diff --git a/app/pages/system/UpdatePage.tsx b/app/pages/system/UpdatePage.tsx
index f3dccf67d..c5f490909 100644
--- a/app/pages/system/UpdatePage.tsx
+++ b/app/pages/system/UpdatePage.tsx
@@ -280,10 +280,26 @@ export default function UpdatePage() {
}),
modalTitle: 'Set target release',
modalContent: (
-
- Are you sure you want to set {repo.systemVersion}{' '}
- as the target release?
-
+
+ {status.contactSupport && (
+
+ The system has detected known conditions that
+ require Oxide support to resolve. Starting an update
+ before talking to support is{' '}
+ strongly discouraged.
+ >
+ }
+ />
+ )}
+
+ Are you sure you want to set {repo.systemVersion}{' '}
+ as the target release?
+
+
),
errorTitle: `Error setting target release to ${repo.systemVersion}`,
})
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts
index d5515b895..170d96b3b 100644
--- a/mock-api/msw/handlers.ts
+++ b/mock-api/msw/handlers.ts
@@ -58,6 +58,7 @@ import {
internalError,
invalidRequest,
ipRangeLen,
+ mockFlags,
NotImplemented,
paginated,
randomHex,
@@ -2121,7 +2122,10 @@ export const handlers = makeHandlers({
},
systemUpdateStatus: ({ cookies }) => {
requireFleetViewer(cookies)
- return db.updateStatus
+ return {
+ ...db.updateStatus,
+ contact_support: db.updateStatus.contact_support || mockFlags(cookies).contactSupport,
+ }
},
targetReleaseUpdate: ({ body, cookies }) => {
requireFleetAdmin(cookies)
diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts
index f3f88a2f1..cca972ede 100644
--- a/mock-api/msw/util.ts
+++ b/mock-api/msw/util.ts
@@ -9,6 +9,7 @@ import { differenceInSeconds, subHours } from 'date-fns'
// Works without the .js for dev server and prod build in MSW mode, but
// playwright wants the .js. No idea why, let's just add the .js.
import { IPv4, IPv6 } from 'ip-num/IPNumber.js'
+import * as R from 'remeda'
import { match } from 'ts-pattern'
import {
@@ -334,6 +335,30 @@ export function handleMetrics({ path: { metricName }, query }: MetricParams) {
}
export const MSW_USER_COOKIE = 'msw-user'
+export const MSW_FLAGS_COOKIE = 'msw-flags'
+
+/**
+ * Test-only fleet-state overrides, serialized into the `msw-flags` cookie as a
+ * comma-separated list of the enabled keys. Some server-computed signals (e.g.
+ * update status's `contact_support`) have no operator UI to flip, so there's no
+ * user-controlled request input to drive them through the real UI. Rather than
+ * reach for `page.route`, a test enables a flag and the relevant handler ORs it
+ * in. Inert in normal use (cookie unset), and reproducible in the dev server
+ * via `document.cookie = 'msw-flags=contactSupport'`.
+ *
+ * This array is the single source of truth for valid flag names; both the e2e
+ * helper that sets the cookie and `mockFlags` that reads it derive their types
+ * from it, so a typo in a handler or test is a type error.
+ */
+export const MOCK_FLAGS = [
+ 'contactSupport', // db.updateStatus.contact_support = true
+] as const
+export type MockFlag = (typeof MOCK_FLAGS)[number]
+
+export function mockFlags(cookies: Record): Record {
+ const present = (cookies[MSW_FLAGS_COOKIE] ?? '').split(',')
+ return R.fromKeys(MOCK_FLAGS, (flag) => present.includes(flag))
+}
/**
* Look up user by display name in cookie. If cookie is empty, return the first
diff --git a/mock-api/system-update.ts b/mock-api/system-update.ts
index fd127164a..ec88544c2 100644
--- a/mock-api/system-update.ts
+++ b/mock-api/system-update.ts
@@ -36,7 +36,10 @@ export const updateStatus: Json = {
'17.0.0': 12,
'16.0.0': 5,
},
- contact_support: true,
+ // Default to false so the normal "set target release" confirmation is the
+ // default path. The scary "support required" path is exercised in e2e via the
+ // contactSupport mock flag.
+ contact_support: false,
suspended: false,
target_release: {
version: '17.0.0',
diff --git a/test/e2e/system-update.e2e.ts b/test/e2e/system-update.e2e.ts
index b68f1ddd0..66fb3222d 100644
--- a/test/e2e/system-update.e2e.ts
+++ b/test/e2e/system-update.e2e.ts
@@ -66,6 +66,8 @@ test('Set target release', async ({ page }) => {
await expect(
modal.getByText('Are you sure you want to set 18.0.0 as the target release?')
).toBeVisible()
+ // no support-required warning when contact_support is false
+ await expect(modal.getByText('strongly discouraged')).toBeHidden()
await page.getByRole('button', { name: 'Confirm' }).click()
@@ -112,6 +114,30 @@ test('Cannot downgrade to older release', async ({ page }) => {
await expect(release16.getByText('Target')).toBeHidden()
})
+test('Support required warning in set target confirmation', async ({ browser }) => {
+ // The contact-support flag makes systemUpdateStatus report support is needed
+ // (see mockFlags). Hannah Arendt is a fleet admin, so she can also open the
+ // set-target confirmation.
+ const page = await getPageAsUser(browser, 'Hannah Arendt', ['contactSupport'])
+ await page.goto('/system/update')
+
+ // the support-required banner is shown on the page
+ await expect(page.getByText('Support required')).toBeVisible()
+
+ // opening the set-target confirmation surfaces the strong warning
+ await page.getByRole('button', { name: '18.0.0 actions' }).click()
+ await page.getByRole('menuitem', { name: 'Set as target release' }).click()
+
+ const modal = page.getByRole('dialog', { name: 'Set target release' })
+ await expect(modal).toBeVisible()
+ await expect(modal.getByText(/require Oxide support to resolve/)).toBeVisible()
+ await expect(modal.getByText('strongly discouraged')).toBeVisible()
+
+ // dismiss without setting the target
+ await page.getByRole('button', { name: 'Cancel' }).click()
+ await expect(modal).toBeHidden()
+})
+
test('Fleet viewer cannot set target release', async ({ browser }) => {
const page = await getPageAsUser(browser, 'Jane Austen')
await page.goto('/system/update')
diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts
index 0ee45106a..a0d6edfe5 100644
--- a/test/e2e/utils.ts
+++ b/test/e2e/utils.ts
@@ -9,7 +9,7 @@ import { expect, test, type Browser, type Locator, type Page } from '@playwright
import { MiB } from '~/util/units'
-import { MSW_USER_COOKIE } from '../../mock-api/msw/util'
+import { MSW_FLAGS_COOKIE, MSW_USER_COOKIE, type MockFlag } from '../../mock-api/msw/util'
export * from '@playwright/test'
@@ -221,11 +221,22 @@ export async function selectOption(
}
}
-export async function getPageAsUser(browser: Browser, user: string): Promise {
+function cookie(name: string, value: string) {
+ return { name, value, domain: 'localhost', path: '/' }
+}
+
+export async function getPageAsUser(
+ browser: Browser,
+ user: string,
+ // fleet-level overrides; see mockFlags in mock-api/msw/util.ts
+ flags: MockFlag[] = []
+): Promise {
const browserContext = await browser.newContext()
- await browserContext.addCookies([
- { name: MSW_USER_COOKIE, value: user, domain: 'localhost', path: '/' },
- ])
+ const cookies = [cookie(MSW_USER_COOKIE, user)]
+ if (flags.length) {
+ cookies.push(cookie(MSW_FLAGS_COOKIE, flags.join(',')))
+ }
+ await browserContext.addCookies(cookies)
return await browserContext.newPage()
}