Skip to content

feat[auto-upgrade]: hub endpoints, host agent, My Trust controls and upgrade email - #1657

Open
garciadias wants to merge 10 commits into
developfrom
trust-auto-upgrade-ui-docs
Open

garciadias wants to merge 10 commits into
developfrom
trust-auto-upgrade-ui-docs

Conversation

@garciadias

@garciadias garciadias commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Automatic upgrades for trusts: a trust can now follow the hub's release on its own, inside a window its own operators choose, without anyone running upgrade-trust by hand.

An agent on the trust host ticks hourly, asks the hub whether it should upgrade, and if everything lines up runs the existing mode-specific upgrade verb. The hub owns the toggle, the target release and the idle decision; the agent owns execution and reports back. Nothing contacts GitHub — a trust that polled releases directly could jump ahead of the hub, and every site would need outbound github.com.

Four slices, in one PR because they are a single contract: the agent calls the endpoints the UI renders, and the email reads the state the lease writes. Splitting them would put the two halves of a wire protocol in separate reviews, which is exactly how the blocker-field mismatch fixed in this branch happened in the first place.

What's here

Hub (trusts_services/trust_auto_upgrade.py, services/auto_upgrade.py)

  • GET /trust/upgrade-policy and POST /trust/upgrade-status, authenticated with the existing TRUST_API_KEY in TRUST_API_KEY_HEADER — dedicated endpoints rather than riding the heartbeat, so the agent does not go blind exactly when trust-api is down.
  • New Trust columns (toggle, maintenance window + timezone, lease holder and expiry, last attempt) and migration a91d3c5e7b20. Existing trusts migrate to off; nobody's live site changes behaviour because this merged.
  • A lease closes the idle-check race: state=STARTING claims the trust, the scheduler assigns it no new jobs while held, the hub rejects a claim if a job landed in the meantime, and the lease self-expires so a dead agent cannot wedge a site.
  • Busy means an FL job involving this trust is RUNNING, computed hub-side. Imaging pulls and cohort queries do not block — they are minutes long and retried.

Host agent (scripts/trust_upgrade_agent.py, scripts/setup_auto_upgrade.py)

  • systemd service + timer; its own checkout, so an operator's working tree is never moved underneath them. The agent refuses to act on a dirty tree or an unknown tag and reports that refusal, so the portal can say why nothing happened.
  • Pre-flight: site health and kit identity are checked before the lease is claimed. Post-upgrade: /health is polled until it reports the target version, bounded.
  • Failure stops and reports with a log tail. No automatic rollback — the upgrade includes an XNAT in-place schema upgrade, and unwinding that can compound damage. An operator clears a failure from the portal.
  • make setup-auto-upgrade KIT=<slot> is a separate interactive step, deliberately: onboard_onprem_trust.py runs on every up-onprem-trust, and an input() there would hang scripted and CI installs.

My Trust panel (TrustAutoUpgradeCard.vue)

  • Switch, optional maintenance window, the release the trust runs against the release the hub runs, why a pending upgrade has not started, and the last attempt with its log tail. The card renders only when the hub answers, so it stays hidden on a hub without the feature.

Email (private_services/upgrade_notifications.py)

  • The hub mails the trust's own Trust Admins and AWS_SES_ADMIN_EMAIL_ADDRESS on STARTING, SUCCEEDED and FAILED, over the SES path the hub already has. Not on BLOCKED — a blocked trust re-blocks every hour, and mailing that would train people to ignore the channel.
  • Sending happens after the commit and swallows its own failures. The status record is the fact; the email is a courtesy and must never be able to fail a write. The accepted consequence is that a FAILED email can be lost silently, and the portal stays the source of truth.

Docs — sys-admin/admin-trust-auto-upgrade covers the three modes, what each tick checks, the claim that stops a job arriving mid-upgrade, and failure handling. Six screenshots; a Cypress docs spec records the card's states.

Please look hardest at

  • The lease timeout. It releases a trust whose agent has gone quiet, but it cannot distinguish a dead agent from a slow one. An upgrade that overruns its lease would let the scheduler send a job into a half-upgraded trust. This is the sharpest edge in the design and I would like a second opinion on the duration and on whether the agent should renew rather than hold.
  • The defaults: off for existing trusts, on for new installs, and "no retry until an operator clears it".
  • Drain model. v1 only upgrades when the trust has no running job; it never drains one. Nets are global, not per-trust, so "drain this trust" is not expressible without stopping every other trust too. The cost is that a continuously busy trust lags releases, which makes the portal's "pending since" load-bearing rather than cosmetic.

