GitHub

RFC: Deploy the InterfaceTaskWorker to the GCP Swarm

Field Value
Author Miko Hadikusuma
Status Accepted for implementation; PHI placement decision outstanding — see Open Items
Created 2026-08-31
Updated 2026-09-02
Context HomeAlign InterfaceTaskWorker (.NET 10, Linux) to the GCP swarm (hb-infra)
Related HomeAlign Swarm Consolidation, Docker Swarm Consolidation
Implemented by healthAlignPMS#2423 (HB-8825, parent HB-8824)
Deployed core-staging ADO run 13239 (2026-08-31); core-prod ADO run 13460 (2026-09-01)

Table of Contents

Summary

The .NET 10 InterfaceTaskWorker is the first HomeAlign workload that can move onto the consolidated GCP swarm without the Windows worker-pool build-out that the HA consolidation RFC prices at 5 to 6 weeks. It is Linux, headless, single-replica, and its image is already built on ubuntu-latest and pushed to GCP Artifact Registry. The cross-cloud Azure SQL firewall allowlisting it depends on is already in place in both environments.

None of the required work is in the application. It is six changes to the deployment contract, all of which follow from one root cause: the worker today runs on a two-node swarm where the Linux node is simultaneously the Raft manager, the deploy target, and the box the container runs on. Every piece of its configuration arrives as a host bind mount placed there moments earlier by the same deploy. On the GCP swarm the task lands on an ephemeral autoscaled MIG worker, and none of those host paths exist.

The recommendation is a phased move that starts with the InterfaceTypes needing no file-share access, which yields a real cross-cloud latency measurement on production data with a one-line rollback.

Scope note. healthAlignPMS#2423 was authored independently and in parallel with this document and carries the implementation, which is now live on core-staging and core-prod. This RFC is therefore not the implementation plan - it is the decision record for the choices that PR makes, what the first real deploy proved or disproved, and what remains open. See Relationship to healthAlignPMS#2423.

Open Items

The implementation has shipped to both environments. These are the things that have not been settled, kept here as an action list rather than only as rows in the risk table further down.

1. PHI placement on the shared production swarm

Status: open. No approving owner or risk acceptance is recorded anywhere. This is why the Status field above is qualified rather than a clean “Accepted”.

core-prod has been running on the shared production swarm since ADO run 13460, processing 837 claims files with the HA SFTP share CIFS-mounted into the task. The HA consolidation RFC argues specifically against that arrangement in its Path 2a and recommends a dedicated HA swarm (2b) if HA workloads go to GCP. So the placement this RFC implemented is the one the parent RFC advises against, and nothing on the record says that trade was accepted.

It has now been decided three times operationally — a staging default, a prod parameter override, and a prod release — rather than once deliberately. healthAlignPMS#2423 still leaves its Auth / PHI-PII exposure change box unchecked. Parent ticket HB-8824’s framing (“consolidate HA VMs onto a single swarm”) may settle it above the PR, but that is an inference, not a record.

What closes this — one of:

  • A named owner accepting shared-swarm placement for HA PHI workloads, recorded here with a date; or
  • A decision to move to a dedicated HA cluster (parent RFC’s Path 2b), with a migration date — in which case this RFC’s placement is superseded and §2’s network and placement choices get revisited; or
  • A documented finding that the parent RFC’s concern does not apply here (for example that the swarm’s isolation properties are sufficient for this workload class), with whatever review that requires.

Who decides: not the author of this RFC. It needs DevOps plus whoever owns the HA compliance posture.

Until then: production releases default to deployTarget: azure-vm, so reaching the swarm stays an explicit per-run choice rather than something a routine release does. That is a holding position, not a resolution.

2. swarmctl rebalance exclusion

Status: open, and this one is live exposure rather than a decision. Both environments run on autoscaling worker pools with no rebalance exclusion, so every scale-out event is a chance for swarmctl to force-update the single-replica worker mid-task, orphan-reset its queue row, and re-run it with duplicated external side effects. See §6 and risk 6.

Closing it is one line in src/services/swarmctl/compose.ymlREBALANCE_EXCLUDED_SERVICES — using the full Swarm service name (<tenant>-<env>-interfacetask-stack_worker); a bare worker matches nothing and looks configured. Infrahive change, so it does not block the app PR. Owner unassigned.

3. Cross-cloud latency measurement

Status: open. Phase 0 was meant to measure this before anything was committed; it was skipped and both environments shipped, so it is now a post-deployment measurement rather than a gate. Scope and exit criterion are in risk 2. Cheap to take now that core-staging is live and cross-cloud. Owner unassigned.

Why This Workload

The HA consolidation RFC’s Path 2 (GCP migration) carries two costs that do not apply here:

  • No Windows tier. Path 2’s dominant line item is a from-scratch Windows MIG with a PowerShell rewrite of the 509-line worker bootstrap. The worker is Linux and joins the existing pool.
  • Not the chatty request path. Path 2’s linchpin risk is moving the transactional PMS webapp away from its co-located Azure SQL databases. The worker is a polling batch host — a poll loop on a 60s default interval dispatching discrete tasks — which tolerates tens of milliseconds of added round-trip far better than a per-request web tier. This is closer to django_homealign, which already runs GCP compute against Azure-resident data.

It is also the smallest available blast radius: headless, no ingress, no published ports, no Cloudflare tunnel, no DNS, one replica.

Current State

The Azure Runtime Today

The worker runs on the HA Azure swarm, which is not one cluster but a set of per-WEB-group two-node swarms: one Linux manager plus one Windows worker, joined by hand (see scripts/manual/readme.md). Non-prod is THB-N-WEB3-LINUX, prod is THB-P-WEB5-LINUX.

flowchart LR
  ADO["Azure DevOps
static-key SSH endpoint"]:::ext subgraph VM["THB-P-WEB5-LINUX - manager AND runtime"] FS["/DevOps/.vars/.core-prod
/etc/gcp/service-account.json
/mnt/ha-sftp-share (CIFS)"]:::data C["worker container
replicas: 1"]:::wkr end SQL["ha-prod1-azsqldb
hACore"]:::data ADO -->|"scp vars + pwsh deploy"| VM FS -.->|"bind mounts"| C C -->|"in-region"| SQL classDef wkr fill:#bbf7d0,stroke:#15803d,color:#06281a classDef data fill:#ddd6fe,stroke:#6d28d9,color:#241152 classDef ext fill:#e5e7eb,stroke:#4b5563,color:#111827

The deploy is an ADO SSH service connection into that VM running compose/deploy_interfacetaskworker.ps1, which docker stack deploys compose/deploy_interfacetaskworker.yml. Because manager and runtime are the same box, host bind mounts are a valid configuration channel. That is the assumption the move breaks.

The Runtime Contract

Everything the container needs at start, and where it comes from:

Need How Azure satisfies it Status on the GCP swarm
Per-tenant vars (Worker.OwnedInterfaceTypeIds, NLog appname/env, connection-string template) Bind mount ./.vars/.${TENANTENVIRONMENT} to /root/.vars, SCP’d by the pipeline Broken — path exists only on the manager
Vault secrets (DB password, EdiFabric license, Sentry DSN, GCS credential) fetch_gcp_secrets.ps1 reads a provisioner SA key file bind-mounted from /etc/gcp/service-account.json Broken — no such file; but the node SA already has the grant
Media/file path /data (types 60 and 7 only) Bind mount of /mnt/ha-sftp-share/HealthAlign/${ENVIRONMENT}, a CIFS mount of Azure Files set up by hasftp.sh Broken — no mount on workers
Image us-central1-docker.pkg.dev/... plus docker stack deploy --with-registry-auth Breaks on scale-out (see §3)
Placement node.labels.os == linux, set by docker_swarm_manager.sh Label does not exist
Network frontend external overlay, created by docker_swarm_manager.sh Network does not exist
Egress to Azure SQL 1433 VM in the same Azure region; per-VM firewall rule Already allowlisted (see below)
Logs and errors NLog to Papertrail plus Sentry, from inside the app Works anywhere with egress
pwsh Installed by pwsh.sh — used by the deploy script, and separately inside the image Not on GCP nodes (in-image copy is unaffected)

What the GCP Swarm Already Provides

Verified against the code and, where noted, against the live cloud:

Capability Evidence
Azure SQL reachability — swarm Cloud NAT egress IPs are allowlisted on both HA SQL servers Rules SWARM_WORKER_NAT_0..3_2026-05-05 present on ha-dev-azsqldb (HA-DEV-SQL-RG) and ha-prod1-azsqldb (HA-PROD1-SQL-RG); confirmed by live az sql server firewall-rule list on 2026-08-31
Vault hop-1 access without a key file — the worker node SA holds project-level secretAccessor on prj-bu1-{n,p}-vault-keys-*, the exact projects fetch_gcp_secrets.ps1 hardcodes swarm_worker/iam.tf worker_vault_keys_accessor; project ids in non-production.auto.tfvars and production.auto.tfvars
Registry read on the-helper-bees swarm_worker/iam.tf worker_thb_ar_reader
Container-level alerting for free — failed tasks, restart loops, container OOM swarm_observability PromQL policies
A precedent for the config-via-node-SA pattern App Service Accountsfetch_secrets.sh bootstraps off the VM SA’s vault-keys grant with no key file on disk

The single most expensive-looking prerequisite, cross-cloud database access, is therefore already done.

Relationship to healthAlignPMS#2423

PR 2423 (HB-8825) adds a deployTarget parameter to the worker’s release pipelines, a bash driver, and a deploy_interfacetaskworker.gcpswarm.yml stack file. It reaches the same conclusions as this RFC on the root cause, placement, networking, the image-pull path, the bash driver, and preserving the Azure path - arrived at separately, which is useful corroboration for both.

The PR has since converged on this RFC in two of the three places the two diverged. Current state:

Topic PR 2423 This RFC Resolution
Provisioner key Now metadata-server auth (83a32782); the cluster-secret version is gone Metadata-server auth using the node SA’s existing vault-keys grant Resolved, agreed. fetch_gcp_secrets.ps1 prefers a mounted key and falls back to the metadata server, so one image serves both clouds. Validated by the staging deploy: the container authenticated hop 1 as the node SA
Vars delivery File-backed swarm secret, still stack-scoped with no explicit name:, so remove-then-deploy is retained Versioned object name, so the deploy is a rolling update Still open. Works for core, but the 64-char cap breaks six tenant/env pairs beyond it, and every deploy tears the stack down mid-task (§1a). The PR did adopt content-addressed naming for the CIFS volume (14d35e51), which is the same technique applied to the other object
The file share local-driver CIFS volume, content-addressed name, deploy-time creds Deferred behind a phase, mounted from the worker bootstrap PR 2423, outright. §5 is rewritten around the CIFS volume; the phasing argument is moot now that staging runs all 20 types including both file-touching ones. Three mount details neither document anticipated are recorded below

The SSH transport divergence is also resolved: the PR moved to the IAP sequence (83a32782), so §4’s four prerequisites are now the PR’s actual prerequisite 1.

Gaps this RFC still carries alone: swarmctl rebalance churn (§6 - zero mentions in the PR diff, and now a live exposure rather than a hypothetical one), the vars-secret naming above, the Phase 0 latency measurement (skipped), and the PHI decision (risk 1, still open for production).

What the first deploy taught us

Staging took five attempts (13231, 13232, 13235, 13237, 13239). All three real failures were in the CIFS mount, and neither document predicted any of them:

  • addr= is load-bearing. Docker’s local driver mounts via the kernel directly rather than shelling out to mount.cifs, and the kernel cannot resolve hostnames. Docker resolves addr= and passes the IP through. Without it the mount fails on name resolution.
  • iocharset=utf8 must be dropped. The GCP worker image’s kernel ships no nls_utf8 module, so the option inherited from the Azure fstab line fails the mount with “can not access a needed shared library”. SMB3 is UTF-16 on the wire regardless.
  • mount.cifs is not required - only the cifs kernel module, which the current worker image already has. This RFC previously called for adding cifs-utils to the worker bootstrap; that was wrong, and it followed from assuming a host mount rather than a driver mount.

And one prediction that did not hold: this RFC rated the SMB dialect as the likeliest first-run failure and recommended vers=3.1.1,seal before any cross-cloud run. Staging mounted successfully on vers=3.0. secureTransferOnly does require an encrypted channel, but SMB 3.0 satisfies it (AES-128-CCM) and the kernel negotiates encryption from the server’s requirement without seal being stated. The 3.1.1 recommendation stands only as a throughput improvement (AES-128-GCM), not a correctness prerequisite - see §5.

Gaps and Proposed Changes

1. Config and Secret Delivery

Problem. Both /root/.vars and the provisioner key arrive as host bind mounts (the key is the host’s /etc/gcp/service-account.json, mounted read-only at /etc/gcp/provisioner-key.json, which is where fetch_gcp_secrets.ps1 looks by default). A bind mount whose host path is missing does not fail the task — Docker creates an empty directory and starts the container. The observable failure is therefore not “mount missing” but a confusing downstream crash: substitute_variables_json.ps1 finds no vars and no secrets, leaves $(SentryDsn) unresolved, and the app dies on Sentry: Invalid URI.

Change 1a — the vars file becomes a Swarm config. Raft distributes configs to whichever node runs the task, which is exactly the property needed. The pipeline step that SCPs .envs/.<tenant>-<env>/ to the manager stays as-is; only the compose changes:

    configs:
      - source: worker_vars
        target: /root/.vars/.interfacetaskworker
        mode: 0400

configs:
  worker_vars:
    # Explicit name: Swarm prefixes stack-scoped configs and the total is capped
    # at 64 chars. Configs are immutable, so the tag makes each deploy a new object.
    name: "itw-${TENANTENVIRONMENT}-vars-${VARS_HASH}"
    file: "./.vars/.${TENANTENVIRONMENT}/.interfacetaskworker"

Version the name on a content hash, not the image tag. Configs and secrets are immutable, so a changed vars file needs a new object name. Keying on ${TAG} is not enough: flipping Worker.OwnedInterfaceTypeIds without a code change redeploys the same tag and collides. The deploy script should compute VARS_HASH as a short digest of the vars file, and prune itw-${TENANTENVIRONMENT}-vars-* objects no longer referenced by a service.

Versioning is also what avoids a stack teardown. With a fixed name, an in-place update whose content changed fails with “secret is in use”, which forces docker stack rm before every deploy - the shape PR 2423 takes, whose driver comments that remove-then-deploy is load-bearing. It is load-bearing for a fixed name. With a versioned name the service simply references a new object and rolls, so the deploy keeps its rollback target and does not tear down the stack’s networks and secrets. For a queue worker the teardown is not free: it kills the in-flight task and orphan-resets its row.

Set the name explicitly regardless, because the stack-scoped default breaks at scale. Swarm caps config and secret names at 64 characters and prefixes stack-scoped ones with <stack>_. That fits for core (56 chars), but the pipelines are per-tenant and six tenant/environment pairs already exceed the cap:

71  healthallianceplan-staging-interfacetask-stack_interfacetaskworker_vars
68  healthallianceplan-prod-interfacetask-stack_interfacetaskworker_vars
67  identityserver-staging-interfacetask-stack_interfacetaskworker_vars
66  networkhealth-staging-interfacetask-stack_interfacetaskworker_vars
65  transamerica-staging-interfacetask-stack_interfacetaskworker_vars
65  abilishealth-staging-interfacetask-stack_interfacetaskworker_vars

Latent today, since the worker’s release pipelines list only core, but it lands the first time the worker follows the legacy service onto another tenant. Same fix as above.

Change 1b — drop the provisioner key mount; authenticate hop 1 from the metadata server. fetch_gcp_secrets.ps1 does a two-hop fetch: a provisioner identity reads ha-<tenant>-vault-key from the vault-keys project, then that vault SA reads ha-<tenant>-vault from the vault-secrets project. Only hop 1 needs the key file, and on a GCP swarm node the node SA already holds that grant. Add a metadata-server path, keeping the key file as the fallback so one image works on both clouds during the parallel run:

function Get-MetadataAccessToken {
    $uri = 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token'
    (Invoke-RestMethod -Uri $uri -Headers @{ 'Metadata-Flavor' = 'Google' }).access_token
}

# Prefer a mounted provisioner key (Azure); fall back to the node's own identity
# (GCP swarm), where the compute SA holds secretAccessor on the vault-keys project.
$provisionerToken = if (Test-Path $ProvisionerKeyPath) {
    $key = Get-Content $ProvisionerKeyPath -Raw | ConvertFrom-Json -AsHashtable
    Invoke-WithRetry -Description 'provisioner auth' -ScriptBlock { Get-GcpAccessToken -ServiceAccountKey $key }
} else {
    Invoke-WithRetry -Description 'metadata auth' -ScriptBlock { Get-MetadataAccessToken }
}

Note the direction of the fallback. The current script exit 1s when the key file is absent; preserve a hard failure when both paths fail. There must be no silent degradation to an unauthenticated fetch.

Adopted, and validated by the staging deploy. PR 2423 initially shipped the key as a pre-created cluster secret and moved to this design in 83a32782; the container now authenticates hop 1 as the node SA on GCP and via the mounted key on Azure, so one image serves both clouds. The rejected alternative - a long-lived private key in Raft, materialising on every node that runs the task, for an identity the node already has - is no longer on the table.

One ordering dependency this RFC missed. The fallback lives in the base-app image (haap-interfacetaskworker), not the app image, so the base image has to be rebuilt and pushed before the first app build that a swarm deploy consumes. Otherwise the container looks for a key file that is not mounted and the fetch hard-fails at startup. The PR captures this as its prerequisite 2; it belongs in any similar port of a base-image-mediated contract.

2. Placement and Networking

node.labels.os == linux is an artifact of HA’s mixed-OS two-node swarms; GCP nodes carry only role=manager, set in manager-startup.sh.tftpl. The frontend overlay is likewise created by HA’s bootstrap and does not exist on the GCP clusters.

The worker is headless, so it needs no shared overlay - only egress. Note that dropping the networks block does not leave the service with no network: docker stack deploy still creates a stack-scoped <stack>_default overlay and attaches the service to it. That is fine (it is isolated per stack and costs nothing), but the accurate statement is “no cluster-wide overlay dependency”, not “no overlay”. Declaring a stack-local network explicitly, as PR 2423 does, makes the same outcome legible. Either way, constrain on role:

      placement:
        constraints:
          - node.role == worker

Keep replicas: 1. The single-replica constraint is a correctness requirement, not a capacity choice: PollingWorker.ExecuteAsync calls ResetStartedTaskQueueItems(_owned) on startup, and that reset is scoped to owned InterfaceTypes but not to the current process, so a second replica would clear a row the first is mid-processing.

3. Image Pull

The GCP swarm deliberately avoids --with-registry-auth because of moby/moby#31063: the token baked into the service spec has a roughly 1-hour TTL, and a MIG worker created more than an hour after the last deploy receives an already-expired credential and never pulls. Instead every node runs docker-gcr-proxy on 127.0.0.1:7676 and injects a locally-refreshed token.

The constraint: the proxy’s upstream is hardcoded to https://gcr.io in proxy.go. The worker image lives at us-central1-docker.pkg.dev/the-helper-bees/healthalignpms/interfacetaskworker, which the proxy cannot reach. Every other swarm stack resolves images as ${IMAGE_REGISTRY:-gcr.io}/the-helper-bees/....

Recommendation: also push the worker image to gcr.io/the-helper-bees/healthalignpms/interfacetaskworker by adding a second tag to the Docker@2 step in build.yml, and parameterise the compose reference:

    image: "${IMAGE_REGISTRY:-us-central1-docker.pkg.dev}/the-helper-bees/healthalignpms/interfacetaskworker:${TAG}"

with the swarm deploy exporting IMAGE_REGISTRY=docker-gcr-proxy:7676. This leaves the Azure deploy on its current path unchanged (no IMAGE_REGISTRY set, so the AR default applies), which matters during the parallel run.

The alternative, teaching the proxy a configurable Artifact Registry upstream, is the cleaner long-term fix and probably a small change, but it touches a component every stack on both clusters depends on for image pulls. It should be its own change, not a rider on this migration.

⚠️ Do not simply keep --with-registry-auth. It succeeds on the first deploy and fails silently on the next scale-out, which is the worst possible failure signature.

4. Deploy Path

Three things break at once: the transport (ADO SSH service connections use a static key, while GCP managers set enable-oslogin=TRUE and block-project-ssh-keys=TRUE and are IAP-only), the interpreter (no pwsh on GCP nodes), and parts of the script that only made sense on a private VM.

Recommendation: keep ownership with the HA ADO pipelines, and drive the manager from the agent pool over IAP. The existing deployFromAgentPool: true shape already runs the deploy from ADO Deploy Helper rather than a VM agent, so only the step template changes — replacing deploy-over-ssh.yml’s SSH@0 task with the ephemeral-key plus OS Login plus IAP sequence that gc_deploy_to_swarm.yml already proves out:

gcloud auth activate-service-account --key-file="$SA_KEY"
ssh-keygen -t ed25519 -N '' -f "$KEY" -C "ado-itw-deploy"
gcloud compute os-login ssh-keys add --key-file="$KEY.pub" --ttl=60m

# Stage the driver, the stack file, and the tenant's vars together. The compose
# file resolves the vars secret as ./.vars/.<tenant>-<env>/.interfacetaskworker
# relative to itself, so all three must land in the same directory.
STAGE=$(mktemp -d)
cp compose/deploy_interfacetaskworker_gcpswarm.sh \
   compose/deploy_interfacetaskworker.gcpswarm.yml "$STAGE/"
mkdir -p "$STAGE/.vars"
cp -r "$ARTIFACT/.envs/.${TENANT}-${ENV}" "$STAGE/.vars/"

# --ssh-key-file is required: without it gcloud uses ~/.ssh/google_compute_engine,
# not the ephemeral key just registered with OS Login. Copy the directory itself,
# not a glob -- "$STAGE/*" would silently skip .vars, which is a dotfile.
gcloud compute scp --tunnel-through-iap --ssh-key-file="$KEY" --zone "$ZONE" \
  --recurse "$STAGE" "$MGR:~/interfacetaskworker"
gcloud compute ssh --tunnel-through-iap --ssh-key-file="$KEY" --zone "$ZONE" "$MGR" \
  --command "sudo bash ~/interfacetaskworker/deploy_interfacetaskworker_gcpswarm.sh core prod $SHORT"

gcloud compute ssh --tunnel-through-iap subsumes hb-ansible’s explicit ProxyCommand; the manager is addressed by instance name and zone, not IP.

The staging step is the easiest part to get subtly wrong. The vars file arrives from the build artifact as .envs/.<tenant>-<env>/ but the compose file expects it at .vars/.<tenant>-<env>/, so the transfer both renames the parent directory and has to keep the tenant directory intact. Getting it wrong does not fail the copy - it fails the driver’s own missing ./.vars/... precondition, which is the good outcome. The bad outcome is a partial copy that leaves a stale vars file from an earlier deploy in place, which deploys successfully with the wrong owned-type set.

Port the deploy script to bash, and while porting, delete three single-VM artifacts:

Remove Why
The mpstat CPU gate (WaitForCpuBelowThreshold) Meaningless on an autoscaled MIG; the point of the pool is that capacity is elastic
docker stack rm before deploy (-RemoveStack $true) Acceptable on a private box, a self-inflicted outage window on a shared cluster. Use a rolling update
--with-registry-auth §3

Keep the convergence wait (IsStackRunning plus deadline) — it is the deploy’s only real health signal.

New IAM required (Terraform, hb-infra). The deploying identity needs three grants, not two: roles/iap.tunnelResourceAccessor on the swarm env project, roles/compute.osAdminLogin (not plain osLogin - the driver needs sudo for docker), and roles/iam.serviceAccountUser on the managers’ runtime service account (google_service_account.swarm_manager). The third is easy to miss and is not optional: OS Login checks iam.serviceAccounts.actAs on a VM’s attached service account at every connection, precisely so SSH access cannot be used to escalate into that account. roles/compute.osAdminLogin already carries the instance and project discovery permissions gcloud compute ssh needs. Which identity is an open question (see Risks): the ADO agent already holds a GCP identity for pushing to Artifact Registry, and there is separate in-flight work on keyless ADO-to-GCP auth worth aligning with rather than minting another long-lived key.

Why not a plain ADO SSH service connection to a manager, the shape PR 2423 originally took before moving to this sequence in 83a32782. It is not impossible, but it carries four prerequisites that are easy to under-count - the PR now folds the first two into its own prerequisite 1:

  1. The POSIX username is derived, not chosen. With enable-oslogin=TRUE the account is sa_<uniqueId> for a service account (<user>_<domain>_<tld> for a human), so the endpoint’s username has to match what OS Login mints.
  2. Two grants, not one. roles/compute.osAdminLogin for passwordless sudo and iam.serviceAccountUser on the manager’s runtime SA - see the comment block in swarm_manager/main.tf.
  3. A firewall rule for :22 from the ADO agent’s egress IP to the manager’s external IP. The swarm’s SSH posture is OS Login plus IAP, and the tag-scoped rules live in the separate thehelperbees/gcp-networks repo - a cross-team change, not a portal click. This is the prerequisite most likely to be missed, because everything else can be staged before it and the failure looks like a hung connection.
  4. It pins one manager. Any manager can serve docker stack deploy, so leadership is irrelevant, but the endpoint becomes a deploy SPOF and its host key changes whenever the image-upgrade runbook rebuilds that node.

The IAP path avoids 3 and 4 outright and makes 1 and 2 explicit in the pipeline rather than latent in a service-connection definition.

5. The Azure Files Share

This is the only genuinely hard gap, and it is narrower than it first appears. Only two of the worker’s modules touch the media path — X12837FileWriter (type 60) and ChangeHealthcareImportResponseFiles (type 7). Prod currently owns types 60,55,47, so exactly one type is affected there.

The path is a CIFS mount of //hasftp.file.core.windows.net/ha-sftp-share. The storage account is permissive on the network side — publicNetworkAccess: Enabled, networkRuleSet.defaultAction: Allow, no IP rules (verified live 2026-08-31) — so no allowlisting work is required. What is required:

Mechanism: a per-node CIFS Docker volume, not a host mount. This RFC originally proposed adding the mount to worker-startup.sh.tftpl. PR 2423’s approach is better and is adopted here: declare a local-driver volume with CIFS driver_opts in the stack file, with credentials fetched at deploy time from Secret Manager using the manager’s own metadata identity, so no credential rides through ADO.

volumes:
  hasftp:
    driver: local
    driver_opts:
      type: cifs
      device: "//hasftp.file.core.windows.net/ha-sftp-share/HealthAlign/${ENVIRONMENT}"
      # addr= and the absence of iocharset are both load-bearing - see below.
      o: "addr=hasftp.file.core.windows.net,username=${HASFTP_USERNAME},password=${HASFTP_PASSWORD},file_mode=0777,dir_mode=0777,vers=3.0"

Two properties make this the right shape. Docker establishes the mount on whichever node runs the task, so nothing has to be pre-mounted on an autoscaled pool; and a failed mount fails the task rather than silently presenting an empty directory the way a bind mount over a missing path does. That closes the silent-failure hole this RFC previously had to warn about: X12837FileWriter can no longer report success while writing into the void.

What still has to be true:

  1. The cifs kernel module - but not cifs-utils. Docker’s local driver issues the mount syscall itself rather than shelling out to mount.cifs, so only the kernel module is needed and the current worker image already ships it. No swarm_worker bootstrap change is required. (An earlier revision of this RFC called for adding cifs-utils; that followed from assuming a host mount and was wrong.)
  2. addr= in the options. Because the kernel performs the mount directly, it cannot resolve the hostname in device:. Docker resolves addr= and passes the address through. Omitting it fails the mount on name resolution - one of the three real staging failures.
  3. No iocharset=utf8. The GCP worker kernel ships no nls_utf8 module, so the option inherited from hasftp.sh’s in-region fstab line fails the mount with “can not access a needed shared library”. SMB3 is UTF-16 on the wire regardless, and the default codepage covers the ASCII filenames this share exchanges.
  4. Encryption is mandatory but vers=3.0 satisfies it. The hasftp account has secureTransferOnly: true (verified live 2026-08-31), so Azure Files refuses an unencrypted channel. An earlier revision of this RFC concluded that the inherited vers=3.0 was therefore the likeliest first-run failure and prescribed vers=3.1.1,seal. That was wrong: staging mounted cleanly on vers=3.0, because SMB 3.0 carries AES-128-CCM and the kernel negotiates encryption from the server’s requirement without seal being stated. Moving to vers=3.1.1 buys AES-128-GCM and better throughput on the internet path, which is worth doing if the 837 volumes grow - as an optimisation, not a prerequisite.
  5. Credential and option changes must change the volume name. Docker volumes keep the driver_opts they were created with, so a fixed name means any change - a cred rotation, a mount-option fix - silently keeps mounting with stale options on every node that already materialised it. PR 2423 solves this by content-addressing the volume name from a fingerprint of the compose file plus the creds (14d35e51), so a change forces every node to materialise fresh and orphaned definitions (which hold no data) die with node recycling. This is the same technique §1a asks for on the vars object, and it is strictly better than this RFC’s original advice to remove the volume by hand after a rotation.

⚠️ The CIFS credential is readable from the node. Docker’s local driver has no credentials-file equivalent, so username=/password= are stored verbatim in the volume’s metadata and returned by docker volume inspect. Anyone who can reach the Docker socket on a worker can read the storage-account key. Docker-socket access is already root-equivalent, so this widens exposure from “root on the node” to “root on the node, plus anything that can query the daemon” rather than creating a new class of access - but it is a real difference from the Azure VMs, which keep the key in a 0600 root-owned credentials file outside Docker. The alternative that avoids it is the host-mount-plus-bind-mount shape this RFC originally proposed, which trades the exposure for the autoscaling and silent-failure problems §5 exists to solve. Worth an explicit decision rather than an accident; at minimum, rotate the key on any node compromise and keep it out of any support bundle that captures docker volume inspect.

No storage-firewall work is required, contrary to a common assumption. publicNetworkAccess: Enabled, networkRuleSet.defaultAction: Allow, ipRules: [] — there is nothing to allowlist for the swarm NAT egress IPs, and GCP does not block outbound 445 (it blocks 25). PR 2423 lists this as a prerequisite; it is already satisfied.

That is a statement about what the migration has to do, not an endorsement of the posture. The share is reachable from any network that holds the storage key, and after this migration it is on the PHI path for two clouds instead of one. Restricting it to the swarm Cloud NAT egress IPs plus the existing Azure consumers is a small, well-understood change (defaultAction: Deny plus ipRules), and a private endpoint is the stronger form. Neither is required to ship, and neither is in this RFC’s scope - but shipping without recording the choice would let the migration quietly inherit an open door. Tracked as risk 10.

Even with a good mechanism the ordering question stands for production. Staging settled it by events - it moved all 20 types at once and debugged the mount in place - but prod owns type 60, so the same choice recurs there:

Option Assessment
Defer — leave type 60 on THB-P-WEB5-LINUX, move 55 and 47 first Weaker now than before staging ran: the mount options are known-good, so the unknown this would isolate is largely gone. Still buys a clean latency read on prod data, and still costs a second worker deployment (below)
Move everything at once PR 2423’s shape, and what both environments did. Reasonable for prod now that the mount options are proven, provided the PHI decision (risk 1) has been made and swarmctl rebalance (§6) is excluded first
Move the exchange path to GCS The right long-term answer, and it deletes the cross-cloud SMB dependency outright. But it touches external SFTP counterparties and is a separate project

Splitting types across two worker deployments is safe. Two workers with disjoint owned sets never reset each other’s rows, because ResetStartedTaskQueueItems(int[] owned) filters on the owned set. The legacy service’s ExcludedInterfaceTypeIds becomes the union of both workers’ owned sets; its own reset already skips worker-owned types via BuildExcludedTypeClause. The mechanical cost is a second vars file and a distinct stack name, since Worker.OwnedInterfaceTypeIds is one CSV per deployment.

6. Rebalance Churn

New exposure the Azure box does not have: when the autoscaler adds a worker, swarmctl force-updates services so Swarm spreads tasks onto it. For a single-replica order: stop-first service, a force-update is a restart mid-task — the queue row is orphan-reset and the task re-runs. Module side effects are already required to be idempotent (a single service is at-least-once by design), so this is a churn and duplicate-side-effect concern rather than a corruption one.

swarmctl already supports an exclusion list: REBALANCE_EXCLUDED_SERVICES, a CSV of service names read in config/rebalance.go. It is currently unset in src/services/swarmctl/compose.yml. Add the full Swarm service name there - docker stack deploy prefixes the stack, so the value is <tenant>-<env>-interfacetask-stack_worker (for example core-staging-interfacetask-stack_worker), not the bare worker from the compose file. A short name silently matches nothing and the exclusion appears to be configured while every rebalance still restarts the task. Autoscaler scale-in drain and MIG autohealing still apply, which is the behaviour we want.

PR 2423 does not address this — a diff-wide search for rebalance returns nothing — so it is the one item in this RFC with no counterpart in the implementation.

Proposed Compose File

Phase-1 shape: no file share, no frontend, config-based vars, metadata-server secrets.

services:
  worker:
    # IMAGE_REGISTRY is set to docker-gcr-proxy:7676 by the swarm deploy; unset on
    # the Azure path, which keeps pulling directly from Artifact Registry.
    image: "${IMAGE_REGISTRY:-us-central1-docker.pkg.dev}/the-helper-bees/healthalignpms/interfacetaskworker:${TAG}"
    environment:
      - TZ=America/Chicago
      - ENVIRONMENT=${ENVIRONMENT}
      # Reuses each tenant's existing legacy vault: VAULT_APP selects
      # `ha-<tenant>-vault` (+ `-vault-key`); ENVIRONMENT selects the n/p project.
      - VAULT_APP=ha-${TENANT}
      # Ownership is NOT set here — this file is tenant/env-agnostic. Each
      # deployment sets Worker.OwnedInterfaceTypeIds in its vars config below.
    configs:
      - source: worker_vars
        target: /root/.vars/.interfacetaskworker
        mode: 0400
    deploy:
      # Single replica is a correctness requirement, not a capacity choice: the
      # startup reset is scoped to owned types but not to this process.
      replicas: 1
      placement:
        constraints:
          - node.role == worker
      restart_policy:
        condition: any
        max_attempts: 1
        window: 120s
        delay: 5s
      resources:
        limits:
          cpus: "2.0"
          memory: 4G
      update_config:
        parallelism: 1
        delay: 10s
        failure_action: rollback
        order: stop-first

configs:
  worker_vars:
    # VARS_HASH is a short digest of the vars file, computed by the deploy
    # script: the tag alone collides when only the vars content changed (§1a).
    name: "itw-${TENANTENVIRONMENT}-vars-${VARS_HASH}"
    file: "./.vars/.${TENANTENVIRONMENT}/.interfacetaskworker"

Removed relative to today: the /root/.vars and provisioner-key bind mounts, the /data bind mount, the frontend network, and the os == linux constraint.

Phased Rollout

Phase Scope Exit criteria
0. Latency spike Point a throwaway worker container on a non-prod swarm worker at ha-dev-azsqldb. Time one real task of each owned type against the same task on THB-N-WEB3-LINUX A per-type wall-clock comparison. A pathological amplification here changes the plan
1. Build the path §1 through §4 and §6: metadata-server auth, vars config, compose rewrite, gcr.io image tag, bash deploy over IAP, swarmctl exclusion, IAM grant Worker runs on the non-prod swarm owning a single harmless type; the startup log shows the expected owned set
2. Staging coexistence Move staging’s 18 non-file types to the GCP deployment; keep 60 and 7 on THB-N-WEB3-LINUX as a second deployment. Legacy ExcludedInterfaceTypeIds equals the union Dispatched equals S plus F across a settle window; no type appears in both services’ logs
3. Production, no file types Types 55,47 on the GCP swarm; type 60 stays on THB-P-WEB5-LINUX Not taken. Production moved all three owned types (60,55,47) at once on run 13460 Superseded. What still wants watching is a clean run across a full schedule cycle, including the type-55 GCS credential path
4. File share §5 for the remaining types, via the adopted local-driver CIFS volume - not a worker-bootstrap host mount, which §5 supersedes (only the cifs kernel module is needed, and the volume already fails the task on a bad mount). The GCS re-platform stays the long-term alternative Type 60 output byte-identical to the Azure run. Done in both - core-staging run 13239, core-prod run 13460

Handoff within each phase follows the established order — deploy legacy-excludes-first, drain in-flight rows to zero, then start the worker — because exclusion stops the legacy service claiming new tasks but does not stop it finishing current ones.

What actually happened. PR 2423 collapsed phases 2 through 4 for staging - deployTarget defaulted to gcp-swarm there at the time, and staging owns both file-touching types - so the first run exercised the new transport, the GCR pull, the metadata-server secret fetch, and cross-internet CIFS at once. It took five attempts (ADO runs 13231 through 13239), and all three real failures were mount options (see What the first deploy taught us). Phase 0 was skipped, so there is still no cross-cloud latency measurement.

Production then did the same thing: run 13460 moved all three owned types at once, including type 60, rather than taking Phase 3’s file-types-last split. Its two failures were both prerequisites rather than mount options - the ADO agent IAM trio (run 13450) and the manager’s access to hasftp-cifs-creds (run 13458) - because staging had already absorbed the mount debugging. Both were hand-applied and are now codified in infrahive#1351 and infrahive#1353.

Read honestly, the collapsed rollout argues for neither position outright. It cost four failed staging runs on an environment where failed runs are cheap, and it surfaced three findings a phased approach would have hit later and one at a time. What it did cost is the Phase 0 measurement: production shipped without a cross-cloud latency number, so the assumption this RFC most wanted evidence for in advance is now something to measure after the fact rather than a gate that was passed. Taking it against either environment is still close to free and worth doing.

Rollback at every phase is a vars change plus a redeploy: hand the types back to the Azure deployment and shrink the GCP deployment’s owned set. No data migration is involved at any point, because the database never moves.

Risks and Open Questions

# Risk or question Mitigation or owner
1 PHI on the shared production swarm. The HA consolidation RFC explicitly flags placing HA PHI workloads in the same Raft cluster and ingress mesh as every other app (its Path 2a) and recommends a dedicated HA swarm (2b) if going to GCP. Deploying this worker there is a small instance of exactly that decision Must be an explicit call, not a side effect of a low-risk-looking migration. If the answer is “dedicated cluster only”, this RFC is superseded by 2b’s scope. Now live, not hypothetical: core-prod runs on the shared production swarm as of run 13460, processing 837 claims files with the HA SFTP share CIFS-mounted into the task. PR 2423 still leaves its Auth / PHI-PII exposure change box unchecked. Parent ticket HB-8824 may settle this above the PR, but nothing on the record does, and the decision has now been made three times operationally rather than once deliberately. No approving owner or risk acceptance is recorded anywhere. Tracked as open item 1, which carries what would close it and who decides
2 Cross-cloud latency on the poll loop and per-row EF Core writes Phase 0 measures it before anything is committed. Phase 0 was skipped and both environments shipped, so this is now a post-deployment measurement, not a gate. Scope: time one dispatch-to-completion cycle per owned type on core-staging (live and cross-cloud) against the same task on THB-N-WEB3-LINUX; exit criterion is no per-type regression beyond one poll interval. Owner unassigned
3 Silent empty-directory failure for any missing bind mount Phase 1 removes all bind mounts; Phase 4 must add a hard mount gate
4 Which identity deploys, and whether it uses a long-lived key Align with the in-flight keyless ADO-to-GCP work rather than minting a new key
5 Config immutability leaks objects — one itw-*-vars-* per deploy Prune unreferenced configs in the deploy script
6 Rebalance restarts a task mid-run. No longer hypothetical: core-staging is live on the shared non-prod swarm, whose worker MIG autoscales roughly 12 to 18, and the PR diff contains no rebalance handling REBALANCE_EXCLUDED_SERVICES (§6). This is an infrahive change, so it does not block the PR - but it is now the highest-priority actionable open item, because every scale-out event is a chance to re-run a task. Tracked as open item 2
7 Two deployments during phases 2 and 3 double the places OwnedInterfaceTypeIds can drift out of sync with the legacy exclusion set Both services log their partition at startup — make comparing them a step in the deploy checklist

| 8 | Swarm config/secret names exceed the 64-char cap on six tenant/environment pairs once the worker moves past core | Explicit name: on the object (§1a), which is also what enables the rolling update | | 9 | Cross-internet SMB has never been exercised. Closed. Exercised by ADO run 13239 on vers=3.0; the dialect concern was a throughput question, not a correctness one (§5 item 4) | Retained as a record of a wrong call. The real mount hazards were addr= and iocharset, which this RFC did not anticipate | | 10 | The hasftp share is reachable from any network holding the storage key, and this migration puts it on a second cloud’s PHI path | Restrict to the swarm NAT egress IPs plus existing Azure consumers (defaultAction: Deny + ipRules), or a private endpoint. Not required to ship; record the decision either way | | 11 | The CIFS credential is exposed via docker volume inspect on any worker that materialises the volume (§5) | Docker-socket access is already root-equivalent, so treat as exposure widening rather than a new class. Rotate on node compromise; keep docker volume inspect out of support bundles |

Effort

Item Estimate
Phase 0 latency spike 1 day
Phase 1 (secrets and config rework, compose, image tag, bash deploy over IAP, IAM, swarmctl exclusion) ~1 week
Phase 2 staging validation 2 to 3 days
Phase 3 production (types 55 and 47) 2 days
Subtotal, everything except the share ~2 weeks
Phase 4, CIFS mount in the worker MIG 1 to 2 weeks
Phase 4 alternative, GCS re-platform Separate project — external counterparties

Compare against the HA consolidation RFC’s Path 2 at 5 to 6 weeks, which buys the Windows tier this workload does not need.

Alternatives Considered

  • Leave it on Azure. Zero cost, and legitimate if the answer to risk 1 is “no HA workloads on the shared swarm”. The worker keeps a statically-sized VM with no autohealing and no graceful drain — the same gap the consolidation RFC describes for the whole HA tier.
  • Wait for the dedicated HA GCP swarm (Path 2b). Correct if the PHI-isolation answer forces it; this work then becomes the first stack on that cluster rather than an addition to the shared one. Most of §1 through §4 is reusable either way.
  • Deploy via AWX and gc_deploy_to_swarm.yml. Reuses a proven IAP path, but that playbook is heavily Django-shaped (migration detection, celery scaling, Caddy, canary) and would need real surgery. A short bash script driven from the existing ADO pipeline is smaller and keeps HA deploys in one place.
  • Ship the provisioner SA key to GCP workers as a Swarm secret. Works, but puts a long-lived private key in Raft to obtain an identity the node already has.

Verification Checklist

Per phase, before declaring done:

References

Edit this page