mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(relay): the cell switch tool changes only the named switches and checks every key on read-back A cell resets a key missing from its switch file to its default, so the tool now reads the current object and changes only what --set names. The read-back compares every known key and fails when the cell reports ignoredKeys. * fix(relay): the cell switch tool refuses a switch the cell's image does not support It reads supportedFlags from runtime-status (#27057). An image from before that field takes only readinessLocal and ticketCheck off/shadow, so ticketCheck=enforce cannot void an old cell's object. * fix(relay): the switch tool validates the whole object, refuses voided files, and records admitMode in Postgres Every key and value of the object to be written is checked against what the image accepts (an image without the list: only the keys it applies, ticketCheck off/shadow). A file the cell did not apply is refused. admitMode=reserve needs a typed RESERVE <cell>; Postgres records reserve before the file is written, and db only after the cell reports every control leased again. * feat(relay): the switch tool knows reregisterInFlight (1-16), the flip-back pace * fix(relay): the switch tool closes the cache gap before reserve, undoes a refused write, and has a break-glass db flip After recording reserve in Postgres it waits past every director's 5 s cache and records it again (re-running the open-flow check) before writing the file. A file write refused outright (412 or 4xx) puts Postgres back to db; after a 5xx it stays reserve. An object the cell did not apply is repaired when the new object passes the image check. Break glass (typed BREAK GLASS <cell>) records db for a dead or unreachable reserve cell without its read-back. * fix(relay): a director deploy refuses a traffic move across reserve-mode support while any cell is reserve * fix(relay): the flip-back wait scales with the cell's controls and pace, and says how to finish * fix(relay): the switch tool refuses to re-arm a tripped cell, breaks glass only on a silent one, and undoes reserve if the second post is refused * feat(relay): director deploys set reserve placement, and refuse turning it off while any cell is reserve * fix(relay): Terraform keeps the deploy-owned placement and shadow cells, and the deploy guard reads placement as built and refuses dropping a reserve cell from the shadow list * fix(relay): director deploys take --admin-audience alone, name a drifted deploy identity, and carry the shadow seat-feed cells Staging passes --admin-audience for the reserve guard without durable rehome verification, which the parser refused. Only the generation and control origin now travel together, and they still need the audience. The guard decodes the caller from its token and refuses, by name, a revision whose ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT is another account, instead of reading into a 401. --shadow-seat-feed-cells (preserve, all, or a cell list) is a workflow input on both director deploys; preserve keeps the serving value. A contract test parses every workflow's real argv for both deploy scripts. * fix(relay): the flag tool paces a flip back from the pool size the cell reports An image from before the field falls back to the deployed sizes.
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
// Pure pieces of the director deploy driver: the exact inputs each existing workflow receives,
|
|
// and the parsers that read a run's result back. No process, network, or clock access here.
|
|
|
|
import {
|
|
RELAY_GITHUB_REPOSITORY,
|
|
RELAY_WORKFLOW_FILE_PREFIX,
|
|
relayWorkflowFile
|
|
} from './relay-repository.mjs'
|
|
|
|
export const REPOSITORY = RELAY_GITHUB_REPOSITORY
|
|
export const WORKFLOW_REF = 'main'
|
|
export const PROJECT = 'onorca-cloud'
|
|
export const REGION = 'us-central1'
|
|
export const DIRECTOR_SERVICE = 'orca-cloud-relay'
|
|
export const ROLLBACK_TAG = 'selector-rollback'
|
|
export const IMAGE_REPOSITORY = 'us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay'
|
|
// Any reviewed registration wave is accepted by admission inspect; the launch wave never changes.
|
|
const ADMISSION_INSPECT_CELLS = 'production-gce-c27,production-gce-c28,production-gce-c29'
|
|
|
|
const workflow = (name, title) => ({ file: relayWorkflowFile(name), name: title })
|
|
export const WORKFLOWS = {
|
|
publish: workflow('publish-relay-production.yml', 'Publish Relay Production Image'),
|
|
director: workflow('deploy-relay-production-director.yml', 'Deploy Relay Production Director'),
|
|
rehome: workflow('operate-relay-production-rehome.yml', 'Operate Relay Production Rehome'),
|
|
admission: workflow('operate-relay-asia-admission.yml', 'Operate Relay Asia Admission'),
|
|
monitor: workflow('monitor-relay-production.yml', 'Monitor Relay Production')
|
|
}
|
|
|
|
// Read-only or unrelated to the production rollout lane, so they never block a deploy.
|
|
const NON_BLOCKING_WORKFLOWS = new Set(
|
|
['verify.yml', 'monitor-relay-clock-skew.yml'].map(relayWorkflowFile)
|
|
)
|
|
|
|
const DIGEST = /^sha256:[a-f0-9]{64}$/
|
|
const COMMIT = /^[a-f0-9]{40}$/
|
|
const PRODUCTION_CELL = /^production-gce-c[1-9][0-9]?$/
|
|
const MEMBERSHIP_KEYS = ['existingOnly', 'migrationOnly', 'general']
|
|
|
|
export function requireDigest(value, label) {
|
|
if (typeof value !== 'string' || !DIGEST.test(value))
|
|
throw new Error(`${label} is not an immutable sha256 digest`)
|
|
return value
|
|
}
|
|
|
|
export function requireCommit(value, label) {
|
|
if (typeof value !== 'string' || !COMMIT.test(value))
|
|
throw new Error(`${label} is not a full commit SHA`)
|
|
return value
|
|
}
|
|
|
|
function requireGeneration(value, label) {
|
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} is invalid`)
|
|
return value
|
|
}
|
|
|
|
export function blocksDeploy(path) {
|
|
// A `@ref` suffix never appears on run paths today; stripping it keeps the check fail-closed.
|
|
const file = String(path ?? '')
|
|
.split('@')[0]
|
|
.split('/')
|
|
.at(-1)
|
|
return (
|
|
file.startsWith(RELAY_WORKFLOW_FILE_PREFIX) &&
|
|
file.endsWith('.yml') &&
|
|
!NON_BLOCKING_WORKFLOWS.has(file)
|
|
)
|
|
}
|
|
|
|
export function membershipInput(cells) {
|
|
return cells.length === 0 ? 'none' : [...cells].sort().join(',')
|
|
}
|
|
|
|
export function parseSelector(value, label) {
|
|
const selector = {
|
|
generation: requireGeneration(value?.generation, `${label} generation`),
|
|
membership: {}
|
|
}
|
|
for (const key of MEMBERSHIP_KEYS) {
|
|
const cells = value?.membership?.[key]
|
|
if (!Array.isArray(cells) || cells.some((cell) => !PRODUCTION_CELL.test(cell))) {
|
|
throw new Error(`${label} ${key} membership is invalid`)
|
|
}
|
|
selector.membership[key] = [...cells].sort()
|
|
}
|
|
const all = MEMBERSHIP_KEYS.flatMap((key) => selector.membership[key])
|
|
if (new Set(all).size !== all.length) throw new Error(`${label} membership has duplicates`)
|
|
return selector
|
|
}
|
|
|
|
function selectorInputs(selector) {
|
|
return {
|
|
'expected-selector-generation': String(selector.generation),
|
|
'expected-existing-only-cells': membershipInput(selector.membership.existingOnly),
|
|
'expected-migration-only-cells': membershipInput(selector.membership.migrationOnly),
|
|
'expected-general-cells': membershipInput(selector.membership.general)
|
|
}
|
|
}
|
|
|
|
export function parseControl(value, label) {
|
|
const control = value ?? {}
|
|
const integers = [
|
|
'generation',
|
|
'notBefore',
|
|
'ratePerMinute',
|
|
'preferenceMaxAgeMs',
|
|
'drainGraceMs'
|
|
]
|
|
if (
|
|
typeof control.enabled !== 'boolean' ||
|
|
integers.some((key) => !Number.isSafeInteger(control[key]))
|
|
) {
|
|
throw new Error(`${label} is not a complete regional rehome control`)
|
|
}
|
|
if (control.hostCooldownMs !== undefined && !Number.isSafeInteger(control.hostCooldownMs)) {
|
|
throw new Error(`${label} host cooldown is invalid`)
|
|
}
|
|
return Object.fromEntries(
|
|
[...integers, 'enabled', 'hostCooldownMs'].map((key) => [key, control[key]])
|
|
)
|
|
}
|
|
|
|
const INTEGER_INPUT = /^(0|[1-9][0-9]*)$/
|
|
|
|
/**
|
|
* The last check before `gh workflow run`. Builders also render dry-run placeholders such as
|
|
* `<published digest>`; this guarantees none of them, or a malformed digest, is ever dispatched.
|
|
*/
|
|
export function validateDispatchInputs(inputs) {
|
|
for (const [key, value] of Object.entries(inputs)) {
|
|
if (typeof value !== 'string' || value.startsWith('<'))
|
|
throw new Error(`input ${key} is not resolved`)
|
|
if (key.endsWith('digest') && !DIGEST.test(value))
|
|
throw new Error(`input ${key} is not a sha256 digest`)
|
|
if (
|
|
(key.includes('generation') ||
|
|
['not-before', 'monitor-run-id', 'monitor-run-attempt'].includes(key)) &&
|
|
!INTEGER_INPUT.test(value)
|
|
) {
|
|
throw new Error(`input ${key} is not an integer`)
|
|
}
|
|
}
|
|
return inputs
|
|
}
|
|
|
|
export function admissionInspectInputs(servingDigest) {
|
|
return {
|
|
environment: 'production',
|
|
mode: 'inspect',
|
|
'cell-ids': ADMISSION_INSPECT_CELLS,
|
|
'image-digest': servingDigest
|
|
}
|
|
}
|
|
|
|
function rehomeInputs(mode, { director, selector, controlGeneration }) {
|
|
return {
|
|
mode,
|
|
'director-image-digest': director.servingDigest,
|
|
'rollback-image-digest': director.rollbackDigest,
|
|
...selectorInputs(selector),
|
|
'expected-control-generation': String(controlGeneration)
|
|
}
|
|
}
|
|
|
|
export function rehomeInspectInputs(context) {
|
|
return rehomeInputs('inspect', context)
|
|
}
|
|
|
|
// Pause keeps every durable field as inspected; only `enabled` and the generation change.
|
|
// Every `confirmation` is the phrase the operator typed, never filled in here.
|
|
export function rehomePauseInputs({ director, selector, control, confirmation }) {
|
|
return {
|
|
...rehomeInputs('pause', { director, selector, controlGeneration: control.generation }),
|
|
'not-before': String(control.notBefore),
|
|
'rate-per-minute': String(control.ratePerMinute),
|
|
'preference-max-age-ms': String(control.preferenceMaxAgeMs),
|
|
'host-cooldown-ms': String(control.hostCooldownMs ?? 604_800_000),
|
|
'drain-grace-ms': String(control.drainGraceMs),
|
|
confirmation
|
|
}
|
|
}
|
|
|
|
export function rehomeEnableInputs({
|
|
director,
|
|
selector,
|
|
control,
|
|
controlGeneration,
|
|
notBefore,
|
|
monitor,
|
|
confirmation
|
|
}) {
|
|
return {
|
|
...rehomeInputs('enable', { director, selector, controlGeneration }),
|
|
'not-before': String(notBefore),
|
|
// The enable job accepts exactly 10 per minute.
|
|
'rate-per-minute': '10',
|
|
'preference-max-age-ms': String(control.preferenceMaxAgeMs),
|
|
'host-cooldown-ms': String(control.hostCooldownMs),
|
|
'drain-grace-ms': String(control.drainGraceMs),
|
|
'monitor-run-id': String(monitor.runId),
|
|
'monitor-run-attempt': String(monitor.attempt),
|
|
confirmation
|
|
}
|
|
}
|
|
|
|
export function publishInputs() {
|
|
return { mode: 'publish' }
|
|
}
|
|
|
|
export function directorDeployInputs({ imageDigest, predecessorDigest, rehomeGeneration }) {
|
|
return {
|
|
'image-digest': imageDigest,
|
|
'regional-placement-mode': 'preserve',
|
|
'region-correction-cohort-percent': 'preserve',
|
|
'reserve-placement': 'preserve',
|
|
'shadow-seat-feed-cells': 'preserve',
|
|
'prune-incompatible-revisions': 'false',
|
|
'expected-rehome-generation': String(rehomeGeneration),
|
|
'bootstrap-runtime-identity': 'false',
|
|
// Required by the form even without the bootstrap; it is only format-checked then.
|
|
'predecessor-image-digest': predecessorDigest
|
|
}
|
|
}
|
|
|
|
export function configureInputs({
|
|
cells,
|
|
cellImageDigest,
|
|
directorDigest,
|
|
selectorGeneration,
|
|
confirmation
|
|
}) {
|
|
return {
|
|
environment: 'production',
|
|
mode: 'configure',
|
|
'cell-ids': cells.join(','),
|
|
'selector-generation': String(selectorGeneration),
|
|
'image-digest': cellImageDigest,
|
|
'director-image-digest': directorDigest,
|
|
confirmation
|
|
}
|
|
}
|
|
|
|
export function monitorDryRunInputs(selector) {
|
|
return {
|
|
mode: 'dry-run',
|
|
...selectorInputs(selector),
|
|
'migration-policy': 'strict',
|
|
'recovery-source-cell-id': 'none',
|
|
'capacity-cell-id': 'none'
|
|
}
|
|
}
|
|
|
|
export function parseConfigureWave(value) {
|
|
const separator = String(value).lastIndexOf('=')
|
|
const cells = String(value)
|
|
.slice(0, separator)
|
|
.split(',')
|
|
.map((cell) => cell.trim())
|
|
.filter(Boolean)
|
|
const cellImageDigest = String(value).slice(separator + 1)
|
|
if (
|
|
separator < 1 ||
|
|
cells.length === 0 ||
|
|
new Set(cells).size !== cells.length ||
|
|
cells.some((cell) => !PRODUCTION_CELL.test(cell))
|
|
) {
|
|
throw new Error('--configure must be <cell>[,<cell>...]=sha256:<cell image digest>')
|
|
}
|
|
return { cells, cellImageDigest: requireDigest(cellImageDigest, '--configure cell image digest') }
|
|
}
|
|
|
|
/** The single 100% revision and the selector-rollback revision of `gcloud run services describe`. */
|
|
export function directorRevisions(service) {
|
|
const traffic = Array.isArray(service?.status?.traffic) ? service.status.traffic : []
|
|
const serving = traffic.filter((entry) => (entry.percent ?? 0) > 0)
|
|
const rollback = traffic.filter((entry) => entry.tag === ROLLBACK_TAG)
|
|
if (serving.length !== 1 || serving[0].percent !== 100 || !serving[0].revisionName) {
|
|
throw new Error('director does not serve one revision at 100%')
|
|
}
|
|
if (rollback.length !== 1 || !rollback[0].revisionName) {
|
|
throw new Error(`director has no single ${ROLLBACK_TAG} revision`)
|
|
}
|
|
return { servingRevision: serving[0].revisionName, rollbackRevision: rollback[0].revisionName }
|
|
}
|
|
|
|
export function revisionDigest(revision, label) {
|
|
const image = revision?.spec?.containers?.[0]?.image
|
|
const [repository, digest] = String(image ?? '').split('@')
|
|
if (repository !== IMAGE_REPOSITORY)
|
|
throw new Error(`${label} does not run the production relay image`)
|
|
return requireDigest(digest, `${label} image`)
|
|
}
|
|
|
|
/** The relay push line, `sha-<commit>: digest: sha256:… size: …`, must name the registry digest. */
|
|
export function logConfirmsPublishedDigest(log, commit, digest) {
|
|
return String(log)
|
|
.split('\n')
|
|
.some((line) => line.includes(`sha-${commit}: digest: ${digest} size:`))
|
|
}
|
|
|
|
/** The last `relay_regional_rehome_control` JSON line a rehome run printed, of any mode. */
|
|
export function rehomeResultFromLog(log) {
|
|
let found
|
|
for (const line of String(log).split('\n')) {
|
|
const start = line.indexOf('{"event":"relay_regional_rehome_control"')
|
|
if (start < 0) continue
|
|
try {
|
|
found = JSON.parse(line.slice(start).trim())
|
|
} catch {
|
|
// A truncated or echoed line is not the result.
|
|
}
|
|
}
|
|
if (!found) throw new Error('the rehome run printed no control result')
|
|
return {
|
|
mode: found.mode,
|
|
recovered: found.recovered,
|
|
control: parseControl(found.control, `rehome ${found.mode} control`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The generation a run paused rehome at, or undefined. Only two lines prove a run paused it: a
|
|
* `pause`, and a failed enable's `recover-enable` that itself disabled rehome (`recovered: true`).
|
|
* `recovered: false` means rehome was already disabled, possibly by a director safety pause.
|
|
*/
|
|
export function pausedGeneration(result) {
|
|
const paused =
|
|
!result.control.enabled &&
|
|
(result.mode === 'pause' || (result.mode === 'recover-enable' && result.recovered === true))
|
|
return paused ? result.control.generation : undefined
|
|
}
|
|
|
|
export function admissionInspectResult(result) {
|
|
if (result?.mode !== 'inspect') throw new Error('admission result is not an inspect')
|
|
return parseSelector(result, 'admission selector')
|
|
}
|