Customer deploy runbook — a fresh AWS account in 8 steps

The managed install into one dedicated AWS account in eight steps, with upgrade, rollback, teardown, costs, licence and secret rotation.

Reference · Updated 2026-10-19 · Describes release 0.1.164

Managed BYOC, profile 1 (decision brief 2026-09-18; decision log §6.4): the customer owns one dedicated AWS account and one IAM role our pipeline may assume. Our Azure Pipelines create the stack in that account through that role and helm upgrade --install our OCI chart. Nothing of ours runs 24x7 in their account beyond the platform itself: no ArgoCD, no licence server, no Kyverno.

Files this runbook drives:

Piece Path
Customer script (they run it) deploy/customers/bootstrap.sh + aiops-deployer-trust.json, aiops-deployer-policy.json beside it
Our onboarding script scripts/customer-onboard.py (mint, render, ado; rotate-key, retire-key in §12.1)
Terraform env template infrastructure/iac/envs/_customer-template/ (+ its README)
Provider role chain, state region infrastructure/iac/terragrunt.hcl (deploy_role_arn, deploy_external_id, state_region)
Our-side pull grant infrastructure/iac/envs/<host-env>/env.hcl (our sandbox host environment) platform_image_pull_account_ids, units ecr, ecr-charts
Build: images + chart + signature .azuredevops/build-ecr.yml
Install / upgrade per customer .azuredevops/deploy-customer.yml, .azuredevops/steps/customer-env.sh
Teardown per customer .azuredevops/destroy-customer.yml
Values deploy/values-customer.example.yaml -> deploy/customers/<slug>/values.yaml (rendered)
Post-install smoke scripts/customer-smoke.sh
Chart deploy/aiops-helm (pushed as oci://<our-registry>/charts/aiops:<version>)

<slug> is one string used everywhere: env dir, values dir, branch customer/<slug>, ADO Environment aiops-<slug>, variable group aiops-<slug>-deploy. The per-customer files carry the customer's account id and host name, so they live on branch customer/<slug> and never on main.

0. The whole install

The host owner is whoever holds the DNS zone of the platform's host name: us for a test host of ours, the customer for their own domain.

# Customer Vendor Time (6 October 2026 rehearsal)
1 — Mint the ExternalId and the app secrets (customer-onboard.py mint); send the zip it names, and the ExternalId in a separate message not measured
2 In CloudShell opened in the install region: bootstrap.sh plan, then apply; send back the JSON it saves — plan 14 s, apply 19 s
3 Host owner: add the certificate validation CNAME the bootstrap printed; wait for ISSUED (host owner, for our test host) not measured: the certificate was already ISSUED and was reused
4 — render on branch customer/<slug>, ado + an approver on the Environment, the pull grant on main not measured; the pull grant already existed
5 Reachable during the booked window deploy-customer.yml with bootstrapDb=true; approve about 27 min from approval to the passing HTTPS health gate, pipeline waits included; inside it, infrastructure apply 18 min 21 s and Helm install 2 min 17 s
6 Host owner: CNAME the host name to the load balancer the deploy printed (host owner, for our test host) not measured
7 — Model price map if needed, then scripts/customer-smoke.sh real-model smoke 34 s
8 Log in, change the admin password, connect the IdP, the first read role and the alert sources Publish the workspace's runtime reader under /ops not measured
T bootstrap.sh delete, the DNS records, the final snapshot once sure destroy-customer.yml; pull grant, variable group, Environment not measured

Times are those observed in the 6 October 2026 rehearsal on build 0.1.105 (02-install.md): measurements, not an SLA. The step 5 phases overlap and sit inside its interval, so do not add them up.

1. Before you start

1.1 The account

1.2 Region

1.3 Model

Check before booking the install date: on 2026-09-28 AWS refused Anthropic models for our test account even after the form ("contact AWS Sales").

1.4 What the deploy role can do

aiops-deployer holds one inline policy, aiops-deployer-policy.json: what the _customer-template units create, unit by unit (checked against the module sources: vpc 6.0.1, security-group 5.3.0, eks 21.0.6, rds 6.10.0, vpc-endpoints 6.0.1, and the local ecr-cache / iam / alb-access / cost-guardrails tf).

Statement Needed by
TheStackItself — ec2:*, eks:*, ecr:*, rds:*, elasticloadbalancing:*, logs:*, cloudwatch:DescribeAlarms, KMS key admin, sts:GetCallerIdentity, tag:GetResources network, rds-sg, alb-access, endpoints (EC2); eks (cluster, access entries, log group, KMS key + alias for secrets encryption); iam (Pod Identity associations); ecr-cache (pull-through rule + cached repository); rds (instance, subnet + parameter group, reading its alarms); the pipelines' own snapshot / describe / kubeconfig / leftover calls
ServiceLinkedRoles first EKS cluster, first RDS instance and first ECR pull-through rule in a fresh account (AWSServiceRoleForAmazonEKS, ...ForRDS, ...ForECRPullThroughCache), created on first use through this role; the bootstrap does not pre-create them
IamForTheStack on role/aiops-*, policy/aiops-*, instance-profile/aiops-*, oidc-provider/* eks cluster + Auto Mode node roles, the ClusterEncryption and Auto Mode tag policies, the IRSA OIDC provider (delete included, for teardown); iam/ Pod Identity roles; ecr-cache/ upstream role; iam:PassRole for all of them. aiops-*, not aiops-<slug>*: the eks module reads its CALLER (data "aws_iam_session_context" = iam:GetRole on aiops-deployer itself)
RdsManagedMasterSecret on secret:rds!* manage_master_user_password (RDS creates the secret with the caller's rights), the first-install bootstrap reading it, deletion at teardown
RdsAlarms on alarm:aiops-* rds — the two CloudWatch alarms beside the instance (logs:* is CloudWatch Logs, another service): PutMetricAlarm plus TagResource (the provider's default_tags ride the create), ListTagsForResource on refresh, DeleteAlarms at teardown
AlarmTopic on sns:*:*:aiops-* rds — with alarm_email set, the topic aiops-<slug>-alarms (created tagged) and its email subscription: create, refresh, delete at teardown
CostGuardrailsBudget on budget/aiops-*, CostGuardrailsAnomaly cost-guardrails, only with enable_cost_guardrails: the monthly budget aiops-<slug>-monthly, the AWS-services anomaly monitor and its subscription (their ARNs carry generated ids, so the ce: actions are not name-scoped)
InterfaceEndpointPrivateDns endpoints/ — an interface endpoint with private DNS associates a Route 53 private zone with the VPC

Said plainly: IamForTheStack allows iam:AttachRolePolicy, iam:PutRolePolicy and iam:CreatePolicyVersion on role/aiops-* and policy/aiops-* with no iam:PolicyARN condition, and aiops-deployer itself matches role/aiops-*. The role can therefore attach any policy, AdministratorAccess included, to itself: in practice it can make itself administrator of the account. The permissions boundary described in the 18-Sep decision brief (§3.1) is not built. Containment today is the dedicated account, plus the fact that only our pipeline can assume the role (our infra role, the ExternalId we minted, and a SourceIdentity naming the operator), so every session and every call lands in the customer's CloudTrail under that name. The boundary is the hardening before any production install, or before a test if the customer asks for it.

The trust (aiops-deployer-trust.json) allows our infra CI role only: sts:AssumeRole with the ExternalId, a Deny on sts:AssumeRole without a SourceIdentity, and separate sts:SetSourceIdentity and sts:TagSession statements. The ExternalId key exists only in AssumeRole's request context — on a statement that also lists SetSourceIdentity or TagSession it never matches, and STS refuses the whole call. Access Analyzer reports no findings on this document. Our side of the chain is the AssumeCustomerDeployRole statement in infrastructure/iac/oidc-role/infra-role-policy.json.

1.5 Host name and certificate

1.6 Network

2. Install, step by step

Step 1 — Vendor: mint

python3 scripts/customer-onboard.py mint <slug>

Writes ~/.aiops/customers/<slug>.env (mode 0600) once and never overwrites it: AIOPS_DEPLOY_EXTERNAL_ID (a UUID: the confused-deputy guard, minted by us per customer), SECRET_KEY, ADMIN_PASSWORD, AIOPS_SECRET_KEY (the Fernet key of the tenant secret store: the file variable reaches the pod as AIOPS_TENANT_SECRET_KEY; losing it loses every stored datasource credential, so back the file up like a key; rotating it is §12.1) and empty AIOPS_SECRET_KEY_PREVIOUS, AIOPS_API_KEY, AIOPS_MODEL, AIOPS_API_BASE. It prints the hand-off: the zip command (git archive of bootstrap.sh and the two JSON files, run from a clean checkout of main) and the sha256sum of the same three files, the ExternalId, and the customer's command line. Send the zip with the sums, and the ExternalId in a separate message. A second mint prints the same hand-off again.

Step 2 — Customer: bootstrap

In AWS CloudShell opened in the install region, as an account admin:

unzip aiops-bootstrap.zip
export AIOPS_ACCOUNT_ID=111122223333 AIOPS_EXTERNAL_ID=<the ExternalId from the vendor>
export AIOPS_HOST=aiops.example.com      # optional: request the certificate here
bash deploy/customers/bootstrap.sh plan  # read-only
bash deploy/customers/bootstrap.sh apply
Input Meaning
AIOPS_ACCOUNT_ID this account; the first AWS call checks it and stops on any other
AIOPS_EXTERNAL_ID the UUID from step 1
AIOPS_HOST optional: the platform host name; apply reuses an ACM certificate for exactly that name or requests one (DNS validation)
AIOPS_CERT_ARN optional: an existing certificate for AIOPS_HOST in this account and region
AIOPS_REGION default: the region CloudShell was opened in (AWS_REGION)
AIOPS_ADMIN_ROLE_ARN the cluster's break-glass admin when the caller cannot be one (root, a federated session); default: the caller's IAM user, or its role for an SSO / assumed-role session
AIOPS_VENDOR_ROLE_ARN default arn:aws:iam::<vendor-account-id>:role/aiops-demo-ado-infra, our pipeline role
AIOPS_ANTHROPIC_MODEL default anthropic.claude-sonnet-5: the Bedrock model id whose inference profile the bootstrap looks for (§1.3)
AIOPS_ALLOW_GLOBAL_ROUTING true lets the bootstrap pick a global. profile, which may serve requests from any region; default: geographic profiles only

An account with only its root user. plan stops when the caller is the account root (or a federated session): EKS access entries take an IAM user or role, and root cannot assume a role, so root cannot be the cluster's break-glass admin. Create a role for the people who will use it and name it before plan:

cat > break-glass.yaml <<'EOF'
Resources:
  BreakGlass:
    Type: AWS::IAM::Role
    Properties:
      RoleName: platform-break-glass
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
            Action: sts:AssumeRole
      Policies:
        - PolicyName: eks-describe-cluster
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: eks:DescribeCluster
                Resource: "*"
EOF
aws cloudformation deploy --stack-name platform-break-glass --template-file break-glass.yaml \
  --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset
export AIOPS_ADMIN_ROLE_ARN=arn:aws:iam::$AIOPS_ACCOUNT_ID:role/platform-break-glass

The deploy makes that ARN a cluster admin through an EKS access entry (render copies the bootstrap JSON's admin_principal_arn into eks_admin_principal_arns); the role's own policy only lets aws eks update-kubeconfig read the cluster. Its trust names the account, so an IAM user or SSO role of yours that may call sts:AssumeRole on it can use it: give at least one person such an identity, since root cannot. Never name it aiops-*: the platform's control-plane and runner roles may assume any role so named, and in this account a trust that names the account is all they need.

apply creates or updates the role aiops-deployer from the two JSON files (trust rendered with our pipeline role and the ExternalId, 1-hour sessions, tagged ManagedBy=aiops-bootstrap), and writes only what differs: a second apply makes no change. It refuses an existing aiops-deployer it did not create and whose trust does not name our pipeline role. plan and apply also check the availability zones, the vCPU quota and Bedrock access, and print one JSON (saved as ./aiops-bootstrap-<account>.json) that holds no secret — never the ExternalId:

account_id, region, azs, deploy_role_arn, deploy_role_ready, admin_principal_arn (goes into the cluster's access entries, so someone of theirs can always kubectl in), host, certificate_arn, certificate_status, validation_record, model (recommended, anthropic_agreement, use_case_form, anthropic_next_step, claude_profile, nova_profile), bedrock_in_region, ec2_standard_vcpu_quota, existing_vpc_cidrs, policy_sha256.

A read it cannot make (the vCPU quota, Bedrock) is printed as a note: line with the AWS error instead of being dropped. They send back the file apply wrote. A plan file says deploy_role_ready: false until the role exists, and render refuses it.

Step 3 — Certificate validation

The host owner adds the validation CNAME from validation_record (DNS only) and waits until ACM shows ISSUED; re-running bootstrap.sh plan prints the status. The ALB gets no HTTPS listener until the certificate is ISSUED.

Step 4 — Vendor: render, Azure DevOps, pull grant

git switch -c customer/<slug>
python3 scripts/customer-onboard.py render <slug> aiops-bootstrap-111122223333.json \
  [--estate-cidr 10.20.0.0/16] [--openai-endpoint] [--alarm-email aiops-oncall@example.com]
git add infrastructure/iac/envs/<slug> deploy/customers/<slug>
git commit -m "chore(deploy): add <slug>" && git push -u origin customer/<slug>
ADO_PAT=<a PAT of yours> python3 scripts/customer-onboard.py ado <slug>

Step 5 — Vendor: deploy

Run deploy-customer.yml from branch customer/<slug> with customer=<slug>, imageTag=<version> (a tag build-ecr.yml pushed — plain 0.1.101 or 0.1.101-<suffix>.<buildId> with the pipeline's fixed build-tag suffix; empty = the version in pyproject.toml) and bootstrapDb=true, in a window booked with the customer.

Step 6 — DNS

The host owner creates ONE CNAME from the host name to the printed ALB name (CNAME target: …, also alb_dns in deploy-log.json), DNS-only. The deploy gate warns while public DNS does not reach the platform yet.

Step 7 — Vendor: price map and smoke

Step 8 — Customer: first login and first connection

  1. Log in at https://<host> as admin@local with ADMIN_PASSWORD, handed over out of band (never in the same message as the URL), and change it: /ops → Users → Reset password.

  2. Connect their identity provider under Settings · Account → Members & roles → Single sign-on (Single sign-on), then point their alert sources at https://<host>/webhook/<alertmanager|cloudwatch-sns|azure-monitor|…>/<slug> with the token as Authorization: Bearer <token>.

  3. Keyless AWS (the first read-role datasource): both Pod Identity roles (aiops-<slug>-aiops, aiops-<slug>-aiops-runner) carry sts:AssumeRole + sts:TagSession on any account's roles named aiops-* (a role named otherwise is out of reach); the guard is the estate role's trust. That role must trust BOTH ARNs with the workspace ExternalId (Keyless cloud access page, GET /api/aiops/{tid}/platform-identity) and allow sts:TagSession — the datasource Test runs on the control plane, investigations on the runners. With AIOPS_KEYLESS_AWS_ANY_ACCOUNT: "true" (the example values) the guided CloudShell setup needs no staff step: it creates aiops-<workspace-slug>-read/-write in the account the customer enters, the read role trusting both roles and the write role the control plane only (a role the customer creates by hand must be named aiops-* too). Three cases still need platform staff, through the workspace's GET/PUT /api/aiops/admin/tenants/<id>/cloud-runtime:

    • the account the platform runs in (<customer-account-id>, where a same-account trust alone lets the platform assume the role): publish only that account's exact read/write pair in aws_targets, with no reader. The shared runner (aiops-<slug>-aiops-runner) stays the reader, and the workspace keeps any-account mode for its other accounts;
    • a workspace with a private-network agent: its reads run on the agent's own AWS role, so publish that role as the reader, never the shared runner, and a prepared pair per account;
    • a workspace staff already set AWS up for: its reader (the runner role or a dedicated reader, never the control-plane role) and its pairs.

    Outside <customer-account-id> a prepared role must be named aiops-* with no path: the platform's IAM reaches no other name, and the API refuses such a pair.

  4. For the incident's Probable cause card: CloudWatch alarms on the resources in scope routed to /webhook/cloudwatch-sns/<slug> (OK action on the same topic), the workspace harvest interval above 0 (900 s by default), config_history: true and the alarms' accounts and regions on the AWS read connection, and an AWS Config recorder that records the in-scope types, Lambda functions included — see aiops-inputs-needed.md, "What the Probable cause card needs".

Acceptance of the install = the smoke passing + one keyless connection test passing.

3. Upgrade

build-ecr.yml on main publishes <version>-<suffix>.<buildId> (a fixed build-tag suffix set in that pipeline), <version>, the chart charts/aiops:<version> and the signature. The repositories are IMMUTABLE: the plain <version> tag and the chart are pushed by the FIRST build of a version only, and later builds of the same version skip them with a warning. To ship a rebuild, deploy imageTag=<version>-<suffix>.<buildId>; the chart stays the first build's, so a chart change needs a version bump. Then deploy-customer.yml from customer/<slug> with customer=<slug>, imageTag=<new version> (bootstrapDb=false). Rebase customer/<slug> on main first, or merge the release into it, for example git merge v<new version> (a merge needs no force-push): a run uses the pipeline files of the branch it is queued from, so a stale branch deploys with stale pipeline steps, and an empty chartVersion resolves to the version in that branch's pyproject.toml. Read the Plan stage log before approving: an application upgrade should show "No changes" in every unit, at most output-only changes. The Deploy stage runs a fresh terragrunt run-all apply without asking again, so it applies what it plans at that moment, not the plan you read. The pipeline always takes an RDS manual snapshot first and waits for it; the snapshot is named after the tag being deployed, dots as dashes: aiops-<slug>-pre-<tag>-<buildId>, for example aiops-rehearsal-pre-0-1-108-17490. Schema changes are the app's additive automigrate at boot (app/db.py): the old image runs against the newer schema, so a Helm rollback needs no schema rollback. Expect about a minute of downtime (single replica, Recreate).

When the release notes say the deployer policy changed, send the new zip first: the customer re-runs bootstrap.sh apply with the same ExternalId, and it updates only the permissions.

The deploy log artifact deploy-log-<slug> on every run is the record of "what is live where"; the app itself reports its version on /healthz.

Recovery objectives, what the snapshot does and does not cover, and the maintenance windows: availability.md.

4. Rollback

5. Teardown

  1. Agree the date with the customer, and whether they want the final database snapshot (it is kept either way; they delete it).

  2. Rebase customer/<slug> on main, or merge main into it (a branch cut before destroy-customer.yml existed cannot queue it), then queue destroy-customer.yml from that branch with customer=<slug> and confirm=<slug>. Stage Plan cannot be unticked under "Stages to run"; it stops before any AWS call unless both match, the slug is a valid one and env.hcl names a customer deploy role (the sandbox env has none, so it can never be the target), then shows terragrunt run-all plan -destroy. Approve on aiops-<slug>: the approval exists only once someone has added it to the Environment (§2, step 4). Stage Destroy runs the same guard again, then, each phase with fresh credentials and a fresh AssumeRole:

    • A: helm uninstall (waits until the Ingress finalizer lets go, i.e. the ALB is gone), deletes namespaces aiops and aiops-agents, waits until no PersistentVolume is left (the EBS volume is deleted with it), switches RDS deletion protection off, and deletes the deploy's aiops-<slug>-pre-* snapshots. The Kubernetes part runs only while the cluster is ACTIVE and still holds the deploy role's access entry; a re-run after a failed cluster delete skips it with a warning;
    • B: terragrunt run-all destroy, retried once after 120 s in a task of its own (ENIs of terminated Auto Mode nodes linger); RDS writes terraform's final snapshot aiops-<slug>-final-… on the way out;
    • C: a read-only leftover check that fails the run while the cluster, the RDS instance, the VPC, an EBS volume of the platform or an Elastic IP tagged Customer=<slug> is left; only then is our state prefix envs/<slug>/ removed. The run then prints what is kept and the follow-ups.

    A failed or timed-out run is re-run as is; the state stays until C is green.

  3. The vendor, on main: remove the account id from platform_image_pull_account_ids and apply ecr and ecr-charts; delete the variable group and the Environment; keep or delete branch customer/<slug>.

  4. Customer: bash deploy/customers/bootstrap.sh delete in CloudShell opened in the install region (with the same AIOPS_ACCOUNT_ID, plus AIOPS_HOST to delete the certificate it requested, once no load balancer uses it). It refuses while an aiops-* cluster, an aiops-*-pg instance or an aiops-*-vpc VPC exists in ANY enabled region (a region the organisation's SCPs close is skipped and named): a teardown that stopped half-way needs the role to finish, and a role recreated later would not get back into the cluster (EKS access entries do not follow a new role of the same name). Then they remove the DNS records and, once sure, the final snapshot.

Kept after the teardown: terraform's final DB snapshot, and the EKS secrets KMS key until its scheduled deletion date. What they cost is in §6.

6. Costs and timings

Item Time Cost
Step 2, bootstrap (plan + apply) 14 s + 19 s not measured
Step 4, render + ado + pull grant not measured (the pull grant already existed) not measured
Step 5, first deploy (stack + platform) about 27 min from approval to the HTTPS health gate (infrastructure apply 18 min 21 s, Helm install 2 min 17 s) not measured
Step 7, smoke (one real-model investigation) 34 s not measured
Running stack, per day — not measured
Teardown (destroy pipeline) not measured not measured
Kept after teardown (final snapshot, KMS key) — not measured

Times: the 6 October 2026 rehearsal on build 0.1.105, as in §0 (02-install.md); no cost has been measured yet. A list-price estimate for these defaults (about USD 725–755 a month before model usage) is in Expected monthly cost.

What the running stack is made of: the EKS control plane, Auto Mode nodes and the Auto Mode fee, one NAT gateway, RDS db.t4g.medium Multi-AZ with 20 GB gp3 and 7 days of backups, one ALB, VPC endpoints (an S3 gateway endpoint, free; interface endpoints for ECR api, ECR dkr, Secrets Manager, STS and the Bedrock runtime, each in two AZs at about USD 7–10 per AZ a month plus a per-GB charge), public IPv4 addresses, CloudWatch logs and alarms, and model usage per investigation.

Availability knobs (env.hcl, applied by the next deploy): rds_multi_az (default true — a synchronous standby in the second AZ, about twice the instance cost; the sandbox runs false), rds_max_allocated_storage (default 100 GiB — the autoscaling ceiling over the 20 GB start, billed only as it grows), single_nat_gateway (default true — one NAT for both AZs; false adds a second NAT gateway for AZ-resilient egress), enable_core_endpoints (default true — the S3, ECR, Secrets Manager and STS endpoints, about USD 60–80 a month for the four interface endpoints; false sends that traffic through the NAT) and enable_bedrock_endpoint. The RDS unit also creates two CloudWatch alarms — free storage below 2 GiB, connections above 80 % of the class limit — notifying the customer's topics in alarm_sns_topic_arns and, when alarm_email is set, the topic the unit creates, aiops-<slug>-alarms, with that address subscribed (SNS emails it a confirmation link that must be clicked once; at this volume the topic costs nothing). Both empty (the default): the alarms exist, nobody is paged.

Cost guardrails (enable_cost_guardrails, default false): a monthly AWS Budget of cost_budget_monthly_usd that mails alarm_email at 80 % and 100 % of the actual spend and at 100 % of the forecast, and a daily Cost Anomaly Detection summary of anomalies worth USD 100 or more. Only in an account that owns its billing: in a member account of an AWS Organization, budgets and anomaly detection belong to the payer, the APIs answer AccessDenied and the deploy fails — the payer sets them there. An account holds one AWS-services anomaly monitor; when it has one already, put its ARN in cost_anomaly_monitor_arn and the subscription uses it.

7. Not covered

8. Proof

9. One-time setup on our side (not per customer)

10. Sandbox notes that affect this path

11. Licence

An offline signed file, no phone-home (licence.md): the pod verifies the JWT in AIOPS_LICENSE against licence.publicKey at boot and every scheduler tick. No key exists yet (alias/aiops-licence waits on the vendor-of-record decision, decision log §6): every install runs unlicensed = no enforcement. The slot is wired, so that changes with one variable.

When State Ops console Runs
before exp valid card only normal
exp .. exp + grace_days grace banner: expired, N days of grace left normal — a warning, nothing paused (grace_days: 0 in the file = no grace at all)
after exp + grace_days expired red banner new agent runs and approved executions refuse to start
any signature / claim failure invalid red banner with the reason same as expired
no licence, or no public key unlicensed card says "no enforcement" normal (warning in the log at boot)

12. Rotating secrets

The secrets file ~/.aiops/customers/<slug>.env stays the source of truth (Step 4): change a value there, run ado <slug> --update-group, then deploy-customer.yml from customer/<slug> (rebased on main) with the live imageTag. A changed Secret restarts the control plane through its checksum, and the provisioner recreates every runner whose environment changed.

Secret Where it lives How to rotate Effect Who
SECRET_KEY secrets file → secrets.AIOPS_SECRET_KEY a new value (python3 -c 'import secrets; print(secrets.token_urlsafe(48))'), update-group, deploy it signs only the 10-minute SSO sign-in state and the 24-hour SSO logout hint; sessions are stored hashed in the database, so nobody is signed out — an SSO sign-in in progress starts again the vendor
ADMIN_PASSWORD the bootstrap superadmin, seeded only when that account is created change it in Ops › Users, then mirror it in the secrets file the file value is ignored once the account exists; mirroring keeps the file true for a rebuild the vendor or the customer's superadmin
AIOPS_SECRET_KEY secrets file → secrets.AIOPS_TENANT_SECRET_KEY; encrypts every stored credential §12.1 — never by overwriting the value two deploys; connections keep working throughout the vendor; the customer approves the deploys
AIOPS_API_KEY secrets file → secrets.AIOPS_API_KEY (empty on Bedrock) a new key at the gateway, then the file, update-group, deploy; revoke the old key at the gateway afterwards control plane restart; runners recreated with the new key the vendor (gateway owner)
AIOPS_LICENSE secrets file → licence.value §11, Renew a rollout, nothing else the vendor
Webhook ingest tokens per workspace; only a hash is stored the workspace owner mints a new one in the portal and puts it in every alert source no overlap window: the old token stops at once, so change the senders in the same sitting customer workspace owner
Personal access tokens per user; only a hash is stored mint a new one on the Tokens page (at most 365 days), switch the client, revoke the old one none beyond the client switch each user
Runner tokens (rt_) per runner; only a hash is stored runner pods the platform provisions get a fresh token on every (re)create — nothing to do. A private-network agent: mint a new agent token in the portal (datasources.manage), set it on the agent, revoke the old one the agent reconnects with the new token customer operator
Datasource credentials the tenant secret store, write-only edit the connection and enter the new value (a blank field keeps the stored one), then Test a new connection revision: the last Test no longer vouches for it customer operator
Database RDS IAM authentication: no database password exists nothing to rotate — —

12.1 The tenant secret-store key

AIOPS_SECRET_KEY encrypts every credential the platform stores. A rotation keeps the old key next to the new one as AIOPS_SECRET_KEY_PREVIOUS (it still decrypts, never encrypts) until the control plane has re-keyed every stored secret, which it does at boot: one compare-and-swap per row, a secret.rekeyed audit row per secret in its workspace's audit log, one secrets.rekey_completed summary in the platform audit log. A secret no configured key opens is never touched, and an edit of that connection answers 409 instead of overwriting it. The re-key needs a build with this support, deployed once before the first rotation: that boot stamps every stored secret with the current key.

  1. Precheck. AIOPS_SECRET_KEY_PREVIOUS is empty in the secrets file (no rotation open). Copy the secrets file, at least the current key, to the password vault. Take the census on the install — an EKS admin principal (the customer's, §4) runs it and sends back the JSON, which holds counts, a key fingerprint and opaque secret ids, never a secret:

    kubectl -n aiops exec deploy/aiops -- python -m app.aiops.secrets rekey --dry-run > census.json
    

    It must show "remaining": 0 and "unreadable": []; note readable. Each deploy-customer.yml run takes an RDS snapshot before the helm upgrade.

  2. Rotate. python3 scripts/customer-onboard.py rotate-key <slug> --census census.json — it refuses without a clean census of the current key, then makes the current key AIOPS_SECRET_KEY_PREVIOUS and mints a new AIOPS_SECRET_KEY. Copy the file to the vault again: it holds the only copy of the new key. Then ado <slug> --update-group (a human with ADO_PAT).

  3. Deploy with both keys. Rebase or merge customer/<slug> with main (the mapping of the previous key lives in the branch's deploy-customer.yml), then queue deploy-customer.yml with customer=<slug> and the live imageTag; the customer approves. The control plane restarts with both keys and re-keys at boot; db-mode runners restart with both keys.

  4. Check. Ops › Audit log, the newest secrets.rekey_completed: key_id is the new fingerprint rotate-key printed, remaining 0, unreadable [], readable the census count (give or take connections added or deleted since). Each workspace's audit log shows secret.rekeyed per stored secret. Test the connections.

  5. Retire. Save that event as JSON (copy it from Ops › Audit log, or GET /api/aiops/admin/audit?action=secrets.rekey_completed&limit=1 with a superadmin token), then python3 scripts/customer-onboard.py retire-key <slug> --rekey-report event.json. It refuses unless the event is for the new key with remaining 0 and unreadable [], then moves the previous key to AIOPS_SECRET_KEY_RETIRED_<YYYYMMDD>, a line that never goes to Azure DevOps. ado <slug> --update-group, deploy again, Test again.

Stop rule. unreadable not empty or remaining above 0: do not retire. The usual cause is a previous key that never reached the pod (a customer branch not rebased, or update-group skipped): the secrets stay untouched and the connections fail closed. Fix the plumbing and deploy again — the boot re-key resumes — or roll back: swap the two values in the secrets file (the old key back as AIOPS_SECRET_KEY, the new one as AIOPS_SECRET_KEY_PREVIOUS), update-group and deploy; the boot re-key moves whatever was written under the new key back to the old one, and step 5 closes the rollback like any rotation.

Rollback rule. The release carries the keys (they ride --set-string into the chart Secret), so rolling it back can undo a rotation step. A helm rollback to a revision from before step 3, or --atomic undoing a step-3 run that failed after its pod had booted and re-keyed, brings the old key back alone. Every secret already re-keyed then fails closed (connections down, edits 409) until the next pipeline deploy, and a credential saved meanwhile is written under the old key: once that key is retired, only a deploy carrying the retired line as AIOPS_SECRET_KEY_PREVIOUS opens it again (as in the DR rule), and step 5 then closes that like any rotation. From step 3 on:

DR rule. Keep the AIOPS_SECRET_KEY_RETIRED_<YYYYMMDD> line until the oldest snapshot and point-in-time-restore window from before the rotation have expired: a restore of such a backup needs that key — deploy the restored install with it as AIOPS_SECRET_KEY_PREVIOUS and the boot re-key moves its secrets onto the current key.

db-mode runners restart at each deploy of a rotation (their environment changes); api-mode runners never receive either key.

Compose. The same steps with the .env file as the source of truth:

docker compose exec aiops python -m app.aiops.secrets rekey --dry-run  # census: remaining 0, unreadable []
# .env: AIOPS_TENANT_SECRET_KEY_PREVIOUS=<the old key>, AIOPS_TENANT_SECRET_KEY=<a new key>
docker compose up -d                                                    # restarts with both keys, re-keys at boot
docker compose exec aiops python -m app.aiops.secrets rekey --dry-run  # remaining 0, unreadable [] on the new key
# .env: clear AIOPS_TENANT_SECRET_KEY_PREVIOUS (keep the old key in the vault), then
docker compose up -d

Runner containers the control plane provisioned itself (AIOPS_RUNNER_PROVISIONER=docker) keep the environment they were created with. After each up -d, stop them with their drain window and remove them; the control plane provisions them again with the current keys:

ids=$(docker ps -aq --filter label=aiops.managed=1); [ -z "$ids" ] || { docker stop -t 120 $ids; docker rm -f $ids; }

Next steps