Not in this PR

Kubernetes (#1644) is deliberately held back: its CronJob cannot judge whether an upgrade succeeded until the Helm exit code is unambiguous, which is #1645. Versioned deployment artifacts are #1646.

Deployment note

SES must be out of the sandbox in the deployment account and region with the sender identity verified. While sandboxed it drops mail to unverified addresses, and because sending is best-effort by design that failure is invisible.

Linked Issues

Closes #1641, closes #1642, closes #1643, closes #1709. Part of #1640.

Checklist

  • Follows the project's coding conventions and style guide
  • Updates documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

2330 flip-api unit tests, 168 agent tests, mypy clean, ruff check and format clean, terraform fmt clean. my-trust.spec.ts passes. The full vitest run has 71 failures in the auth and training-metrics specs; the same 71 fail on an unmodified develop.

Type of Change

  • Non-breaking change (fix or new feature that would not break existing functionality).
  • New tests added to cover the changes.
  • Documentation updated, tested make -C docs/ docs.

Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
…ime hub toggle

Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>

# Conflicts:
#	Makefile
#	flip-api/src/flip_api/fl_services/services/fl_scheduler_service.py
#	trust/AGENTS.md
The root lockfile lost its `[options]` block, so `uv lock --check` re-resolved
it against `exclude-newer = "3 days"` in pyproject.toml and failed the
Dependency Cooldown Check. Regenerated with uv 0.11.16, the version CI pins;
only the four `[options]` lines return, no dependency changes.

Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
The agent decided purely from the hub's policy and the local git state, so it
would recreate every container on a site that was already broken -- and then
report SUCCEEDED on the exit code of `make` alone, which only says the
deployment applied, not that the site came back.

Pre-flight: refuse to start unless trust-api's /health is reachable, status is
ok, no tasks are dead and hub_key_match holds, reported to the hub as a new
SITE_UNHEALTHY blocker. It sits after the cheap SKIPs, so a disabled or
already-current trust never reports a blocker it was not going to act on.

Post-flight: poll /health until it serves the target release healthily, with a
10 minute budget, and report FAILED with the health body otherwise. The budget
is generous because XNAT's schema upgrade dominates it and FAILED parks the
release until an operator clears it.

Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
… finishes

An unattended upgrade runs in a window chosen because nobody is watching, so
its only trace was the Trust panel. Send the trust's Trust Admins and the
platform admin address one email when the lease is claimed and one when it
resolves, reusing the existing SES sender and Terraform-managed templates.

Sending is best-effort and happens after the commit: the upgrade record is the
fact and must never be rolled back by a mail failure. BLOCKED is deliberately
not emailed, since it repeats every hourly tick.

Also fixes a contract break from faa92b7: the hub matched the human-readable
`detail` against the known blocker codes, so SITE_UNHEALTHY stopped reaching
the portal card. The code now travels in its own `blocker` field, with the old
spelling still accepted for agents that have not upgraded themselves yet.

Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
terraform plan -chdir=deploy/providers/AWS
Diff of 2 changes.
+ module.ses.aws_ses_template.flip_trust_upgrade_finished will be created
+ module.ses.aws_ses_template.flip_trust_upgrade_started will be created
Plan: 2 to add, 0 to change, 0 to destroy.
By @garciadias at 2026-11-02T19:36:35Z (view log).
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.ses.aws_ses_template.flip_trust_upgrade_finished will be created
  + resource "aws_ses_template" "flip_trust_upgrade_finished" {
      + arn     = (known after apply)
      + html    = <<-EOT
            <!--
                Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
                Licensed under the Apache License, Version 2.0 (the "License");
                you may not use this file except in compliance with the License.
                You may obtain a copy of the License at
                    http://www.apache.org/licenses/LICENSE-2.0
                Unless required by applicable law or agreed to in writing, software
                distributed under the License is distributed on an "AS IS" BASIS,
                WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
                See the License for the specific language governing permissions and
                limitations under the License.
            -->
            
            <!DOCTYPE html>
            <html>
            <head>
              <meta charset="utf-8">
              <meta name="viewport" content="width=device-width, initial-scale=1">
            </head>
            <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, 'Helvetica Neue', sans-serif; background-color: #f5f5f5;">
              <table cellpadding="0" cellspacing="0" width="100%" style="background-color: #f5f5f5;">
                <tr>
                  <td style="padding: 40px 20px;">
                    <!-- Main container -->
                    <table cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden;">
                      <!-- Header with gradient background and solid purple fallback -->
                      <tr>
                        <td bgcolor="#61366e" style="background-color: #61366e; background-image: linear-gradient(135deg, #61366e 0%, #9452A8 100%); padding: 30px;">
                          <table cellpadding="0" cellspacing="0" width="100%">
                            <tr>
                              <td style="width: 80px; vertical-align: middle;">
                                <img src="https://raw.githubusercontent.com/londonaicentre/FLIP/main/docs/images/flip-logo-icon.png" alt="FLIP Logo" style="max-height: 90px; border-radius: 8px;">
                              </td>
                              <td style="vertical-align: middle; padding-left: 16px;">
                                <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">FLIP</h1>
                                <p style="margin: 6px 0 0 0; color: rgba(255,255,255,0.9); font-size: 14px; font-weight: 500;">Federated Learning &amp; Interoperability Platform</p>
                              </td>
                            </tr>
                          </table>
                        </td>
                      </tr>
            
                      <!-- Content section -->
                      <tr>
                        <td style="padding: 40px 30px;">
                          <p style="margin: 0 0 24px 0; color: #301A37; font-size: 16px; line-height: 1.5;">Hello,</p>
            
                          {{#if succeeded}}
                          <p style="margin: 0 0 24px 0; color: #434757; font-size: 16px; line-height: 1.6;">The automatic upgrade of trust <strong>{{trust_name}}</strong> finished successfully. The services are back up and reporting the new release.</p>
                          {{else}}
                          <p style="margin: 0 0 24px 0; color: #434757; font-size: 16px; line-height: 1.6;">The automatic upgrade of trust <strong>{{trust_name}}</strong> <strong>did not complete</strong>. This trust will not retry this release until an operator clears the failure, so it needs someone to look at it.</p>
                          {{/if}}
            
                          <!-- Upgrade details -->
                          <div style="background-color: #F7F3F9; border-left: 4px solid #9452A8; padding: 20px; margin: 24px 0; border-radius: 4px;">
                            <p style="margin: 0 0 16px 0; color: #301A37; font-size: 14px; font-weight: 600;">Upgrade details:</p>
                            <table cellpadding="0" cellspacing="0" width="100%">
                              <tr>
                                <td style="padding: 8px 0; color: #61366e; font-weight: 600; width: 120px; font-size: 14px;">Trust:</td>
                                <td style="padding: 8px 0; color: #301A37; font-size: 14px;">{{trust_name}} ({{trust_code}})</td>
                              </tr>
                              <tr>
                                <td style="padding: 8px 0; color: #61366e; font-weight: 600; font-size: 14px;">Release:</td>
                                <td style="padding: 8px 0; color: #301A37; font-family: 'Monaco', 'Courier New', monospace; font-size: 14px; word-break: break-word;">{{release}}</td>
                              </tr>
                              <tr>
                                <td style="padding: 8px 0; color: #61366e; font-weight: 600; font-size: 14px;">Outcome:</td>
                                <td style="padding: 8px 0; color: #301A37; font-size: 14px;">{{state}}</td>
                              </tr>
                            </table>
                          </div>
            
                          {{#if detail}}
                          <p style="margin: 24px 0 8px 0; color: #301A37; font-size: 14px; font-weight: 600;">What the upgrade reported:</p>
                          <pre style="background-color: #f4f4f4; border: 1px solid #e0e0e0; border-radius: 4px; padding: 16px; margin: 0; color: #301A37; font-family: 'Monaco', 'Courier New', monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;">{{detail}}</pre>
                          {{/if}}
            
                          <p style="margin: 24px 0 0 0; color: #434757; font-size: 16px; line-height: 1.6;">The Trust panel in FLIP always holds the current state of this trust, including anything this email missed.</p>
            
                          <!-- Confidentiality notice -->
                          <p style="margin: 24px 0 0 0; color: #434757; font-size: 13px; line-height: 1.6;">This is an automated message. Please do not reply to this email. This message may contain confidential information. If you are not the intended recipient, please do not disclose, copy, or distribute it.</p>
                        </td>
                      </tr>
            
                      <!-- Footer -->
                      <tr>
                        <td style="background-color: #f9f9f9; padding: 24px 30px; border-top: 1px solid #eeeeee; text-align: center;">
                          <p style="margin: 0 0 8px 0; color: #9452A8; font-size: 13px; font-weight: 600;">The FLIP Team</p>
                          <p style="margin: 0; color: #999999; font-size: 12px;">Federated Learning and Interoperability Platform</p>
                        </td>
                      </tr>
                    </table>
                  </td>
                </tr>
              </table>
            </body>
            </html>
        EOT
      + id      = (known after apply)
      + name    = "flip-trust-upgrade-finished"
      + region  = "eu-west-2"
      + subject = "Upgrade {{state}} on {{trust_name}}"
      + text    = <<-EOT
            # Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
            # Licensed under the Apache License, Version 2.0 (the "License");
            # you may not use this file except in compliance with the License.
            # You may obtain a copy of the License at
            #     http://www.apache.org/licenses/LICENSE-2.0
            # Unless required by applicable law or agreed to in writing, software
            # distributed under the License is distributed on an "AS IS" BASIS,
            # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
            # See the License for the specific language governing permissions and
            # limitations under the License.
            #
            
            FLIP - Federated Learning & Interoperability Platform
            
            Hello,
            
            {{#if succeeded}}
            The automatic upgrade of trust {{trust_name}} finished successfully. The services are back up and reporting the new release.
            {{else}}
            The automatic upgrade of trust {{trust_name}} DID NOT COMPLETE. This trust will not retry this release until an operator clears the failure, so it needs someone to look at it.
            {{/if}}
            
            Upgrade details:
              Trust: {{trust_name}} ({{trust_code}})
              Release: {{release}}
              Outcome: {{state}}
            
            {{#if detail}}
            What the upgrade reported:
            
            {{detail}}
            {{/if}}
            
            The Trust panel in FLIP always holds the current state of this trust, including anything this email missed.
            
            This is an automated message. Please do not reply to this email. This message may contain confidential information. If you are not the intended recipient, please do not disclose, copy, or distribute it.
            
            The FLIP Team
            Federated Learning and Interoperability Platform
        EOT
    }

  # module.ses.aws_ses_template.flip_trust_upgrade_started will be created
  + resource "aws_ses_template" "flip_trust_upgrade_started" {
      + arn     = (known after apply)
      + html    = <<-EOT
            <!--
                Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
                Licensed under the Apache License, Version 2.0 (the "License");
                you may not use this file except in compliance with the License.
                You may obtain a copy of the License at
                    http://www.apache.org/licenses/LICENSE-2.0
                Unless required by applicable law or agreed to in writing, software
                distributed under the License is distributed on an "AS IS" BASIS,
                WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
                See the License for the specific language governing permissions and
                limitations under the License.
            -->
            
            <!DOCTYPE html>
            <html>
            <head>
              <meta charset="utf-8">
              <meta name="viewport" content="width=device-width, initial-scale=1">
            </head>
            <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, 'Helvetica Neue', sans-serif; background-color: #f5f5f5;">
              <table cellpadding="0" cellspacing="0" width="100%" style="background-color: #f5f5f5;">
                <tr>
                  <td style="padding: 40px 20px;">
                    <!-- Main container -->
                    <table cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden;">
                      <!-- Header with gradient background and solid purple fallback -->
                      <tr>
                        <td bgcolor="#61366e" style="background-color: #61366e; background-image: linear-gradient(135deg, #61366e 0%, #9452A8 100%); padding: 30px;">
                          <table cellpadding="0" cellspacing="0" width="100%">
                            <tr>
                              <td style="width: 80px; vertical-align: middle;">
                                <img src="https://raw.githubusercontent.com/londonaicentre/FLIP/main/docs/images/flip-logo-icon.png" alt="FLIP Logo" style="max-height: 90px; border-radius: 8px;">
                              </td>
                              <td style="vertical-align: middle; padding-left: 16px;">
                                <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">FLIP</h1>
                                <p style="margin: 6px 0 0 0; color: rgba(255,255,255,0.9); font-size: 14px; font-weight: 500;">Federated Learning &amp; Interoperability Platform</p>
                              </td>
                            </tr>
                          </table>
                        </td>
                      </tr>
            
                      <!-- Content section -->
                      <tr>
                        <td style="padding: 40px 30px;">
                          <p style="margin: 0 0 24px 0; color: #301A37; font-size: 16px; line-height: 1.5;">Hello,</p>
            
                          <p style="margin: 0 0 24px 0; color: #434757; font-size: 16px; line-height: 1.6;">An automatic upgrade has started on trust <strong>{{trust_name}}</strong>. No action is needed from you &mdash; this message is a record that it began.</p>
            
                          <!-- Upgrade details -->
                          <div style="background-color: #F7F3F9; border-left: 4px solid #9452A8; padding: 20px; margin: 24px 0; border-radius: 4px;">
                            <p style="margin: 0 0 16px 0; color: #301A37; font-size: 14px; font-weight: 600;">Upgrade details:</p>
                            <table cellpadding="0" cellspacing="0" width="100%">
                              <tr>
                                <td style="padding: 8px 0; color: #61366e; font-weight: 600; width: 120px; font-size: 14px;">Trust:</td>
                                <td style="padding: 8px 0; color: #301A37; font-size: 14px;">{{trust_name}} ({{trust_code}})</td>
                              </tr>
                              <tr>
                                <td style="padding: 8px 0; color: #61366e; font-weight: 600; font-size: 14px;">Release:</td>
                                <td style="padding: 8px 0; color: #301A37; font-family: 'Monaco', 'Courier New', monospace; font-size: 14px; word-break: break-word;">{{release}}</td>
                              </tr>
                            </table>
                          </div>
            
                          <p style="margin: 24px 0 0 0; color: #434757; font-size: 16px; line-height: 1.6;">The upgrade only runs when no federated learning job is using this trust, and the services will be briefly unavailable while they restart. You will receive a second email when it finishes.</p>
            
                          <!-- Confidentiality notice -->
                          <p style="margin: 24px 0 0 0; color: #434757; font-size: 13px; line-height: 1.6;">This is an automated message. Please do not reply to this email. This message may contain confidential information. If you are not the intended recipient, please do not disclose, copy, or distribute it.</p>
                        </td>
                      </tr>
            
                      <!-- Footer -->
                      <tr>
                        <td style="background-color: #f9f9f9; padding: 24px 30px; border-top: 1px solid #eeeeee; text-align: center;">
                          <p style="margin: 0 0 8px 0; color: #9452A8; font-size: 13px; font-weight: 600;">The FLIP Team</p>
                          <p style="margin: 0; color: #999999; font-size: 12px;">Federated Learning and Interoperability Platform</p>
                        </td>
                      </tr>
                    </table>
                  </td>
                </tr>
              </table>
            </body>
            </html>
        EOT
      + id      = (known after apply)
      + name    = "flip-trust-upgrade-started"
      + region  = "eu-west-2"
      + subject = "Upgrade started on {{trust_name}}"
      + text    = <<-EOT
            # Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
            # Licensed under the Apache License, Version 2.0 (the "License");
            # you may not use this file except in compliance with the License.
            # You may obtain a copy of the License at
            #     http://www.apache.org/licenses/LICENSE-2.0
            # Unless required by applicable law or agreed to in writing, software
            # distributed under the License is distributed on an "AS IS" BASIS,
            # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
            # See the License for the specific language governing permissions and
            # limitations under the License.
            #
            
            FLIP - Federated Learning & Interoperability Platform
            
            Hello,
            
            An automatic upgrade has started on trust {{trust_name}}. No action is needed from you - this message is a record that it began.
            
            Upgrade details:
              Trust: {{trust_name}} ({{trust_code}})
              Release: {{release}}
            
            The upgrade only runs when no federated learning job is using this trust, and the services will be briefly unavailable while they restart. You will receive a second email when it finishes.
            
            This is an automated message. Please do not reply to this email. This message may contain confidential information. If you are not the intended recipient, please do not disclose, copy, or distribute it.
            
            The FLIP Team
            Federated Learning and Interoperability Platform
        EOT
    }

Plan: 2 to add, 0 to change, 0 to destroy.

@garciadias
garciadias marked this pull request as ready for review October 29, 2026 13:02
@garciadias garciadias changed the title Trust auto-upgrade: My Trust switch and behaviour docs (draft, merge after implementation) Trust auto-upgrade: hub endpoints, host agent, My Trust controls and upgrade email Oct 29, 2026

@atriaybagur atriaybagur left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a well-shaped feature: the lease/lock protocol is the right idea, the trust-key routes reuse authenticate_trust, the new permission is trust-scoped and seeded correctly, the tag regex keeps hub-chosen values out of git/make argv, and I confirmed with a marker-filled kit that the upgrade's echoed command lines carry no kit secrets. The PR body's "look hardest at" list was useful; answers are inline (lease: the hub already allows renewal).

Requesting changes for four things that stop the feature working, each reproduced locally rather than read (how, in each comment):

  1. Two Alembic heads once develop is merged — flip-api will not boot (alembic heads on develop + this revision).
  2. A fresh install never upgrades: the --no-checkout clone has no agent script and reads as dirty forever.
  3. A failed final report leaves the lease held and the release retried; kit_identity raises after the claim.
  4. The scheduler's lock-and-look (and record_status) read stale ORM state, so the idle-check race remains.

Then the stored-but-hidden blockers, the card's blank-window / 422 / poll-reset issues, the missing clear-failure control, and the docs claims that do not match the code. Seven mutations survive the current suite (listed on the test file). The rest are small tidy-ups.

Also ran a security pass over the new surface (authz on the six routes, argv construction, TLS, SES template escaping, v-html, secrets in the captured log tail): nothing to report. Worth constraining UpgradeStatusInput.release to the release-tag regex as hardening, since it is persisted, displayed and emailed as-is.

Verification: flip-api unit tests (121), the script suites (42, one macOS-only failure noted inline), the my-trust vitest spec (27), ruff and mypy all green on the branch; probes and mutation runs were against a detached worktree at c34ccaa.


# revision identifiers, used by Alembic.
revision: str = "a91d3c5e7b20" # pragma: allowlist secret
down_revision: str | None = "161d07d3d134" # pragma: allowlist secret

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: two Alembic heads after the merge. develop's head is now 9d4b7a1c5e20 (#1601, trust key rotation, merged 30 Oct), which also revises 161d07d3d134. The merge has no text conflict, and this PR's CI last ran on 29 Oct, so nothing flagged it. Reproduced with alembic heads over develop's versions dir plus this file: 9d4b7a1c5e20 (head) and a91d3c5e7b20 (head). flip-api's entrypoint runs alembic upgrade head fail-fast, so the hub would not boot.

Merge develop and set down_revision = "9d4b7a1c5e20".

Comment thread scripts/setup_auto_upgrade.py Outdated
print(f"creating the agent's own checkout at {CHECKOUT}")
if not dry_run:
CHECKOUT.parent.mkdir(parents=True, exist_ok=True)
run(["git", "clone", "--no-checkout", repo_url, str(CHECKOUT)], dry_run)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: a fresh install can never upgrade. --no-checkout leaves an empty working tree and nothing afterwards checks a tag out: the only git checkout is inside the agent's upgrade(), which is never reached. Two effects, both reproduced on a throwaway git clone --no-checkout of this repo:

  1. <CHECKOUT>/scripts/trust_upgrade_agent.py does not exist, so the systemd oneshot fails every hour (the hub toggle is already on by then).
  2. Even started by hand, git status --porcelain --untracked-files=no prints every tracked file as D (2757 lines here), so checkout_clean is False and every tick reports BLOCKED / LOCAL_MODIFICATIONS.

Check out a tag here at install time (the operator checkout's tag, or the hub's current release). The idempotency test only asserts the clone command was run, which is why the suite does not see this.

Comment thread scripts/setup_auto_upgrade.py Outdated
[Service]
Type=oneshot
Environment=HOME=/root
ExecStart=/usr/bin/env uv run --no-config {agent} tick --kit-file {kit_file} --checkout {checkout}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit sets no PATH, so /usr/bin/env uv resolves against systemd's default (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin). That works on the Azure VM, where azure.yml installs uv into /usr/local/bin, but an on-prem host that followed the astral installer has it in ~/.local/bin and the timer exits 127 while sudo -E make … keeps working for the operator. Read, not executed on Linux.

Smallest fix: write shutil.which("uv") as an absolute path into ExecStart at install time and refuse to install when it is not found; the same PATH has to find docker for the make run.

Comment thread scripts/setup_auto_upgrade.py Outdated

def migrate_kit(kit_file: Path, dry_run: bool) -> Path:
"""Move the kit under /etc/flip/trust and symlink the old path. Idempotent; returns the new path."""
if kit_file.is_symlink() and kit_file.resolve().parent == ETC_KIT_DIR:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kit_file.resolve() resolves symlinks in the whole path but ETC_KIT_DIR is compared unresolved, so on macOS (/var → /private/var) test_running_it_twice_is_harmless fails with "already exists but … is not its symlink". CI is Linux so it passes there; I hit it locally. ETC_KIT_DIR.resolve() on this line fixes both.

Comment thread scripts/trust_upgrade_agent.py Outdated
report = {"state": "SUCCEEDED" if ok else "FAILED", "release": decision.release}
if not ok:
report["detail"] = detail
hub_call(kit, "POST", "/trust/upgrade-status", report)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: a failed final report loses the outcome and breaks "never retried". This call (and the BLOCKED one above) is outside any try. I ran tick() with hub_call returning 200 for the policy and the claim and raising HTTPError 502 here: unhandled traceback, exit 1 (which the docstring defines as "the upgrade ran and failed"), the lease stays held for two hours, and FAILED is never recorded. After the lease expires the next tick re-claims and re-runs the same release. The systemd TimeoutStartSec=2h kill has the same shape: SIGTERM mid-compose up, no report.

Suggest: wrap claim-to-report in try/finally that always posts FAILED (retry the POST a few times before giving up), and keep TimeoutStartSec below LEASE_TTL - VERIFY_TIMEOUT_S so the hub hears about a kill.

An upgrade recreates database-backed services and runs XNAT's in-place schema upgrade. Rolling that back
automatically can do more damage than stopping, so the agent does not try. Instead it:

- stops, and reports **Failed** with the tail of the log, shown on My Trust (see the figure above);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No failed figure is on the page; assets/flip/trust-auto-upgrade/failed.png exists and is unreferenced.

Turning it on or off
********************

- **Defaults.** A new installation has automatic upgrades **on**; the installer states this and says it can be turned

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only true once setup-auto-upgrade has been run and answered yes; up-onprem-trust just prints a hint. "setup-auto-upgrade defaults to on; a Trust where it was never run, and every Trust that predates the feature, is off" matches the code. Also worth one line: the agent checks site health (/health ok, no dead tasks, hub_key_match) before anything else, and the flowchart above checks window → job → checkout while decide() goes unsupported → checkout → health → window → job.

- **Who can change it.** The Trust Admin of that Trust, and FLIP admins for support.
- **Changing it during an upgrade.** An upgrade already running finishes. The new setting applies from the next
release.
- **Not covered.** Snowflake-hosted Trusts are not upgraded automatically; they report that their mode is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no Snowflake anywhere in the repo. The code refuses kits whose DOCKER_HOST/DOCKER_CONTEXT is ssh:// or tcp:// at install (exit 2), so nothing is reported: "remotely driven Trusts (including the EC2 trust) are refused at install; on-prem hosts only" — which also duplicates the earlier bullet.

Comment thread Makefile
@$(MAKE) onboard-onprem-trust KIT=$(KIT) ONBOARD_ARGS=--gate
$(MAKE) DEBUG=$(DEBUG) -C trust up-trust KIT=$(KIT) HUB=$(or $(HUB),prod)
@[ -n "$(AUTO_UPGRADE)" ] || [ -e /etc/systemd/system/flip-trust-upgrade.timer ] || \
echo "ℹ️ Automatic upgrades are not configured: run sudo -E make setup-auto-upgrade KIT=$(KIT) HUB=$(or $(HUB),prod) (or pass AUTO_UPGRADE=yes|no)"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AUTO_UPGRADE= here only silences this hint; nothing installs or disables the agent. Either forward it to setup-auto-upgrade or drop the parenthesis.

- **Decided** — a table of the projects you (or the hub, before your Trust had a Trust Admin) approved or declined, each row marked green or red with who decided and when. Click a row for its description and a link to its query.
- **Trust detail** — to the right, your Trust as the Connection Status page shows it: whether it is online, and the health and version of each of its services.

- **Automatic upgrades** — under the trust detail, the switch that lets your Trust upgrade itself to the hub's release when no training is running on it, with an optional maintenance window. See :ref:`admin-trust-auto-upgrade`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blank line above this bullet splits the list in two in Sphinx; remove it.

@garciadias garciadias changed the title Trust auto-upgrade: hub endpoints, host agent, My Trust controls and upgrade email feat[auto-upgrade]: Trust auto-upgrade: hub endpoints, host agent, My Trust controls and upgrade email Nov 2, 2026
@garciadias garciadias changed the title feat[auto-upgrade]: Trust auto-upgrade: hub endpoints, host agent, My Trust controls and upgrade email feat[auto-upgrade]: hub endpoints, host agent, My Trust controls and upgrade email Nov 2, 2026
@atriaybagur atriaybagur assigned garciadias and unassigned atriaybagur Nov 2, 2026
@atriaybagur

Copy link
Copy Markdown
Member

@garciadias assigning this back to you. There are no commits since 29 Oct (c34ccaab5), and all 28 threads from my review of 2 Nov are still open, so the re-request has nothing new for me to look at yet. The four blockers listed at the top of that review come first: two Alembic heads once develop is merged in, a fresh install that never upgrades, the lease left held after a failed final report, and the stale reads in the scheduler's lock-and-look.

Test coverage also needs to improve before this merges:

  • Codecov puts patch coverage at 90% with 42 lines uncovered, mostly in services/auto_upgrade.py (19) and trust_auto_upgrade.py (10), plus trust-service.ts (0%) and TrustAutoUpgradeCard.vue.
  • Seven planted mutations pass the current suite (listed here). Each needs a test that fails on it, in particular note_agent_tick, a successful settings PUT that keeps the timezone, and the scheduler's ~exists(leased_trust) filter against a real database rather than a mock.
  • GET /trust/upgrade-policy and POST /trust/upgrade-status are never called through the app, and the record_status tests run the real notification path instead of patching notify_*.

Please re-request review once the threads have replies and the fixes are pushed.

Rebase the migration onto develop's head so there is one head again.

Run the service's uv by absolute path: systemd's PATH has no ~/.local/bin,
where the astral installer puts it, so /usr/bin/env uv exits 127. Install
fails fast when uv is missing rather than leaving a unit that cannot start.

Clone with a release tag checked out. A --no-checkout clone leaves an empty
working tree, which loses the agent script and makes every tick report
LOCAL_MODIFICATIONS.

Cap TimeoutStartSec at 1h50m, the hub's 2h lease minus the 10m verify budget,
so a timeout kill still leaves time to report FAILED; a kill the hub never
hears about holds the lease until it self-expires. Terminal reports are
retried for the same reason.

Surface every reportable blocker, not just LOCAL_MODIFICATIONS: the other
three were recorded and never shown. Carry the agent's detail alongside the
code so an admin reading "not healthy" sees which check failed, and keep the
UI keyed off the code so an unknown one is dropped rather than rendered raw.

Refresh the scheduler's locked re-read with populate_existing, without which
it returns the already-loaded attributes and misses a lease committed in
between.

Make Local.health required: an optional pre-flight is a representable
"health check never ran".
@garciadias

Copy link
Copy Markdown
Collaborator Author

Pushed f1774da. Walking the threads:

Migration head — retargeted down_revision onto develop's current head, so alembic heads is back to one.

/usr/bin/env uv — systemd's PATH is /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, which never contains ~/.local/bin where the astral installer puts uv, so the unit would have exited 127 on any host that installed it that way. The unit now runs uv by absolute path, resolved at install time, and install aborts if uv is missing rather than writing a unit that cannot start.

git clone --no-checkout — you were right that this is self-defeating: the empty tree has no agent script in it, and git status on it makes every tick report LOCAL_MODIFICATIONS. It now lands on a release tag. The test that pinned --no-checkout was pinning the bug, so it has been replaced with one that pins a real commit.

TimeoutStartSec=2h — now 1h50m, the lease TTL minus the verify budget, so a timeout kill still leaves the agent time to POST FAILED. Related: terminal reports are now retried, since a lost one holds the lease for its full TTL and then re-runs the same release.

Blockers stored but never shown — fixed; the view returns whatever blocker is current rather than only LOCAL_MODIFICATIONS. I also added upgrade_blocker_detail so the agent's prose rides along: SITE_UNHEALTHY alone told an admin a check failed but not which, which just moves the question to the host operator. The UI still switches on the code only, and now logs an unrecognised one instead of silently dropping it.

Compatibility fallback for an older agent — dropped. The agent ships for the first time in this PR, so there is no older one to be compatible with.

Scheduler's locked re-read — good catch, it was not actually re-reading: job.trusts has already put those rows in the session, so the FOR UPDATE select handed back the cached attributes and a lease committed in between would have gone unseen. Added populate_existing=True.

Local.health optional — made required. A skippable pre-flight is a representable "health check never ran", which is exactly the state the check exists to prevent.

plan decision 4 — comment reworded to say the thing instead of pointing at a document readers do not have.

Tests: 2627 flip-api, 140 script-side, 29 UI unit. vue-tsc clean on the touched files.

This branch was successfully deployed

1 active and 1 inactive deployments
flip — f1774da4 Deployed Nov 2, 2026 by garciadias via flip-api #4019
aws-stag — f1774da4 Deployed Nov 2, 2026 by garciadias via Plan (stag) #476
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants