Recreate an Azure DevOps Environment
Rebuild a corrupt Azure DevOps Environment (e.g.
Staging, Production, Development
in the HealthAlign PMS project) by renaming the broken record
aside, creating a fresh one with the same name, and re-registering every
swarm-manager agent into it.
Use this runbook when:
- A new Linux swarm manager fails to provision with
its
SetupVMextension reportingVMExtensionProvisioningError, and/var/log/setup-vm.logon the VM ends with:Failed to add virtual machine resource. Linked environment pool is null. - More generally, when adding an agent to an existing Environment fails with “Linked environment pool is null” even though existing resources in that Environment still deploy fine.
Why this happens
Swarm managers register an on-VM Azure DevOps agent as an
Environment → Virtual machines resource during
bootstrap
(infra/ha-infra/scripts/linux/bootstrap/azagent.sh, run
only when is_swarm_manager = true). It authenticates with
the thb-ado-terraform-sp service principal and targets the
Environment name mapped from the deployment environment:
| Deploy env | ADO Environment |
|---|---|
non-production / staging /
uat |
Staging |
production / prod |
Production |
development / dev |
Development |
Legacy Environment records (created early, low environment ID) can end up with a null backing deployment-pool link. Existing resources keep working, but the server rejects new VM-resource registrations with “Linked environment pool is null.” A freshly-created Environment usually gets a correctly-linked pool, so recreating it is the durable fix.
The service principal is not necessarily the problem.
thb-ado-terraform-spregisters environment VM resources fine against a cleanly-provisioned environment (verified onDevelopment). “Linked environment pool is null” is a property of a specific environment’s broken pool — and in practice even a freshly-recreated environment may still reject SP registration while a PAT succeeds. So treat SP registration as best-effort and fall back to a PAT (Stage 3c). The bootstrap (azagent.sh) treats a failed registration as non-fatal — the VM provisions cleanly and logs a warning to/var/log/setup-vm.log— so a manager whose agent didn’t register still comes up healthy, and you register it out-of-band afterward (Stage 4).
This is manager-only. Windows workers (
*-WIN) skip agent registration, so they are unaffected — do not run this for a worker failure.
Prerequisites
- ADO Administrator on the target Environment
(Environment → ⋮ → Security) and on its deployment pool
(Org Settings → Deployment pools → Security) for the
thb-ado-terraform-spservice principal - SSH access to each affected Linux manager (see SSH Into Azure VMs)
- The
thb-ado-terraform-spcredentials:- client_id
af82b357-0019-49ec-859c-c7dc13cc6c76, tenant_id00ff0ed4-7e61-48b7-aac1-14848f6a3574(both ininfra/ha-infra/common.auto.tfvars) - client secret in GCP Secret Manager: secret
ado_sp_client_secretin projectprj-c-secrets-a7cc
- client_id
- A maintenance window for that environment — deployments to it are down from the moment you rename until every manager is re-registered
- Terraform apply access (only if you need to re-run a manager’s IaC bootstrap in Stage 4)
Never paste the client secret into a terminal literal, a chat, or a commit. Always load it into
$SECRETvia thegcloud secretsfetch shown below. If it is ever exposed, rotate it (Entra → app registrationthb-ado-terraform-sp→ Certificates & secrets; add a new version toado_sp_client_secret).
Procedure
Substitute <ENV> (e.g. Staging),
<DEPLOY_ENV> (e.g. non-production), and
each manager <VM_NAME> (e.g.
THB-N-WEB2-LINUX) throughout.
Stage 0 — Rule out the cheap fix first
Recreating is disruptive. First confirm it is actually the corrupt-record case and not a plain permission gap:
- Environment → ⋮ → Security: is
thb-ado-terraform-sppresent as Administrator? - Org Settings → Deployment pools → the pool behind
<ENV>→ Security: isthb-ado-terraform-span Administrator there too?
If the SP is missing/under-privileged on the pool, grant it and re-run the manager bootstrap — that fixes it with no recreate and no downtime. Only proceed with the recreate if the SP already has Administrator on both and registration still fails.
Stage 1 — Rename the broken environment
In ADO → Pipelines → Environments → <ENV>
→ ⋮ → Edit:
- Rename
<ENV>→Old-<ENV>(e.g.Old-Staging) - Add a description noting why (e.g. “Obsolete legacy record; backing pool link broken for new VM-resource registrations.”)
Renaming frees the name for a fresh environment while preserving the old record (and its existing registrations) as a rollback. Names are unique per project, so the rename must happen before the new one exists.
Back up first: screenshot the Environment’s Approvals and checks, Security roster, and Pipeline permissions (which pipelines are authorized to use it). A recreated environment gets a new ID and starts with none of these.
Stage 2 — Create the fresh environment
Either create it in the UI (New environment, name it
exactly <ENV>, resource type None),
or simply let the first agent registration in Stage 3
auto-create it (config.sh --environmentname "<ENV>"
creates the environment and its pool if absent). Re-add everything you
captured: the Security roster
(thb-ado-terraform-sp as Administrator),
approvals/checks, and pipeline
permissions.
Pipelines resolve the environment by name
(environment: <ENV>), so no YAML edits are needed —
but the recreated environment has a new ID, so pipeline
authorizations do not carry over. Re-authorize
each pipeline that deploys to <ENV> (Environment →
Security → Pipeline permissions → add the pipelines, or
enable open access), and confirm a real deployment before deleting
Old-<ENV> (Stage 5).
Stage 3 — Re-register each manager agent
Run this on each affected manager, one at a time,
verifying each before moving on. The agent must live in
/azagent (the IaC/bootstrap location) — do
not create a second copy under ~.
3a — Inventory every agent first (do not assume there’s only one)
A manager can carry more than one agent registration
— a live Environment agent plus a stale Deployment
Group agent left over from an earlier bootstrap (deployment
groups are deprecated; all pipelines moved to YAML/environments). They
can live in different directories
(/azagent vs ~/azagent), so removing “the”
agent blindly leaves a second one running.
Confirm the box, then list every agent service and trace each one:
hostname # confirm the intended VM
systemctl list-units --type=service --all | grep -i vsts # may show 1, 2, or moreFor each service listed, find its backing directory and what it’s registered to:
systemctl cat '<full-service-name>' | grep -iE 'ExecStart|WorkingDirectory' # → the agent dir
cat <that-dir>/.agent # agentName / poolName / serverUrlRead the .agent JSON: a deploymentGroupId
field (and an old visualstudio.com serverUrl)
marks a legacy Deployment Group agent — remove it,
don’t keep it. An Environment agent has no
deploymentGroupId. You must remove all of
them before re-registering, or the box ends up with
duplicate/competing agents.
3b — Remove every agent found, then re-register once
For each agent directory identified above (commonly
/azagent, and ~/azagent if a stray
exists):
SECRET=$(gcloud secrets versions access latest --secret=ado_sp_client_secret --project=prj-c-secrets-a7cc)
cd <agent-dir> # repeat this block for every dir from 3a
sudo ./svc.sh stop && sudo ./svc.sh uninstall
# Pass the secret via env var (VSTS_AGENT_INPUT_CLIENTSECRET), not argv, so it
# never appears in `ps`.
VSTS_AGENT_INPUT_CLIENTSECRET="$SECRET" ./config.sh remove --auth SP \
--clientid af82b357-0019-49ec-859c-c7dc13cc6c76 \
--tenantid 00ff0ed4-7e61-48b7-aac1-14848f6a3574A legacy deployment-group agent (old visualstudio.com
server) may fail config.sh remove with SP auth — that’s
fine, it still clears the local
.agent/.credentials; if it errors hard,
rm -f <dir>/.agent <dir>/.credentials. Then
confirm you’re at zero before re-registering:
systemctl list-units --type=service --all | grep -i vsts # MUST be emptyNow register a single fresh agent from /azagent, and
delete any stray non-/azagent dir afterward
(rm -rf ~/azagent):
cd /azagent
# Register into the fresh environment (secret via env var, not argv)
VSTS_AGENT_INPUT_CLIENTSECRET="$SECRET" ./config.sh --unattended --environment --environmentname "<ENV>" \
--acceptteeeula --agent "$HOSTNAME" \
--url https://dev.azure.com/thehelperbees/ \
--work _work --projectname "HealthAlign PMS" \
--runasservice --replace \
--addvirtualmachineresourcetags --virtualmachineresourcetags "$HOSTNAME" \
--auth SP \
--clientid af82b357-0019-49ec-859c-c7dc13cc6c76 \
--tenantid 00ff0ed4-7e61-48b7-aac1-14848f6a3574
sudo ./svc.sh install HALocalAdmin && sudo ./svc.sh start && sudo ./svc.sh status
unset SECRET # done with the secretExpected: config.sh completes with no
“Linked environment pool is null”; svc.sh status shows
“Listening for Jobs”; and the manager appears online in
<ENV> → Resources.
(--virtualmachineresourcetags "$HOSTNAME" tags the resource
with the box name so pipelines can target it.)
3c — If SP registration fails: fall back to a PAT
If config.sh above fails with
Failed to add virtual machine resource. Linked environment pool is null
(HTTP 500), the environment’s pool won’t accept the SP.
This is environment-specific (the SP works elsewhere), and the reliable
fallback is a PAT:
- In ADO, open the environment → Resources → Add resource →
Virtual machines. ADO generates a registration script
containing a short-lived PAT (
--token …) — it mints this on the fly; it is not the SP secret and expires in a few hours. - Re-run the registration from
/azagentwith that token, keeping the rest of the flags (do not run the raw UI script — it does a relativemkdir azagentthat recreates the~/azagentsplit-brain and pulls a different agent version):
cd /azagent
# Token via env var (VSTS_AGENT_INPUT_TOKEN), not argv, so it stays out of `ps`.
VSTS_AGENT_INPUT_TOKEN='<UI-generated-token>' ./config.sh --unattended --environment --environmentname "<ENV>" \
--acceptteeeula --agent "$HOSTNAME" \
--url https://dev.azure.com/thehelperbees/ \
--work _work --projectname "HealthAlign PMS" \
--runasservice --replace \
--addvirtualmachineresourcetags --virtualmachineresourcetags "$HOSTNAME" \
--auth PAT
sudo ./svc.sh install HALocalAdmin && sudo ./svc.sh start && sudo ./svc.sh statusTreat the token as a secret — don’t paste it into chat or commits. (A personal PAT with Agent Pools (Read & manage) scope works too — that’s the documented scope for agent registration; if VM-environment registration still fails, Microsoft suggests scoping the PAT to All accessible organizations.)
Confirm exactly one agent service is present after each box:
systemctl list-units --type=service --all | grep -i vsts # one entry, active (running)Stage 4 — Finish a manager whose bootstrap couldn’t register its agent
azagent.sh treats a failed registration as
non-fatal: if the environment rejects the agent during
bootstrap, it logs a warning to /var/log/setup-vm.log and
exits 0, so setup_vm still succeeds and the VM comes up as
a healthy swarm node — just not registered for
deployments. Look for:
### WARNING: Azure DevOps agent registration FAILED (non-fatal)
To finish it, just register the agent out-of-band — no taint or re-apply needed:
- SSH to the manager and follow Stage 3b (SP) or Stage 3c (PAT, if the environment rejects the SP).
- Confirm it shows online in
<ENV> → Resourcesandsystemctl list-units --type=service --all | grep -i vstsshows one runningvsts.agent.*service.
Recovering a genuinely failed/tainted
setup_vm
Only needed if setup_vm itself is in a failed/tainted
state (e.g. a manager provisioned before the non-fatal change, or a
non-registration failure). Register the agent first
(Stage 3b/3c) so azagent.sh’s skip-logic passes on the
re-run, then re-create the extension.
Development — local apply is allowed.
zig build apply applies the whole environment (it
can’t target one resource), so gate it on the plan:
./zig/zig build plan -- ha-infra developmentReview the plan — it should contain only:
module.managers["<VM_NAME>"].azurerm_virtual_machine_extension.setup_vm (create/replace)
If any other resource appears (unrelated drift, another manager, a
destroy), stop and reconcile first — don’t run a broad
apply that sweeps in unintended changes. Only when the plan is scoped to
that one setup_vm:
./zig/zig build apply -- ha-infra developmentNon-production / production — local apply is
blocked (these applies run in Cloud Build). Delete the failed
SetupVM extension out-of-band so Terraform recreates it on
the next apply:
# Resource group: non-prod = THB-UAT-RG, prod = THB-PROD-RG
az vm extension delete \
--resource-group <THB-UAT-RG|THB-PROD-RG> \
--vm-name <VM_NAME> \
--name SetupVMThen re-trigger the environment’s apply in Cloud Build. Terraform
sees SetupVM missing and recreates it; because the agent is
already registered, azagent.sh skips and the extension goes
green. (Deleting the extension only removes the CustomScript resource —
swarm/docker set up by the earlier run persist.)
Verify: setup_vm shows
Creation complete; the manager is online in
<ENV> → Resources; and
systemctl list-units --type=service --all | grep -i vsts
shows one running vsts.agent.* service.
Stage 5 — Verify, then delete the old environment
Once every manager is online in the fresh
<ENV>:
- Verify a real deployment to
<ENV>succeeds end-to-end (e.g. a WebApp release) — this confirms both agent registration and pipeline authorization (Stage 2) are in place. - Inventory
Old-<ENV>before deleting it. Its Resources tab should list only the migrated swarm-manager agents (now offline). If anything else is registered there — a manually-added agent, a non-manager resource — account for it first; deletion is irreversible and destroys your rollback record. - Delete
Old-<ENV>(⋮ → Delete) once both checks are clean.
Troubleshooting
SP registration fails with “Linked environment pool is null”
The target environment’s backing pool won’t accept the service principal. It’s environment-specific — the same SP works against cleanly-provisioned environments — and it can happen even on a freshly-recreated environment. Register the agent with a PAT instead (Stage 3c). Rotating the SP secret does not help (it’s not an auth failure — the request connects, then 500s on the pool).
config.sh remove says “Does not exist.
Skipping”
Expected for an Environment VM resource — the CLI’s
pool-based removal doesn’t delete the environment-side entry, so the
resource stays listed in the UI. Remove it with
<ENV> → Resources → ⋮ → Delete.
(Local .agent/.credentials are still cleaned
by the command.)
systemctl disable says the unit “does not
exist” but list-units shows it
Unit names escape literal - to \x2d, so
systemctl disable <plain-dashes> doesn’t match the
on-disk file. Remove the unit directory-independently with a glob:
sudo systemctl stop 'vsts.agent.thehelperbees.<ENV>.*<VMshort>*LINUX.service' 2>/dev/null || true
sudo rm -f /etc/systemd/system/vsts.agent.thehelperbees.<ENV>.*<VMshort>*LINUX.service
sudo rm -f /etc/systemd/system/multi-user.target.wants/vsts.agent.thehelperbees.<ENV>.*<VMshort>*LINUX.service
sudo systemctl daemon-reload && sudo systemctl reset-failedThe shell glob on rm matches the escaped filename on
disk, which is why this works where systemctl disable
didn’t. If you prefer an exact target, copy the full escaped unit name
verbatim from systemctl list-units … | grep -i vsts and
pass that quoted name to
systemctl stop/disable and rm
instead of the glob.
svc.sh keeps acting on a stale/old unit, or
an agent appears in two places
svc.sh reads the service name from the
.service file in its current directory. If
you ran config.sh from ~ you created a second
agent under ~/azagent (split-brain) and svc.sh
in /azagent targets the wrong (often already-removed) unit.
Fix: uninstall the real unit by file (see above),
rm -rf ~/azagent, and re-register strictly from
/azagent.
A manager has two (or more) agents
Seen in the wild: an Environment agent in ~/azagent
and a stale Deployment Group agent in
/azagent (or vice-versa), both running. Don’t migrate just
one — see Stage 3a to inventory every vsts.* service, trace
each to its directory (systemctl cat … | grep ExecStart)
and its .agent (a deploymentGroupId = legacy
DG agent), remove all of them to zero, then register a
single fresh agent from /azagent. Leftover deployment
groups can be deleted from ADO afterward (Org/Project Settings →
Deployment groups).
“Cannot configure because it is already configured”
A prior registration succeeded. Run ./config.sh remove …
first (as HALocalAdmin, not
sudo), then re-register.
Agent registers but stays offline / “inactive dead”
The service isn’t started.
cd /azagent && sudo ./svc.sh start && sudo ./svc.sh status
and look for “Listening for Jobs.”
Validation checklist
Edit this pageThe agent must live in
/azagent. Runningconfig.shor the ADO UI-generated command from a home directory creates~/azagent, which diverges from what the IaC bootstrap manages and causessvc.shto operate on the wrong systemd unit. Alwayscd /azagentfirst.