mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Phase 6 of the relay split: the relay's deploy/operate surface moves under cloud/ with 24 cloud-* workflows gated on ORCA_CLOUD_OPERATIONS_ENABLED, the Cloud SQL rollout lease action, the relay Terraform root (dual-accept identities for both repositories), scripts, docs, CODEOWNERS, and a terraform validate job in Cloud Verify.
3136 lines
103 KiB
JavaScript
3136 lines
103 KiB
JavaScript
import { readFileSync } from 'node:fs'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { resolve4 } from 'node:dns/promises'
|
|
import { pathToFileURL } from 'node:url'
|
|
import {
|
|
aggregateCellStatus,
|
|
assertDeploymentConnectionCapacity,
|
|
createAdminPost,
|
|
defaultCommandJson,
|
|
defaultIdentityToken,
|
|
deployment,
|
|
drainSource,
|
|
inspectCell,
|
|
setCellState,
|
|
suppliedFenceMutationIdentityToken,
|
|
validateBackend,
|
|
validateInstance,
|
|
validateMig
|
|
} from './deploy-relay-gce-candidate.mjs'
|
|
import {
|
|
abortSupersededTerraformFenceBeforeUpload,
|
|
abortTerraformFenceBeforeApply,
|
|
adoptLegacyTerraformFence,
|
|
assertTerraformFenceSet,
|
|
assertTerraformFenceZeroDiff,
|
|
deleteTerraformFencePlan,
|
|
downloadTerraformFencePlan,
|
|
inspectCompletedTerraformFenceProgress,
|
|
inspectTerraformFenceProgress,
|
|
assertTerraformFenceStateFenced,
|
|
readTerraformStateObjectBinding,
|
|
recoverSupersededCompletedTerraformFence,
|
|
resolveTerraformFencePlanGeneration,
|
|
resumeTerraformFence,
|
|
runTerraformFenceApply,
|
|
uploadTerraformFencePlan
|
|
} from './relay-gce-terraform-fence.mjs'
|
|
import {
|
|
addExactMigrationCells,
|
|
applyExactAdmissionSelector,
|
|
inspectAdmissionSelector,
|
|
membershipWithStates,
|
|
selectorCellState
|
|
} from './relay-admission-selector.mjs'
|
|
|
|
const DEFAULT_BATCH_SIZE = 100
|
|
const DEFAULT_CONNECTION_CEILING = 600
|
|
const DEFAULT_MINIMUM_LEASE_MS = 10 * 60 * 1_000
|
|
const DEFAULT_POLL_MS = 5_000
|
|
const DEFAULT_TIMEOUT_MS = 14 * 60 * 1_000
|
|
const CUTOVER_CONNECTION_HARD_CAP = 600
|
|
const CUTOVER_CONTROL_REBIND_RESERVE = 100
|
|
const MAX_PRE_AUTH_CONNECTIONS = 45
|
|
const SELECTOR_ROLLBACK_TAG = 'selector-rollback'
|
|
const SELECTOR_REVISION_MARKER = '3'
|
|
const FENCE_BROKER_MUTATION_ROUTES = new Set([
|
|
'/v1/admin/cell-fence-adopt-legacy',
|
|
'/v1/admin/cell-fence-commit-legacy-adoption',
|
|
'/v1/admin/cell-fence-attest',
|
|
'/v1/admin/cell-fence-attempt-prepare',
|
|
'/v1/admin/cell-fence-attempt-start',
|
|
'/v1/admin/cell-fence-attempt-plan',
|
|
'/v1/admin/cell-fence-attempt-operation',
|
|
'/v1/admin/cell-fence-attempt-abort',
|
|
'/v1/admin/migration-supersede-cell'
|
|
])
|
|
|
|
function positiveInteger(value, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
const parsed = Number(value)
|
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
|
throw new Error(`${name} must be a positive integer`)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function nonnegativeInteger(value, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
const parsed = Number(value)
|
|
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximum) {
|
|
throw new Error(`${name} must be a nonnegative integer`)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function canonicalOrigin(value, name) {
|
|
const url = new URL(value)
|
|
if (url.protocol !== 'https:' || url.origin !== value || url.pathname !== '/') {
|
|
throw new Error(`${name} must be a canonical HTTPS origin`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function targetIds(value, minimum) {
|
|
const ids = [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))].sort()
|
|
if (ids.length < minimum || ids.some((id) => !/^[a-z][a-z0-9-]{0,127}$/.test(id))) {
|
|
throw new Error(
|
|
`--target-cell-ids must contain at least ${minimum} distinct cell ID${minimum === 1 ? '' : 's'}`
|
|
)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
function optionalCellIds(value, name) {
|
|
const ids = [...new Set(String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean))]
|
|
.sort()
|
|
if (ids.some((id) => !/^[a-z][a-z0-9-]{0,127}$/.test(id))) {
|
|
throw new Error(`${name} contains an invalid cell ID`)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
export function parseMultiTargetArguments(argv) {
|
|
const values = {}
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const key = argv[index]
|
|
const value = argv[index + 1]
|
|
if (!key?.startsWith('--') || value === undefined) throw new Error(`invalid argument ${key ?? ''}`)
|
|
values[key.slice(2)] = value
|
|
}
|
|
for (const key of [
|
|
'project',
|
|
'director-origin',
|
|
'admin-audience',
|
|
'topology-file',
|
|
'source-cell-id',
|
|
'target-cell-ids',
|
|
'runtime-service-account',
|
|
'mode'
|
|
]) {
|
|
if (!values[key]) throw new Error(`missing --${key}`)
|
|
}
|
|
if (
|
|
![
|
|
'audit',
|
|
'preflight',
|
|
'execute',
|
|
'cutover-admission',
|
|
'add-migration-cells',
|
|
'promote-general-cell',
|
|
'retire-migration-cell',
|
|
'recover-forward',
|
|
'fence-source',
|
|
'abort-fence-source',
|
|
'supersede-target'
|
|
].includes(values.mode)
|
|
) {
|
|
throw new Error(
|
|
'--mode must be audit, preflight, execute, cutover-admission, add-migration-cells, promote-general-cell, retire-migration-cell, recover-forward, fence-source, abort-fence-source, or supersede-target'
|
|
)
|
|
}
|
|
const singleTargetMode = [
|
|
'add-migration-cells',
|
|
'promote-general-cell',
|
|
'retire-migration-cell'
|
|
].includes(values.mode)
|
|
const parsedTargetIds = targetIds(values['target-cell-ids'], singleTargetMode ? 1 : 2)
|
|
const generalCellIds = optionalCellIds(values['general-cell-ids'], '--general-cell-ids')
|
|
const failedTargetCellId = values['failed-target-cell-id']
|
|
const replacementTargetCellId = values['replacement-target-cell-id']
|
|
if (
|
|
['promote-general-cell', 'retire-migration-cell'].includes(values.mode) &&
|
|
parsedTargetIds.length !== 1
|
|
) {
|
|
throw new Error(`${values.mode} requires exactly one target cell ID`)
|
|
}
|
|
if (values.mode === 'supersede-target') {
|
|
if (!failedTargetCellId || !replacementTargetCellId) {
|
|
throw new Error('supersede-target requires failed and replacement target cell IDs')
|
|
}
|
|
if (
|
|
failedTargetCellId === replacementTargetCellId ||
|
|
!parsedTargetIds.includes(failedTargetCellId) ||
|
|
!parsedTargetIds.includes(replacementTargetCellId) ||
|
|
parsedTargetIds.length !== 2
|
|
) {
|
|
throw new Error('supersede-target target set must exactly match failed and replacement')
|
|
}
|
|
}
|
|
const selectorMode = [
|
|
'cutover-admission',
|
|
'add-migration-cells',
|
|
'promote-general-cell',
|
|
'retire-migration-cell'
|
|
].includes(values.mode)
|
|
if (selectorMode) {
|
|
for (const key of ['director-region', 'director-service', 'director-min-instances']) {
|
|
if (!values[key]) throw new Error(`${values.mode} requires --${key}`)
|
|
}
|
|
if (values.mode === 'cutover-admission' && generalCellIds.length === 0) {
|
|
throw new Error('cutover-admission requires --general-cell-ids')
|
|
}
|
|
if (
|
|
values['selector-attempt-id'] &&
|
|
!/^[A-Za-z0-9_-]{8,128}$/.test(values['selector-attempt-id'])
|
|
) {
|
|
throw new Error('--selector-attempt-id is invalid')
|
|
}
|
|
if (
|
|
['add-migration-cells', 'promote-general-cell', 'retire-migration-cell'].includes(
|
|
values.mode
|
|
) &&
|
|
!values['selector-attempt-id']
|
|
) {
|
|
throw new Error(`${values.mode} requires --selector-attempt-id`)
|
|
}
|
|
}
|
|
const capacityBoundModes = [
|
|
'cutover-admission',
|
|
'add-migration-cells',
|
|
'recover-forward',
|
|
'fence-source'
|
|
]
|
|
if (
|
|
capacityBoundModes.includes(values.mode) &&
|
|
!values['unobserved-connection-bound']
|
|
) {
|
|
throw new Error(`${values.mode} requires --unobserved-connection-bound`)
|
|
}
|
|
const adminAudience = new URL(values['admin-audience'])
|
|
if (
|
|
adminAudience.protocol !== 'https:' ||
|
|
adminAudience.pathname !== '/v1/admin/drain' ||
|
|
adminAudience.search ||
|
|
adminAudience.hash
|
|
) {
|
|
throw new Error('--admin-audience must be the director drain URL')
|
|
}
|
|
if (!['staging', 'production'].includes(values.environment ?? 'production')) {
|
|
throw new Error('--environment must be staging or production')
|
|
}
|
|
if (
|
|
['fence-source', 'abort-fence-source', 'supersede-target'].includes(values.mode) &&
|
|
!/^[a-f0-9]{40}$/.test(values['fence-commit'] ?? '')
|
|
) {
|
|
throw new Error('Terraform fence modes require the exact --fence-commit')
|
|
}
|
|
const completedFenceFields = {
|
|
attemptId: values['completed-fence-attempt-id'],
|
|
fenceCommit: values['completed-fence-commit'],
|
|
gceOperation: values['completed-fence-operation'],
|
|
terraformStateSerial: values['completed-fence-state-serial'],
|
|
planObjectGeneration: values['completed-fence-plan-generation'],
|
|
terraformStateObjectGeneration: values['completed-fence-state-generation'],
|
|
terraformStateObjectSha256: values['completed-fence-state-sha256'],
|
|
principalEmail: values['fence-broker-service-account']
|
|
}
|
|
const completedFenceValues = Object.values(completedFenceFields)
|
|
const completedFenceRecovery =
|
|
completedFenceValues.every((value) => value === undefined)
|
|
? undefined
|
|
: completedFenceFields
|
|
if (
|
|
completedFenceRecovery &&
|
|
(values.mode !== 'supersede-target' ||
|
|
completedFenceValues.some((value) => value === undefined) ||
|
|
!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(
|
|
completedFenceRecovery.attemptId
|
|
) ||
|
|
!/^[a-f0-9]{40}$/.test(completedFenceRecovery.fenceCommit) ||
|
|
completedFenceRecovery.fenceCommit === values['fence-commit'] ||
|
|
!/^[A-Za-z0-9._-]{1,256}$/.test(completedFenceRecovery.gceOperation) ||
|
|
!/^[1-9][0-9]{0,30}$/.test(completedFenceRecovery.planObjectGeneration) ||
|
|
!/^[1-9][0-9]{0,30}$/.test(
|
|
completedFenceRecovery.terraformStateObjectGeneration
|
|
) ||
|
|
!/^[a-f0-9]{64}$/.test(
|
|
completedFenceRecovery.terraformStateObjectSha256
|
|
) ||
|
|
!/^[^@\s]+@[^@\s]+\.gserviceaccount\.com$/.test(
|
|
completedFenceRecovery.principalEmail
|
|
))
|
|
) {
|
|
throw new Error('completed Terraform fence recovery inputs are invalid')
|
|
}
|
|
const minimumLeaseRemainingMs = positiveInteger(
|
|
values['minimum-lease-remaining-ms'] ?? DEFAULT_MINIMUM_LEASE_MS,
|
|
'--minimum-lease-remaining-ms',
|
|
60 * 60 * 1_000
|
|
)
|
|
if (minimumLeaseRemainingMs < DEFAULT_MINIMUM_LEASE_MS) {
|
|
throw new Error('--minimum-lease-remaining-ms cannot be below 600000')
|
|
}
|
|
return {
|
|
project: values.project,
|
|
directorOrigin: canonicalOrigin(values['director-origin'], '--director-origin'),
|
|
adminAudience: adminAudience.toString(),
|
|
topologyFile: values['topology-file'],
|
|
sourceCellId: values['source-cell-id'],
|
|
targetCellIds: parsedTargetIds,
|
|
generalCellIds,
|
|
directorRegion: values['director-region'],
|
|
directorService: values['director-service'],
|
|
directorMinimumInstances: selectorMode
|
|
? positiveInteger(values['director-min-instances'], '--director-min-instances', 1_000)
|
|
: undefined,
|
|
selectorAttemptId: values['selector-attempt-id'],
|
|
unobservedConnectionBound:
|
|
capacityBoundModes.includes(values.mode)
|
|
? nonnegativeInteger(
|
|
values['unobserved-connection-bound'],
|
|
'--unobserved-connection-bound',
|
|
CUTOVER_CONNECTION_HARD_CAP - CUTOVER_CONTROL_REBIND_RESERVE - 1
|
|
)
|
|
: undefined,
|
|
failedTargetCellId,
|
|
replacementTargetCellId,
|
|
completedFenceRecovery: completedFenceRecovery
|
|
? {
|
|
...completedFenceRecovery,
|
|
terraformStateSerial: nonnegativeInteger(
|
|
completedFenceRecovery.terraformStateSerial,
|
|
'--completed-fence-state-serial'
|
|
)
|
|
}
|
|
: undefined,
|
|
runtimeServiceAccount: values['runtime-service-account'],
|
|
environment: values.environment ?? 'production',
|
|
fenceCommit: values['fence-commit'],
|
|
terraformDir: values['terraform-dir'] ?? 'infra/terraform',
|
|
terraformVarFile:
|
|
values['terraform-var-file'] ??
|
|
`environments/${values.environment ?? 'production'}.tfvars`,
|
|
mode: values.mode,
|
|
batchSize: positiveInteger(values['batch-size'] ?? DEFAULT_BATCH_SIZE, '--batch-size', 100),
|
|
connectionCeiling: positiveInteger(
|
|
values['connection-ceiling'] ?? DEFAULT_CONNECTION_CEILING,
|
|
'--connection-ceiling',
|
|
100_000
|
|
),
|
|
minimumLeaseRemainingMs,
|
|
drainGraceMs: positiveInteger(
|
|
values['drain-grace-ms'] ?? 120_000,
|
|
'--drain-grace-ms',
|
|
60 * 60 * 1_000
|
|
),
|
|
pollIntervalMs: positiveInteger(
|
|
values['poll-interval-ms'] ?? DEFAULT_POLL_MS,
|
|
'--poll-interval-ms',
|
|
60_000
|
|
),
|
|
timeoutMs: positiveInteger(
|
|
values['timeout-ms'] ?? DEFAULT_TIMEOUT_MS,
|
|
'--timeout-ms',
|
|
60 * 60 * 1_000
|
|
)
|
|
}
|
|
}
|
|
|
|
export function selectMultiTargetDeployments(topology, sourceCellId, targetCellIds) {
|
|
const legacyCapacityTopology = Object.values(topology).every(
|
|
(cell) =>
|
|
cell &&
|
|
typeof cell === 'object' &&
|
|
!Object.hasOwn(cell, 'connection_hard_cap') &&
|
|
!Object.hasOwn(cell, 'connection_unobserved_bound')
|
|
)
|
|
const fromTopology = (cellId) => ({
|
|
...deployment(topology[cellId], cellId),
|
|
legacyCapacityTopology
|
|
})
|
|
const source = fromTopology(sourceCellId)
|
|
const targets = targetCellIds.map(fromTopology)
|
|
const resources = new Map()
|
|
for (const cell of [source, ...targets]) {
|
|
for (const key of ['origin', 'migName', 'instanceGroup', 'backendName', 'backendId']) {
|
|
const resourceKey = `${key}:${cell[key]}`
|
|
const previous = resources.get(resourceKey)
|
|
if (previous) throw new Error(`${cell.cellId} ${key} overlaps ${previous}`)
|
|
resources.set(resourceKey, cell.cellId)
|
|
}
|
|
}
|
|
if (targets.some((target) => target.initiallyEnabled)) {
|
|
throw new Error('every target must be declared initially disabled')
|
|
}
|
|
return { source, targets }
|
|
}
|
|
|
|
function revisionEnvironment(revision) {
|
|
return Object.fromEntries(
|
|
(revision.spec?.containers?.[0]?.env ?? [])
|
|
.filter((entry) => entry.name && 'value' in entry)
|
|
.map((entry) => [entry.name, entry.value])
|
|
)
|
|
}
|
|
|
|
function revisionMinimum(revision) {
|
|
return Number(revision.metadata?.annotations?.['autoscaling.knative.dev/minScale'] ?? 0)
|
|
}
|
|
|
|
function directorInventory(environment, revisionName) {
|
|
let cells
|
|
try {
|
|
cells = JSON.parse(environment.ORCA_RELAY_CELLS_JSON)
|
|
} catch {
|
|
throw new Error(`${revisionName} has an invalid director inventory`)
|
|
}
|
|
const ids = Array.isArray(cells) ? cells.map((cell) => cell?.id) : []
|
|
if (
|
|
ids.length === 0 ||
|
|
ids.some((id) => typeof id !== 'string' || id.length === 0) ||
|
|
new Set(ids).size !== ids.length
|
|
) {
|
|
throw new Error(`${revisionName} has an invalid director inventory`)
|
|
}
|
|
return JSON.stringify(cells)
|
|
}
|
|
|
|
export function verifySelectorCompatibleDirector(config, deps) {
|
|
const common = [
|
|
'--project',
|
|
config.project,
|
|
'--region',
|
|
config.directorRegion,
|
|
'--format=json'
|
|
]
|
|
const service = deps.commandJson([
|
|
'run',
|
|
'services',
|
|
'describe',
|
|
config.directorService,
|
|
...common
|
|
])
|
|
const active = (service.status?.traffic ?? []).filter(
|
|
(entry) => Number(entry.percent ?? 0) > 0
|
|
)
|
|
const rollback = (service.status?.traffic ?? []).find(
|
|
(entry) => entry.tag === SELECTOR_ROLLBACK_TAG
|
|
)
|
|
if (
|
|
active.length !== 1 ||
|
|
Number(active[0].percent) !== 100 ||
|
|
!active[0].revisionName ||
|
|
!rollback?.revisionName ||
|
|
Number(rollback.percent ?? 0) !== 0 ||
|
|
rollback.revisionName === active[0].revisionName
|
|
) {
|
|
throw new Error('director lacks an isolated compatible rollback revision')
|
|
}
|
|
const revisions = deps.commandJson([
|
|
'run',
|
|
'revisions',
|
|
'list',
|
|
'--service',
|
|
config.directorService,
|
|
...common
|
|
])
|
|
const allowed = new Set([active[0].revisionName, rollback.revisionName])
|
|
const names = revisions.map((revision) => revision.metadata?.name).filter(Boolean)
|
|
if (
|
|
revisions.length !== 2 ||
|
|
names.length !== 2 ||
|
|
names.some((name) => !allowed.has(name))
|
|
) {
|
|
throw new Error('old or pre-selector director revisions still exist')
|
|
}
|
|
let compatibleImage
|
|
let compatibleInventory
|
|
for (const revisionName of allowed) {
|
|
const revision = deps.commandJson([
|
|
'run',
|
|
'revisions',
|
|
'describe',
|
|
revisionName,
|
|
...common
|
|
])
|
|
const environment = revisionEnvironment(revision)
|
|
if (
|
|
environment.ORCA_RELAY_ROLE !== 'director' ||
|
|
environment.ORCA_RELAY_ADMISSION_SELECTOR_VERSION !== SELECTOR_REVISION_MARKER
|
|
) {
|
|
throw new Error(`${revisionName} is not selector-compatible`)
|
|
}
|
|
const inventory = directorInventory(environment, revisionName)
|
|
if (compatibleInventory && inventory !== compatibleInventory) {
|
|
throw new Error('active and rollback director inventories do not match')
|
|
}
|
|
compatibleInventory = inventory
|
|
const image = revision.spec?.containers?.[0]?.image
|
|
if (!image || (compatibleImage && image !== compatibleImage)) {
|
|
throw new Error('active and rollback director images do not match')
|
|
}
|
|
compatibleImage = image
|
|
if (revisionName === rollback.revisionName && revisionMinimum(revision) !== 0) {
|
|
throw new Error('selector rollback revision is not scale-to-zero')
|
|
}
|
|
if (
|
|
revisionName === active[0].revisionName &&
|
|
!(revisionMinimum(revision) >= config.directorMinimumInstances)
|
|
) {
|
|
throw new Error('active selector revision is below the required floor')
|
|
}
|
|
}
|
|
return {
|
|
activeRevision: active[0].revisionName,
|
|
rollbackRevision: rollback.revisionName
|
|
}
|
|
}
|
|
|
|
function verifyActiveSelectorDirector(config, deps, cellId) {
|
|
const common = [
|
|
'--project',
|
|
config.project,
|
|
'--region',
|
|
config.directorRegion,
|
|
'--format=json'
|
|
]
|
|
const service = deps.commandJson([
|
|
'run',
|
|
'services',
|
|
'describe',
|
|
config.directorService,
|
|
...common
|
|
])
|
|
const active = (service.status?.traffic ?? []).filter(
|
|
(entry) => Number(entry.percent ?? 0) > 0
|
|
)
|
|
if (
|
|
active.length !== 1 ||
|
|
Number(active[0].percent) !== 100 ||
|
|
!active[0].revisionName
|
|
) {
|
|
throw new Error('director lacks one active selector revision')
|
|
}
|
|
const revision = deps.commandJson([
|
|
'run',
|
|
'revisions',
|
|
'describe',
|
|
active[0].revisionName,
|
|
...common
|
|
])
|
|
const environment = revisionEnvironment(revision)
|
|
const inventory = JSON.parse(directorInventory(environment, active[0].revisionName))
|
|
if (
|
|
environment.ORCA_RELAY_ROLE !== 'director' ||
|
|
environment.ORCA_RELAY_ADMISSION_SELECTOR_VERSION !== SELECTOR_REVISION_MARKER ||
|
|
!(revisionMinimum(revision) >= config.directorMinimumInstances) ||
|
|
!inventory.some((cell) => cell.id === cellId)
|
|
) {
|
|
throw new Error('active director is not compatible with the promoted cell')
|
|
}
|
|
return { activeRevision: active[0].revisionName }
|
|
}
|
|
|
|
export function pruneIncompatibleDirectorRevisions(config, deps) {
|
|
const common = [
|
|
'--project',
|
|
config.project,
|
|
'--region',
|
|
config.directorRegion,
|
|
'--format=json'
|
|
]
|
|
const service = deps.commandJson([
|
|
'run',
|
|
'services',
|
|
'describe',
|
|
config.directorService,
|
|
...common
|
|
])
|
|
const traffic = service.status?.traffic ?? []
|
|
const active = traffic.filter((entry) => Number(entry.percent ?? 0) > 0)
|
|
const rollback = traffic.find((entry) => entry.tag === SELECTOR_ROLLBACK_TAG)
|
|
const unexpectedTags = traffic.filter(
|
|
(entry) => entry.tag && entry.tag !== SELECTOR_ROLLBACK_TAG
|
|
)
|
|
if (
|
|
active.length !== 1 ||
|
|
Number(active[0].percent) !== 100 ||
|
|
!active[0].revisionName ||
|
|
!rollback?.revisionName ||
|
|
Number(rollback.percent ?? 0) !== 0 ||
|
|
rollback.revisionName === active[0].revisionName ||
|
|
unexpectedTags.length > 0
|
|
) {
|
|
throw new Error('director traffic is not ready for selector cutover')
|
|
}
|
|
const activeRevision = deps.commandJson([
|
|
'run',
|
|
'revisions',
|
|
'describe',
|
|
active[0].revisionName,
|
|
...common
|
|
])
|
|
const rollbackRevision = deps.commandJson([
|
|
'run',
|
|
'revisions',
|
|
'describe',
|
|
rollback.revisionName,
|
|
...common
|
|
])
|
|
const activeEnvironment = revisionEnvironment(activeRevision)
|
|
const rollbackEnvironment = revisionEnvironment(rollbackRevision)
|
|
const activeInventory = directorInventory(activeEnvironment, active[0].revisionName)
|
|
const rollbackInventory = directorInventory(rollbackEnvironment, rollback.revisionName)
|
|
if (
|
|
activeEnvironment.ORCA_RELAY_ROLE !== 'director' ||
|
|
rollbackEnvironment.ORCA_RELAY_ROLE !== 'director' ||
|
|
activeEnvironment.ORCA_RELAY_ADMISSION_SELECTOR_VERSION !==
|
|
SELECTOR_REVISION_MARKER ||
|
|
rollbackEnvironment.ORCA_RELAY_ADMISSION_SELECTOR_VERSION !==
|
|
SELECTOR_REVISION_MARKER ||
|
|
!activeRevision.spec?.containers?.[0]?.image ||
|
|
activeRevision.spec?.containers?.[0]?.image !==
|
|
rollbackRevision.spec?.containers?.[0]?.image ||
|
|
activeInventory !== rollbackInventory ||
|
|
revisionMinimum(rollbackRevision) !== 0 ||
|
|
!(revisionMinimum(activeRevision) >= config.directorMinimumInstances)
|
|
) {
|
|
throw new Error('director compatibility pair failed before revision pruning')
|
|
}
|
|
const retained = new Set([active[0].revisionName, rollback.revisionName])
|
|
const revisions = deps.commandJson([
|
|
'run',
|
|
'revisions',
|
|
'list',
|
|
'--service',
|
|
config.directorService,
|
|
...common
|
|
])
|
|
for (const revision of revisions) {
|
|
const revisionName = revision.metadata?.name
|
|
if (!revisionName) throw new Error('director revision list contains an unnamed revision')
|
|
if (retained.has(revisionName)) continue
|
|
deps.command([
|
|
'run',
|
|
'revisions',
|
|
'delete',
|
|
revisionName,
|
|
'--project',
|
|
config.project,
|
|
'--region',
|
|
config.directorRegion,
|
|
'--quiet'
|
|
])
|
|
}
|
|
}
|
|
|
|
export function cutoverMembership(topology, config) {
|
|
const all = Object.keys(topology).sort()
|
|
const migration = new Set(config.targetCellIds)
|
|
const general = new Set(config.generalCellIds)
|
|
if ([...migration].some((cellId) => general.has(cellId))) {
|
|
throw new Error('general and migration-only cell sets overlap')
|
|
}
|
|
if (migration.has(config.sourceCellId) || general.has(config.sourceCellId)) {
|
|
throw new Error('cutover source must remain existing-only')
|
|
}
|
|
for (const cellId of [...migration, ...general]) {
|
|
if (!all.includes(cellId)) throw new Error(`selector cell ${cellId} is absent from topology`)
|
|
}
|
|
return {
|
|
existingOnly: all.filter((cellId) => !migration.has(cellId) && !general.has(cellId)),
|
|
migrationOnly: [...migration].sort(),
|
|
general: [...general].sort()
|
|
}
|
|
}
|
|
|
|
function assertDistinctCutoverResources(cells) {
|
|
const resources = new Map()
|
|
for (const cell of cells) {
|
|
for (const key of ['origin', 'migName', 'instanceGroup', 'backendName', 'backendId']) {
|
|
const resourceKey = `${key}:${cell[key]}`
|
|
const previous = resources.get(resourceKey)
|
|
if (previous) throw new Error(`${cell.cellId} ${key} overlaps ${previous}`)
|
|
resources.set(resourceKey, cell.cellId)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function assertCutoverCellReady(cellId, status, unobservedConnectionBound) {
|
|
const capacity = status.runtimeConnectionCapacity
|
|
const directorCapacity = status.connectionCapacity
|
|
const process = status.process
|
|
const expectedPause =
|
|
CUTOVER_CONNECTION_HARD_CAP -
|
|
CUTOVER_CONTROL_REBIND_RESERVE -
|
|
unobservedConnectionBound
|
|
if (
|
|
!capacity ||
|
|
capacity.hardCap !== CUTOVER_CONNECTION_HARD_CAP ||
|
|
capacity.controlRebindReserve !== CUTOVER_CONTROL_REBIND_RESERVE ||
|
|
capacity.ordinaryConnectionLimit !==
|
|
CUTOVER_CONNECTION_HARD_CAP - CUTOVER_CONTROL_REBIND_RESERVE ||
|
|
capacity.unobservedBound !== unobservedConnectionBound ||
|
|
capacity.normalAdmissionPause !== expectedPause ||
|
|
!directorCapacity ||
|
|
directorCapacity.hardCap !== capacity.hardCap ||
|
|
directorCapacity.controlRebindReserve !== capacity.controlRebindReserve ||
|
|
directorCapacity.ordinaryConnectionLimit !== capacity.ordinaryConnectionLimit ||
|
|
directorCapacity.unobservedBound !== capacity.unobservedBound ||
|
|
directorCapacity.normalAdmissionPause !== capacity.normalAdmissionPause ||
|
|
directorCapacity.heartbeatFresh !== true ||
|
|
expectedPause <= 0
|
|
) {
|
|
throw new Error(`${cellId} does not expose the reviewed connection-capacity policy`)
|
|
}
|
|
if (
|
|
!process ||
|
|
!Number.isSafeInteger(process.enforcedConnectionUnits) ||
|
|
process.enforcedConnectionUnits < 0 ||
|
|
!Number.isSafeInteger(process.preAuthConnections) ||
|
|
process.preAuthConnections < 0 ||
|
|
!Number.isSafeInteger(directorCapacity.pendingControlReservations) ||
|
|
directorCapacity.pendingControlReservations < 0
|
|
) {
|
|
throw new Error(`${cellId} has incomplete connection-capacity evidence`)
|
|
}
|
|
if (process.preAuthConnections >= MAX_PRE_AUTH_CONNECTIONS) {
|
|
throw new Error(`${cellId} has insufficient pre-auth connection headroom`)
|
|
}
|
|
const committedUnits =
|
|
process.enforcedConnectionUnits + directorCapacity.pendingControlReservations
|
|
if (!Number.isSafeInteger(committedUnits) || committedUnits >= expectedPause) {
|
|
throw new Error(`${cellId} has insufficient normal-admission connection headroom`)
|
|
}
|
|
if (status.draining) throw new Error(`${cellId} is draining before selector cutover`)
|
|
}
|
|
|
|
async function inspectCutoverCells(topology, config, deps, adminPost, membership) {
|
|
const selectedIds = [...membership.migrationOnly, ...membership.general]
|
|
const selected = selectedIds.map((cellId) => deployment(topology[cellId], cellId))
|
|
assertDistinctCutoverResources([
|
|
deployment(topology[config.sourceCellId], config.sourceCellId),
|
|
...selected
|
|
])
|
|
for (const cell of selected) {
|
|
const status = await inspectCell(config, deps, adminPost, cell)
|
|
assertCutoverCellReady(cell.cellId, status, config.unobservedConnectionBound)
|
|
}
|
|
}
|
|
|
|
function projection(total, quota, assignments) {
|
|
return assignments === 0 ? 0 : Math.ceil((total * quota) / assignments)
|
|
}
|
|
|
|
function connectionProjection(current, sourceConnections, sourceAssignments, quota) {
|
|
const unboundConnections = Math.max(0, sourceConnections - sourceAssignments)
|
|
return current + quota + unboundConnections
|
|
}
|
|
|
|
function targetConnectionCeiling(config, target) {
|
|
return Math.min(
|
|
config.connectionCeiling,
|
|
target.connectionHardCap ?? CUTOVER_CONNECTION_HARD_CAP
|
|
)
|
|
}
|
|
|
|
function connectionReservationHeadroom(status, cellId) {
|
|
const capacity = status.connectionCapacity
|
|
const values = [
|
|
capacity?.hardCap,
|
|
capacity?.controlRebindReserve,
|
|
capacity?.ordinaryConnectionLimit,
|
|
capacity?.unobservedBound,
|
|
capacity?.normalAdmissionPause,
|
|
capacity?.enforcedConnectionUnits,
|
|
capacity?.pendingControlReservations
|
|
]
|
|
if (
|
|
capacity?.heartbeatFresh !== true ||
|
|
values.some((value) => !Number.isSafeInteger(value) || value < 0) ||
|
|
capacity.ordinaryConnectionLimit !== capacity.hardCap - capacity.controlRebindReserve ||
|
|
capacity.normalAdmissionPause !==
|
|
capacity.ordinaryConnectionLimit - capacity.unobservedBound
|
|
) {
|
|
throw new Error(`${cellId} has inconsistent connection-reservation capacity`)
|
|
}
|
|
return Math.max(
|
|
0,
|
|
capacity.normalAdmissionPause -
|
|
capacity.enforcedConnectionUnits -
|
|
capacity.pendingControlReservations
|
|
)
|
|
}
|
|
|
|
export function allocateTargetQuotas({
|
|
sourceAssignments,
|
|
sourceConnections,
|
|
requiredTargetUnits,
|
|
targets,
|
|
connectionCeiling
|
|
}) {
|
|
const quotas = new Map(targets.map((target) => [target.cellId, 0]))
|
|
for (let assigned = 0; assigned < sourceAssignments; assigned++) {
|
|
const candidates = targets
|
|
.map((target) => {
|
|
const quota = quotas.get(target.cellId) + 1
|
|
const projectedConnections = connectionProjection(
|
|
target.currentConnections,
|
|
sourceConnections,
|
|
sourceAssignments,
|
|
quota
|
|
)
|
|
const projectedUnits = projection(requiredTargetUnits, quota, sourceAssignments)
|
|
return { target, quota, projectedConnections, projectedUnits }
|
|
})
|
|
.filter(
|
|
({ target, quota, projectedConnections, projectedUnits }) =>
|
|
projectedConnections < (target.connectionCeiling ?? connectionCeiling) &&
|
|
quota <= target.availableConnectionReservations &&
|
|
projectedUnits <= target.availableTargetUnits
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
left.projectedConnections - right.projectedConnections ||
|
|
left.target.cellId.localeCompare(right.target.cellId)
|
|
)
|
|
const selected = candidates[0]
|
|
if (!selected) throw new Error('multi-target connection or request-unit headroom exhausted')
|
|
quotas.set(selected.target.cellId, selected.quota)
|
|
}
|
|
return targets.map((target) => ({
|
|
...target,
|
|
quota: quotas.get(target.cellId),
|
|
projectedConnections: connectionProjection(
|
|
target.currentConnections,
|
|
sourceConnections,
|
|
sourceAssignments,
|
|
quotas.get(target.cellId)
|
|
),
|
|
projectedUnits: projection(
|
|
requiredTargetUnits,
|
|
quotas.get(target.cellId),
|
|
sourceAssignments
|
|
)
|
|
}))
|
|
}
|
|
|
|
function defaultCommand(args) {
|
|
const result = spawnSync('gcloud', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
if (result.status !== 0) {
|
|
throw new Error(`gcloud ${args.slice(0, 5).join(' ')} failed: ${result.stderr.trim()}`)
|
|
}
|
|
}
|
|
|
|
function defaultCommandResult(args) {
|
|
const result = spawnSync('gcloud', args, {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
return { status: result.status, stdout: result.stdout, stderr: result.stderr }
|
|
}
|
|
|
|
function validatedProcessCounts(counts, cellId) {
|
|
for (const field of [
|
|
'totalConnections',
|
|
'preAuthConnections',
|
|
'controls',
|
|
'splices',
|
|
'pendingSplices',
|
|
'queuedBytes'
|
|
]) {
|
|
if (!Number.isSafeInteger(counts?.[field]) || counts[field] < 0) {
|
|
throw new Error(`${cellId} runtime metrics have invalid ${field}`)
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
function processCounts(config, deps, status, cellId) {
|
|
if (status.process) return validatedProcessCounts(status.process, cellId)
|
|
const filter = [
|
|
'resource.type="gce_instance"',
|
|
'jsonPayload.event="orca_relay_runtime_metrics"',
|
|
`jsonPayload.cellId="${cellId}"`
|
|
].join(' AND ')
|
|
const entries = deps.commandJson([
|
|
'logging',
|
|
'read',
|
|
filter,
|
|
'--project',
|
|
config.project,
|
|
'--freshness=5m',
|
|
'--limit=1',
|
|
'--order=desc',
|
|
'--format=json'
|
|
])
|
|
const entry = entries[0]
|
|
if (!entry?.jsonPayload) throw new Error(`${cellId} has no fresh runtime metrics`)
|
|
const timestamp = Date.parse(entry.timestamp)
|
|
if (!Number.isFinite(timestamp) || timestamp < deps.now() - 90_000) {
|
|
throw new Error(`${cellId} runtime metrics are stale`)
|
|
}
|
|
return validatedProcessCounts(entry.jsonPayload, cellId)
|
|
}
|
|
|
|
function runtimeConnections(counts, cellId) {
|
|
const count = counts?.totalConnections
|
|
if (!Number.isSafeInteger(count) || count < 0) {
|
|
throw new Error(`${cellId} runtime does not expose totalConnections`)
|
|
}
|
|
return count
|
|
}
|
|
|
|
function runtimeIncarnation(status, cellId) {
|
|
const incarnation = status.runtime?.cellIncarnation
|
|
if (typeof incarnation !== 'string' || incarnation.length === 0) {
|
|
throw new Error(`${cellId} has no exact runtime incarnation`)
|
|
}
|
|
return incarnation
|
|
}
|
|
|
|
async function pairStatus(config, adminPost, targetCellId, completeReady) {
|
|
return await adminPost(config.directorOrigin, '/v1/admin/evacuation-status', {
|
|
v: 1,
|
|
sourceCellId: config.sourceCellId,
|
|
targetCellId,
|
|
completeReady
|
|
})
|
|
}
|
|
|
|
async function allPairStatuses(config, adminPost, completeReady) {
|
|
const statuses = []
|
|
for (const targetCellId of config.targetCellIds) {
|
|
statuses.push({
|
|
targetCellId,
|
|
status: await pairStatus(config, adminPost, targetCellId, completeReady)
|
|
})
|
|
}
|
|
return statuses
|
|
}
|
|
|
|
function statusTotals(statuses) {
|
|
return statuses.reduce(
|
|
(totals, { status }) => ({
|
|
inProgress: totals.inProgress + status.inProgress,
|
|
targetRegistered: totals.targetRegistered + status.targetRegistered,
|
|
registeredSourceActive: totals.registeredSourceActive + status.registeredSourceActive,
|
|
registeredCompletable: totals.registeredCompletable + status.registeredCompletable,
|
|
registeredTargetInactive:
|
|
totals.registeredTargetInactive + status.registeredTargetInactive,
|
|
completed: totals.completed + status.completed,
|
|
blocked: totals.blocked + status.blocked,
|
|
expiredUnregistered: totals.expiredUnregistered + status.expiredUnregistered,
|
|
repairableExpiredUnregistered:
|
|
totals.repairableExpiredUnregistered + status.repairableExpiredUnregistered,
|
|
abortableExpiredUnregistered:
|
|
totals.abortableExpiredUnregistered + status.abortableExpiredUnregistered,
|
|
blockedExpiredUnregistered:
|
|
totals.blockedExpiredUnregistered + status.blockedExpiredUnregistered,
|
|
blockedExpiredOnNewerTargetAssignment:
|
|
totals.blockedExpiredOnNewerTargetAssignment +
|
|
status.blockedExpiredOnNewerTargetAssignment
|
|
}),
|
|
{
|
|
inProgress: 0,
|
|
targetRegistered: 0,
|
|
registeredSourceActive: 0,
|
|
registeredCompletable: 0,
|
|
registeredTargetInactive: 0,
|
|
completed: 0,
|
|
blocked: 0,
|
|
expiredUnregistered: 0,
|
|
repairableExpiredUnregistered: 0,
|
|
abortableExpiredUnregistered: 0,
|
|
blockedExpiredUnregistered: 0,
|
|
blockedExpiredOnNewerTargetAssignment: 0
|
|
}
|
|
)
|
|
}
|
|
|
|
function hasDurableTargetOwnership(totals) {
|
|
return (
|
|
totals.inProgress === totals.targetRegistered &&
|
|
totals.targetRegistered ===
|
|
totals.registeredSourceActive +
|
|
totals.registeredCompletable +
|
|
totals.registeredTargetInactive &&
|
|
totals.registeredSourceActive === 0 &&
|
|
totals.expiredUnregistered === 0 &&
|
|
totals.repairableExpiredUnregistered === 0 &&
|
|
totals.abortableExpiredUnregistered === 0 &&
|
|
totals.blockedExpiredUnregistered === 0 &&
|
|
totals.blockedExpiredOnNewerTargetAssignment === 0
|
|
)
|
|
}
|
|
|
|
function boundedUnregisteredMigrations(totals, unobservedConnectionBound) {
|
|
const unregistered = totals.inProgress - totals.targetRegistered
|
|
return (
|
|
Number.isSafeInteger(unobservedConnectionBound) &&
|
|
unregistered > 0 &&
|
|
unregistered <= unobservedConnectionBound &&
|
|
totals.targetRegistered ===
|
|
totals.registeredSourceActive +
|
|
totals.registeredCompletable +
|
|
totals.registeredTargetInactive &&
|
|
totals.registeredSourceActive === 0 &&
|
|
totals.blocked === 0 &&
|
|
totals.expiredUnregistered === 0 &&
|
|
totals.repairableExpiredUnregistered === 0 &&
|
|
totals.abortableExpiredUnregistered === 0 &&
|
|
totals.blockedExpiredUnregistered === 0 &&
|
|
totals.blockedExpiredOnNewerTargetAssignment === 0
|
|
)
|
|
}
|
|
|
|
function isSettledOrOffline(totals) {
|
|
return (
|
|
hasDurableTargetOwnership(totals) &&
|
|
totals.registeredCompletable === 0 &&
|
|
totals.blocked === totals.registeredTargetInactive
|
|
)
|
|
}
|
|
|
|
function assertLeaseGate(statuses, minimumLeaseRemainingMs) {
|
|
const remaining = statuses
|
|
.map(({ status }) => status.oldestRemainingMs)
|
|
.filter((value) => value !== null)
|
|
if (remaining.length === 0 || Math.min(...remaining) < minimumLeaseRemainingMs) {
|
|
throw new Error('oldest migration lease has insufficient time remaining')
|
|
}
|
|
}
|
|
|
|
async function targetRuntime(config, adminPost, target) {
|
|
if (config.mode !== 'fence-source') {
|
|
const runtime = await adminPost(target.origin, '/v1/admin/runtime-status', { v: 1 })
|
|
const count = runtime.runtime?.totalConnections
|
|
if (!Number.isSafeInteger(count) || count < 0) {
|
|
throw new Error(`${target.cellId} runtime does not expose totalConnections`)
|
|
}
|
|
if (count >= targetConnectionCeiling(config, target)) {
|
|
throw new Error(`${target.cellId} reached the connection ceiling`)
|
|
}
|
|
return count
|
|
}
|
|
const result = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: target.cellId
|
|
})
|
|
const status = result.status
|
|
if (
|
|
status?.cellId !== target.cellId ||
|
|
status.cellUrl !== target.origin ||
|
|
status.runtime?.cellUrl !== target.origin ||
|
|
status.runtime?.ready !== true ||
|
|
status.runtime?.heartbeatFresh !== true
|
|
) {
|
|
throw new Error(`${target.cellId} has no fresh matching director runtime snapshot`)
|
|
}
|
|
const values = [
|
|
status.runtime.observedRequests,
|
|
status.connectionCapacity?.observedConnections,
|
|
status.connectionCapacity?.enforcedConnectionUnits
|
|
]
|
|
if (values.some((value) => !Number.isSafeInteger(value) || value < 0)) {
|
|
throw new Error(`${target.cellId} has incomplete director runtime counts`)
|
|
}
|
|
const count = Math.max(...values)
|
|
connectionReservationHeadroom(status, target.cellId)
|
|
if (count >= Math.min(config.connectionCeiling, status.connectionCapacity.hardCap)) {
|
|
throw new Error(`${target.cellId} reached the connection ceiling`)
|
|
}
|
|
return count
|
|
}
|
|
|
|
async function checkPublicCellEndpoint(deps, cell, path) {
|
|
const response = await deps.fetch(`${cell.origin}${path}`, {
|
|
signal: AbortSignal.timeout(15_000)
|
|
})
|
|
const body = await response.json().catch(() => ({}))
|
|
if (!response.ok || body.ok !== true) {
|
|
throw new Error(`${cell.cellId} ${path} is unavailable`)
|
|
}
|
|
}
|
|
|
|
async function inspectGeneralPromotionTarget(config, deps, adminPost, target) {
|
|
await checkPublicCellEndpoint(deps, target, '/health')
|
|
await checkPublicCellEndpoint(deps, target, '/ready')
|
|
const result = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: target.cellId
|
|
})
|
|
const status = result.status
|
|
if (
|
|
status?.cellId !== target.cellId ||
|
|
status.cellUrl !== target.origin ||
|
|
status.runtime?.cellUrl !== target.origin ||
|
|
status.runtime?.ready !== true ||
|
|
status.runtime?.heartbeatFresh !== true
|
|
) {
|
|
throw new Error(`${target.cellId} has no fresh matching director runtime snapshot`)
|
|
}
|
|
connectionReservationHeadroom(status, target.cellId)
|
|
const currentConnections = Math.max(
|
|
status.runtime.observedRequests,
|
|
status.connectionCapacity.observedConnections,
|
|
status.connectionCapacity.enforcedConnectionUnits
|
|
)
|
|
if (
|
|
!Number.isSafeInteger(currentConnections) ||
|
|
currentConnections >= targetConnectionCeiling(config, target)
|
|
) {
|
|
throw new Error(`${target.cellId} reached the connection ceiling`)
|
|
}
|
|
}
|
|
|
|
function validateReviewedInstanceTemplate(template, expected, capacityPredecessor) {
|
|
const startupScript = (template.properties?.metadata?.items ?? [])
|
|
.find((item) => item.key === 'startup-script')?.value
|
|
const configuredDigest = startupScript?.match(
|
|
/ORCA_RELAY_IMAGE_DIGEST=%s\\n' '(sha256:[a-f0-9]{64})'/
|
|
)?.[1]
|
|
const configuredImages = [
|
|
...String(startupScript ?? '').matchAll(
|
|
/'(?:[a-z0-9.-]+\/)+[a-z0-9._/-]+@(sha256:[a-f0-9]{64})'/g
|
|
)
|
|
].map((match) => match[1])
|
|
if (
|
|
template.selfLink !== expected.generationIdentity ||
|
|
configuredDigest !== expected.imageDigest ||
|
|
!configuredImages.includes(expected.imageDigest)
|
|
) {
|
|
throw new Error(`${expected.cellId} instance template does not pin the reviewed image`)
|
|
}
|
|
const hardCaps = [
|
|
...String(startupScript ?? '').matchAll(
|
|
/^ printf 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP=%s\\n' '([0-9]+)'$/gm
|
|
)
|
|
].map((match) => Number(match[1]))
|
|
const unobservedBounds = [
|
|
...String(startupScript ?? '').matchAll(
|
|
/^ printf 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND=%s\\n' '([0-9]+)'$/gm
|
|
)
|
|
].map((match) => Number(match[1]))
|
|
const hardCap = hardCaps[0]
|
|
const unobservedBound = unobservedBounds[0]
|
|
const exactCapacityPredecessor =
|
|
capacityPredecessor !== undefined &&
|
|
hardCaps.length === 1 &&
|
|
unobservedBounds.length === 1 &&
|
|
hardCap === capacityPredecessor.hardCap &&
|
|
unobservedBound === capacityPredecessor.unobservedBound
|
|
if (expected.connectionHardCap === undefined) {
|
|
if (!exactCapacityPredecessor && (hardCaps.length !== 0 || unobservedBounds.length !== 0)) {
|
|
throw new Error(`${expected.cellId} instance template capacity differs from Terraform`)
|
|
}
|
|
return exactCapacityPredecessor
|
|
? {
|
|
...expected,
|
|
connectionHardCap: hardCap,
|
|
connectionUnobservedBound: unobservedBound
|
|
}
|
|
: expected
|
|
}
|
|
const isReviewedPredecessor =
|
|
exactCapacityPredecessor && expected.connectionHardCap === 1_000
|
|
if (
|
|
hardCaps.length !== 1 ||
|
|
unobservedBounds.length !== 1 ||
|
|
!Number.isSafeInteger(hardCap) ||
|
|
!Number.isSafeInteger(unobservedBound) ||
|
|
(hardCap !== expected.connectionHardCap && !isReviewedPredecessor) ||
|
|
unobservedBound !== expected.connectionUnobservedBound
|
|
) {
|
|
throw new Error(`${expected.cellId} instance template capacity is outside reviewed rollout`)
|
|
}
|
|
return {
|
|
...expected,
|
|
connectionHardCap: hardCap,
|
|
connectionUnobservedBound: unobservedBound
|
|
}
|
|
}
|
|
|
|
async function inspectDirectorObservedCell(config, deps, adminPost, expected) {
|
|
const common = ['--project', config.project, '--zone', expected.zone, '--format=json']
|
|
const mig = deps.commandJson([
|
|
'compute',
|
|
'instance-groups',
|
|
'managed',
|
|
'describe',
|
|
expected.migName,
|
|
...common
|
|
])
|
|
const instances = deps.commandJson([
|
|
'compute',
|
|
'instance-groups',
|
|
'managed',
|
|
'list-instances',
|
|
expected.migName,
|
|
...common
|
|
])
|
|
const instanceName = validateMig(mig, instances, expected)
|
|
if (
|
|
mig.instanceTemplate !== expected.generationIdentity ||
|
|
instances[0]?.version?.instanceTemplate !== expected.generationIdentity
|
|
) {
|
|
throw new Error(`${expected.cellId} MIG does not serve the reviewed generation`)
|
|
}
|
|
const instance = deps.commandJson([
|
|
'compute',
|
|
'instances',
|
|
'describe',
|
|
instanceName,
|
|
...common
|
|
])
|
|
validateInstance(instance, expected, config.runtimeServiceAccount)
|
|
const templateName = new URL(expected.generationIdentity).pathname.split('/').at(-1)
|
|
const template = deps.commandJson([
|
|
'compute',
|
|
'instance-templates',
|
|
'describe',
|
|
templateName,
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
const deployed = validateReviewedInstanceTemplate(
|
|
template,
|
|
expected,
|
|
config.mode === 'fence-source' &&
|
|
(expected.connectionHardCap !== undefined || expected.legacyCapacityTopology)
|
|
? {
|
|
hardCap: config.connectionCeiling,
|
|
unobservedBound: config.unobservedConnectionBound
|
|
}
|
|
: undefined
|
|
)
|
|
const backend = deps.commandJson([
|
|
'compute',
|
|
'backend-services',
|
|
'describe',
|
|
expected.backendName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
validateBackend(backend, expected)
|
|
await assertCellRoute(config, deps, expected)
|
|
await checkPublicCellEndpoint(deps, expected, '/health')
|
|
await checkPublicCellEndpoint(deps, expected, '/ready')
|
|
const result = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: expected.cellId
|
|
})
|
|
const status = result.status
|
|
if (
|
|
status?.cellId !== expected.cellId ||
|
|
status.cellUrl !== expected.origin ||
|
|
status.runtime?.cellUrl !== expected.origin ||
|
|
status.runtime?.ready !== true ||
|
|
status.runtime?.heartbeatFresh !== true
|
|
) {
|
|
throw new Error(`${expected.cellId} has no fresh matching director runtime snapshot`)
|
|
}
|
|
connectionReservationHeadroom(status, expected.cellId)
|
|
assertDeploymentConnectionCapacity(
|
|
deployed,
|
|
status.connectionCapacity ?? null,
|
|
status.connectionCapacity ?? null
|
|
)
|
|
const connectionValues = [
|
|
status.runtime.observedRequests,
|
|
status.connectionCapacity.observedConnections,
|
|
status.connectionCapacity.enforcedConnectionUnits
|
|
]
|
|
if (connectionValues.some((value) => !Number.isSafeInteger(value) || value < 0)) {
|
|
throw new Error(`${expected.cellId} has incomplete director runtime counts`)
|
|
}
|
|
const currentConnections = Math.max(...connectionValues)
|
|
if (currentConnections >= targetConnectionCeiling(config, deployed)) {
|
|
throw new Error(`${expected.cellId} reached the connection ceiling`)
|
|
}
|
|
return {
|
|
...status,
|
|
process: { totalConnections: currentConnections }
|
|
}
|
|
}
|
|
|
|
async function waitForMultiStatus(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
targets,
|
|
completeReady,
|
|
allowBoundedUnregistered = false,
|
|
requireZero = false
|
|
) {
|
|
const deadline = deps.now() + config.timeoutMs
|
|
while (deps.now() < deadline) {
|
|
const statuses = await allPairStatuses(config, adminPost, completeReady)
|
|
for (const target of targets) await targetRuntime(config, adminPost, target)
|
|
const totals = statusTotals(statuses)
|
|
deps.emit({
|
|
event: completeReady ? 'multi_migration_completion' : 'multi_migration_registration',
|
|
...totals
|
|
})
|
|
const boundedUnregistered =
|
|
allowBoundedUnregistered &&
|
|
boundedUnregisteredMigrations(totals, config.unobservedConnectionBound)
|
|
const boundedSettlement = boundedUnregistered && totals.registeredCompletable === 0
|
|
if (
|
|
completeReady
|
|
? (requireZero
|
|
? totals.inProgress === 0
|
|
: isSettledOrOffline(totals) || boundedSettlement)
|
|
: totals.inProgress === totals.targetRegistered || boundedUnregistered
|
|
) {
|
|
if (boundedUnregistered) {
|
|
deps.emit({
|
|
event: completeReady
|
|
? 'multi_migration_bounded_offline_complete'
|
|
: 'multi_migration_bounded_offline_registered',
|
|
unobservedConnectionBound: config.unobservedConnectionBound,
|
|
unregistered: totals.inProgress - totals.targetRegistered,
|
|
...totals
|
|
})
|
|
}
|
|
return statuses
|
|
}
|
|
await deps.wait(config.pollIntervalMs)
|
|
}
|
|
throw new Error(
|
|
completeReady
|
|
? 'timed out waiting for multi-target completion'
|
|
: 'timed out waiting for multi-target registration'
|
|
)
|
|
}
|
|
|
|
async function waitForRecoveredSourceZero(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
expectedIncarnation
|
|
) {
|
|
const deadline = deps.now() + config.timeoutMs
|
|
while (deps.now() < deadline) {
|
|
const status = await inspectCell(config, deps, adminPost, source)
|
|
const counts = processCounts(config, deps, status, source.cellId)
|
|
deps.emit({
|
|
event: 'source_recovery_runtime_settlement',
|
|
draining: status.draining,
|
|
activityLeases: status.activityLeases,
|
|
reservedRequests: status.reservedRequests,
|
|
controls: counts.controls,
|
|
splices: counts.splices,
|
|
pendingSplices: counts.pendingSplices
|
|
})
|
|
if (
|
|
runtimeIncarnation(status, source.cellId) !== expectedIncarnation
|
|
) {
|
|
throw new Error('source incarnation changed during recovery settlement')
|
|
}
|
|
if (
|
|
status.draining &&
|
|
status.activityLeases === 0 &&
|
|
status.reservedRequests === 0 &&
|
|
counts.controls === 0 &&
|
|
counts.splices === 0 &&
|
|
counts.pendingSplices === 0
|
|
) {
|
|
return
|
|
}
|
|
await deps.wait(config.pollIntervalMs)
|
|
}
|
|
throw new Error('timed out waiting for recovered source runtime to reach zero')
|
|
}
|
|
|
|
async function rollbackBeforeDrain(config, deps, adminPost, targets, selectorActive) {
|
|
let statuses
|
|
try {
|
|
statuses = await allPairStatuses(config, adminPost, false)
|
|
} catch (error) {
|
|
deps.emit({
|
|
event: 'multi_forward_recovery_required',
|
|
targetRegistered: null,
|
|
reason: 'registration_status_unavailable'
|
|
})
|
|
throw new Error('cannot prove zero target registrations; preserving forward recovery', {
|
|
cause: error
|
|
})
|
|
}
|
|
const totals = statusTotals(statuses)
|
|
if (totals.targetRegistered > 0) {
|
|
deps.emit({ event: 'multi_forward_recovery_required', ...totals })
|
|
return
|
|
}
|
|
if (selectorActive) {
|
|
deps.emit({ event: 'multi_rollback_preserved_selector', ...totals })
|
|
return
|
|
}
|
|
await setCellState(config, adminPost, config.sourceCellId, true).catch(() => undefined)
|
|
for (const target of targets) {
|
|
await setCellState(config, adminPost, target.cellId, false).catch(() => undefined)
|
|
}
|
|
deps.emit({ event: 'multi_rollback_waiting_for_lease_expiry', ...totals })
|
|
}
|
|
|
|
async function publishMigrations(config, deps, adminPost, plannedTargets) {
|
|
for (const target of plannedTargets) {
|
|
let remaining = target.quota
|
|
while (remaining > 0) {
|
|
const limit = Math.min(config.batchSize, remaining)
|
|
let result
|
|
try {
|
|
result = await adminPost(config.directorOrigin, '/v1/admin/evacuate-cell', {
|
|
v: 1,
|
|
sourceCellId: config.sourceCellId,
|
|
targetCellId: target.cellId,
|
|
limit
|
|
})
|
|
} catch (error) {
|
|
if (
|
|
config.mode !== 'recover-forward' ||
|
|
!(error instanceof Error) ||
|
|
error.message !==
|
|
'/v1/admin/evacuate-cell failed: relay_connection_headroom_exhausted'
|
|
) {
|
|
throw error
|
|
}
|
|
deps.emit({
|
|
event: 'multi_recovery_target_headroom_paused',
|
|
targetCellId: target.cellId,
|
|
remaining
|
|
})
|
|
break
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(result.started) ||
|
|
result.started < 0 ||
|
|
result.started > limit
|
|
) {
|
|
throw new Error(`${target.cellId} migration quota could not be filled deterministically`)
|
|
}
|
|
if (result.started === 0 && config.mode === 'recover-forward') {
|
|
deps.emit({
|
|
event: 'multi_recovery_quota_depleted',
|
|
targetCellId: target.cellId,
|
|
remaining
|
|
})
|
|
break
|
|
}
|
|
if (result.started === 0) {
|
|
throw new Error(`${target.cellId} migration quota could not be filled deterministically`)
|
|
}
|
|
remaining -= result.started
|
|
deps.emit({
|
|
event: 'multi_migration_batch',
|
|
targetCellId: target.cellId,
|
|
started: result.started,
|
|
remaining
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function sourceMig(config, deps, source) {
|
|
const common = ['--project', config.project, '--zone', source.zone, '--format=json']
|
|
return {
|
|
mig: deps.commandJson([
|
|
'compute',
|
|
'instance-groups',
|
|
'managed',
|
|
'describe',
|
|
source.migName,
|
|
...common
|
|
]),
|
|
instances: deps.commandJson([
|
|
'compute',
|
|
'instance-groups',
|
|
'managed',
|
|
'list-instances',
|
|
source.migName,
|
|
...common
|
|
])
|
|
}
|
|
}
|
|
|
|
function validateFencedMig(mig, source) {
|
|
const policy = mig.updatePolicy ?? {}
|
|
if (
|
|
Number(mig.targetSize) !== 0 ||
|
|
mig.instanceTemplate !== source.generationIdentity ||
|
|
policy.replacementMethod !== 'RECREATE' ||
|
|
Number(policy.maxSurge?.fixed ?? policy.maxSurge) !== 0 ||
|
|
Number(policy.maxUnavailable?.fixed ?? policy.maxUnavailable) !== 1
|
|
) {
|
|
throw new Error(`${source.cellId} MIG fence topology is unsafe`)
|
|
}
|
|
}
|
|
|
|
function canonicalBackendServiceId(value) {
|
|
if (typeof value !== 'string') return null
|
|
const prefix = 'https://www.googleapis.com/compute/v1/'
|
|
const resource = value.startsWith(prefix) ? value.slice(prefix.length) : value
|
|
return /^projects\/[a-z][a-z0-9-]{4,29}\/global\/backendServices\/[A-Za-z0-9_-]{1,63}$/.test(
|
|
resource
|
|
)
|
|
? resource
|
|
: null
|
|
}
|
|
|
|
export function sameBackendServiceResource(left, right) {
|
|
if (left === right) return true
|
|
const leftId = canonicalBackendServiceId(left)
|
|
return leftId !== null && leftId === canonicalBackendServiceId(right)
|
|
}
|
|
|
|
function validateRetainedRoute(urlMap, source) {
|
|
const hostname = new URL(source.origin).hostname
|
|
const hostRule = (urlMap.hostRules ?? []).find((rule) =>
|
|
(rule.hosts ?? []).includes(hostname)
|
|
)
|
|
const matcher = (urlMap.pathMatchers ?? []).find(
|
|
(candidate) => candidate.name === hostRule?.pathMatcher
|
|
)
|
|
if (
|
|
!source.urlMapName ||
|
|
urlMap.name !== source.urlMapName ||
|
|
!sameBackendServiceResource(matcher?.defaultService, source.backendId) ||
|
|
(matcher.pathRules?.length ?? 0) !== 0 ||
|
|
(matcher.routeRules?.length ?? 0) !== 0 ||
|
|
matcher.defaultRouteAction !== undefined ||
|
|
matcher.defaultUrlRedirect !== undefined ||
|
|
matcher.headerAction !== undefined
|
|
) {
|
|
throw new Error(`${source.cellId} retained route topology mismatch`)
|
|
}
|
|
}
|
|
|
|
async function assertCellRoute(config, deps, cell) {
|
|
const urlMap = deps.commandJson([
|
|
'compute',
|
|
'url-maps',
|
|
'describe',
|
|
cell.urlMapName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
validateRetainedRoute(urlMap, cell)
|
|
const proxy = deps.commandJson([
|
|
'compute',
|
|
'target-https-proxies',
|
|
'describe',
|
|
cell.urlMapName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
const forwardingRule = deps.commandJson([
|
|
'compute',
|
|
'forwarding-rules',
|
|
'describe',
|
|
cell.urlMapName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
const address = deps.commandJson([
|
|
'compute',
|
|
'addresses',
|
|
'describe',
|
|
cell.urlMapName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
const resolved = await deps.resolve4(new URL(cell.origin).hostname)
|
|
if (
|
|
proxy.name !== cell.urlMapName ||
|
|
proxy.urlMap !== urlMap.selfLink ||
|
|
forwardingRule.name !== cell.urlMapName ||
|
|
forwardingRule.target !== proxy.selfLink ||
|
|
forwardingRule.IPAddress !== address.address ||
|
|
forwardingRule.portRange !== '443-443' ||
|
|
forwardingRule.loadBalancingScheme !== 'EXTERNAL_MANAGED' ||
|
|
!Array.isArray(resolved) ||
|
|
resolved.length === 0 ||
|
|
resolved.some((value) => value !== address.address)
|
|
) {
|
|
throw new Error(`${cell.cellId} live frontend topology mismatch`)
|
|
}
|
|
}
|
|
|
|
async function inspectFencedSource(config, deps, adminPost, source, mig) {
|
|
validateFencedMig(mig, source)
|
|
const backend = deps.commandJson([
|
|
'compute',
|
|
'backend-services',
|
|
'describe',
|
|
source.backendName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
validateBackend(backend, source)
|
|
await assertCellRoute(config, deps, source)
|
|
const result = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: source.cellId
|
|
})
|
|
const status = result.status
|
|
if (
|
|
status?.cellUrl !== source.origin ||
|
|
(status.runtime !== null && status.runtime?.cellUrl !== source.origin)
|
|
) {
|
|
throw new Error(`${source.cellId} fenced runtime does not match Terraform topology`)
|
|
}
|
|
return { ...status, draining: true, process: null }
|
|
}
|
|
|
|
async function inspectFenceCandidate(config, deps, adminPost, cell, mig) {
|
|
const targetSize = Number(mig.targetSize)
|
|
const policy = mig.updatePolicy ?? {}
|
|
if (
|
|
![0, 1].includes(targetSize) ||
|
|
policy.replacementMethod !== 'RECREATE' ||
|
|
Number(policy.maxSurge?.fixed ?? policy.maxSurge) !== 0 ||
|
|
Number(policy.maxUnavailable?.fixed ?? policy.maxUnavailable) !== 1
|
|
) {
|
|
throw new Error(`${cell.cellId} MIG fence topology is unsafe`)
|
|
}
|
|
const backend = deps.commandJson([
|
|
'compute',
|
|
'backend-services',
|
|
'describe',
|
|
cell.backendName,
|
|
'--global',
|
|
'--project',
|
|
config.project,
|
|
'--format=json'
|
|
])
|
|
validateBackend(backend, cell)
|
|
const result = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: cell.cellId
|
|
})
|
|
if (
|
|
result.status?.cellUrl !== cell.origin ||
|
|
result.status.runtime?.cellUrl !== cell.origin
|
|
) {
|
|
throw new Error(`${cell.cellId} retained topology does not match Terraform`)
|
|
}
|
|
return result.status
|
|
}
|
|
|
|
const CAPACITY_SNAPSHOT_ATTEMPTS = 3
|
|
const CAPACITY_SNAPSHOT_RETRY_MS = 250
|
|
const RECOVERY_CATCH_UP_PASSES = 5
|
|
const RECOVERY_TARGET_OWNERSHIP_TIMEOUT_MS = 2 * 60 * 1_000
|
|
|
|
function conservativeRecoveryCapacity(rounds, targets, requireZeroProof) {
|
|
const capacities = rounds.flat()
|
|
for (const capacity of capacities) {
|
|
if (
|
|
!Number.isSafeInteger(capacity.sourceAssignments) ||
|
|
capacity.sourceAssignments < 0 ||
|
|
!Number.isSafeInteger(capacity.requiredTargetUnits) ||
|
|
capacity.requiredTargetUnits < capacity.sourceAssignments ||
|
|
!Number.isSafeInteger(capacity.availableTargetUnits) ||
|
|
capacity.availableTargetUnits < 0
|
|
) {
|
|
throw new Error('target capacity snapshot is internally inconsistent')
|
|
}
|
|
}
|
|
const observedSourceAssignments = capacities.map(
|
|
(capacity) => capacity.sourceAssignments
|
|
)
|
|
const sourceAssignments = requireZeroProof
|
|
? Math.max(...observedSourceAssignments)
|
|
: Math.min(...observedSourceAssignments)
|
|
const requiredTargetUnits =
|
|
sourceAssignments === 0
|
|
? 0
|
|
: requireZeroProof
|
|
? Math.max(...capacities.map((capacity) => capacity.requiredTargetUnits))
|
|
: Math.max(
|
|
...capacities.map((capacity) =>
|
|
Math.ceil(
|
|
sourceAssignments *
|
|
(capacity.requiredTargetUnits / capacity.sourceAssignments)
|
|
)
|
|
)
|
|
)
|
|
return {
|
|
sourceAssignments,
|
|
requiredTargetUnits,
|
|
availableTargetUnits: new Map(targets.map((target, index) => [
|
|
target.cellId,
|
|
Math.min(...rounds.map((round) => round[index].availableTargetUnits))
|
|
]))
|
|
}
|
|
}
|
|
|
|
async function readTargetCapacitySnapshot(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
requireRecoveryZeroProof = false
|
|
) {
|
|
for (let attempt = 0; attempt < CAPACITY_SNAPSHOT_ATTEMPTS; attempt++) {
|
|
const rounds = []
|
|
for (let round = 0; round < 2; round++) {
|
|
const capacities = []
|
|
for (const target of targets) {
|
|
capacities.push(await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/evacuation-capacity',
|
|
{ v: 1, sourceCellId: source.cellId, targetCellId: target.cellId }
|
|
))
|
|
}
|
|
rounds.push(capacities)
|
|
}
|
|
if (config.mode === 'recover-forward') {
|
|
return conservativeRecoveryCapacity(rounds, targets, requireRecoveryZeroProof)
|
|
}
|
|
const capacities = rounds.flat()
|
|
const baseline = capacities[0]
|
|
if (capacities.every((capacity) =>
|
|
capacity.sourceAssignments === baseline.sourceAssignments &&
|
|
capacity.requiredTargetUnits === baseline.requiredTargetUnits
|
|
)) {
|
|
return {
|
|
sourceAssignments: baseline.sourceAssignments,
|
|
requiredTargetUnits: baseline.requiredTargetUnits,
|
|
availableTargetUnits: new Map(targets.map((target, index) => [
|
|
target.cellId,
|
|
Math.min(...rounds.map((round) => round[index].availableTargetUnits))
|
|
]))
|
|
}
|
|
}
|
|
if (attempt + 1 < CAPACITY_SNAPSHOT_ATTEMPTS) {
|
|
await deps.wait(CAPACITY_SNAPSHOT_RETRY_MS)
|
|
}
|
|
}
|
|
throw new Error('target capacity snapshots disagree')
|
|
}
|
|
|
|
async function preflight(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
sourceFence = null,
|
|
selector = null,
|
|
coveredSourceConnections = null
|
|
) {
|
|
const sourceStatus = sourceFence
|
|
? await inspectFencedSource(config, deps, adminPost, source, sourceFence.mig)
|
|
: config.mode === 'fence-source'
|
|
? {
|
|
...await inspectDirectorObservedCell(config, deps, adminPost, source),
|
|
process: null
|
|
}
|
|
: await inspectCell(config, deps, adminPost, source)
|
|
const sourceProcess = sourceFence
|
|
? null
|
|
: processCounts(config, deps, sourceStatus, source.cellId)
|
|
const targetStatuses = []
|
|
for (const target of targets) {
|
|
const status = config.mode === 'fence-source'
|
|
? {
|
|
...await inspectDirectorObservedCell(config, deps, adminPost, target),
|
|
process: null
|
|
}
|
|
: await inspectCell(config, deps, adminPost, target)
|
|
const targetProcess = processCounts(config, deps, status, target.cellId)
|
|
const targetAdmission = selector
|
|
? selectorCellState(selector, target.cellId)
|
|
: status.enabled
|
|
? 'general'
|
|
: 'existing-only'
|
|
if (
|
|
['preflight', 'execute'].includes(config.mode) &&
|
|
(selector ? targetAdmission === 'general' : status.enabled)
|
|
) {
|
|
throw new Error(`${target.cellId} must not start in general admission`)
|
|
}
|
|
targetStatuses.push({
|
|
...target,
|
|
status,
|
|
process: targetProcess,
|
|
currentConnections: runtimeConnections(targetProcess, target.cellId),
|
|
availableConnectionReservations: connectionReservationHeadroom(
|
|
status,
|
|
target.cellId
|
|
),
|
|
connectionCeiling: Math.min(
|
|
config.connectionCeiling,
|
|
status.connectionCapacity.hardCap
|
|
)
|
|
})
|
|
}
|
|
if (config.mode === 'audit' || config.mode === 'recover-forward') {
|
|
const statuses = await allPairStatuses(config, adminPost, false)
|
|
deps.emit({
|
|
event: config.mode === 'audit' ? 'multi_target_audit' : 'multi_forward_recovery_preflight',
|
|
...statusTotals(statuses)
|
|
})
|
|
if (config.mode === 'audit') {
|
|
return {
|
|
sourceProcess,
|
|
sourceStatus,
|
|
plannedTargets: targetStatuses,
|
|
sourceAlreadyFenced: Boolean(sourceFence)
|
|
}
|
|
}
|
|
}
|
|
const capacity = await readTargetCapacitySnapshot(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets
|
|
)
|
|
const { sourceAssignments, requiredTargetUnits } = capacity
|
|
const observedSourceConnections = sourceFence
|
|
? 0
|
|
: runtimeConnections(sourceProcess, source.cellId)
|
|
const sourceConnections =
|
|
coveredSourceConnections === null
|
|
? observedSourceConnections
|
|
: sourceAssignments +
|
|
Math.max(0, observedSourceConnections - coveredSourceConnections)
|
|
for (const target of targetStatuses) {
|
|
target.availableTargetUnits = capacity.availableTargetUnits.get(target.cellId)
|
|
}
|
|
deps.emit({
|
|
event: 'multi_target_capacity_snapshot',
|
|
sourceConnections,
|
|
observedSourceConnections,
|
|
sourceAssignments,
|
|
requiredTargetUnits,
|
|
targets: targetStatuses.map((target) => ({
|
|
cellId: target.cellId,
|
|
currentConnections: target.currentConnections,
|
|
availableConnectionReservations: target.availableConnectionReservations,
|
|
availableTargetUnits: target.availableTargetUnits
|
|
}))
|
|
})
|
|
if (
|
|
config.mode === 'execute' &&
|
|
(selector
|
|
? selectorCellState(selector, source.cellId) !== 'existing-only'
|
|
: !sourceStatus.enabled)
|
|
) {
|
|
throw new Error(selector ? 'source cell is not existing-only' : 'source cell is not enabled')
|
|
}
|
|
const plannedTargets = allocateTargetQuotas({
|
|
sourceAssignments,
|
|
sourceConnections,
|
|
requiredTargetUnits,
|
|
targets: targetStatuses,
|
|
connectionCeiling: config.connectionCeiling
|
|
})
|
|
deps.emit({
|
|
event: 'multi_target_preflight',
|
|
source: aggregateCellStatus(sourceStatus),
|
|
sourceConnections,
|
|
observedSourceConnections,
|
|
sourceAssignments,
|
|
targets: plannedTargets.map((target) => ({
|
|
cellId: target.cellId,
|
|
quota: target.quota,
|
|
currentConnections: target.currentConnections,
|
|
projectedConnections: target.projectedConnections,
|
|
projectedUnits: target.projectedUnits
|
|
}))
|
|
})
|
|
return { sourceProcess, sourceStatus, plannedTargets, sourceAlreadyFenced: Boolean(sourceFence) }
|
|
}
|
|
|
|
async function assertRecoveryPreDrain(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
selectorPost,
|
|
expectedSelector
|
|
) {
|
|
const statuses = await allPairStatuses(config, adminPost, false)
|
|
const totals = statusTotals(statuses)
|
|
const capacity = await readTargetCapacitySnapshot(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
true
|
|
)
|
|
if (capacity.sourceAssignments !== 0 || capacity.requiredTargetUnits !== 0) {
|
|
deps.emit({
|
|
event: 'multi_forward_recovery_catch_up',
|
|
sourceAssignments: capacity.sourceAssignments,
|
|
requiredTargetUnits: capacity.requiredTargetUnits
|
|
})
|
|
return false
|
|
}
|
|
for (const target of targets) await targetRuntime(config, adminPost, target)
|
|
if (expectedSelector) {
|
|
const current = await inspectAdmissionSelector(selectorPost)
|
|
if (
|
|
current.selector.generation !== expectedSelector.generation ||
|
|
JSON.stringify(current.selector.membership) !==
|
|
JSON.stringify(expectedSelector.membership)
|
|
) {
|
|
throw new Error('admission selector changed before recovery drain')
|
|
}
|
|
}
|
|
deps.emit({
|
|
event: 'multi_forward_recovery_ready_to_drain',
|
|
...totals
|
|
})
|
|
return true
|
|
}
|
|
|
|
async function waitForRecoveryTargetOwnership(config, deps, adminPost, targets) {
|
|
const deadline =
|
|
deps.now() + Math.min(config.timeoutMs, RECOVERY_TARGET_OWNERSHIP_TIMEOUT_MS)
|
|
while (deps.now() < deadline) {
|
|
const statuses = await allPairStatuses(config, adminPost, false)
|
|
for (const target of targets) await targetRuntime(config, adminPost, target)
|
|
const totals = statusTotals(statuses)
|
|
deps.emit({ event: 'multi_recovery_target_ownership', ...totals })
|
|
assertLeaseGate(statuses, config.minimumLeaseRemainingMs)
|
|
if (hasDurableTargetOwnership(totals)) return
|
|
if (boundedUnregisteredMigrations(totals, config.unobservedConnectionBound)) {
|
|
const unregistered = totals.inProgress - totals.targetRegistered
|
|
deps.emit({
|
|
event:
|
|
totals.targetRegistered === 0
|
|
? 'multi_recovery_bounded_unregistered'
|
|
: 'multi_recovery_bounded_mixed_registration',
|
|
unobservedConnectionBound: config.unobservedConnectionBound,
|
|
unregistered,
|
|
...totals
|
|
})
|
|
return
|
|
}
|
|
await deps.wait(config.pollIntervalMs)
|
|
}
|
|
throw new Error('timed out waiting for recovery target ownership')
|
|
}
|
|
|
|
async function runEvacuation(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
token,
|
|
source,
|
|
targets,
|
|
plannedTargets,
|
|
sourceStatus,
|
|
selectorActive,
|
|
selectorPost,
|
|
expectedSelector
|
|
) {
|
|
let drainAttempted = false
|
|
try {
|
|
if (!selectorActive) {
|
|
await setCellState(config, adminPost, source.cellId, false)
|
|
for (const target of targets) await setCellState(config, adminPost, target.cellId, true)
|
|
}
|
|
await publishMigrations(config, deps, adminPost, plannedTargets)
|
|
const statuses = await allPairStatuses(config, adminPost, false)
|
|
assertLeaseGate(statuses, config.minimumLeaseRemainingMs)
|
|
for (const target of targets) await targetRuntime(config, adminPost, target)
|
|
if (selectorActive) {
|
|
const current = await inspectAdmissionSelector(selectorPost)
|
|
if (
|
|
current.selector.generation !== expectedSelector.generation ||
|
|
JSON.stringify(current.selector.membership) !==
|
|
JSON.stringify(expectedSelector.membership)
|
|
) {
|
|
throw new Error('admission selector changed before drain')
|
|
}
|
|
}
|
|
const attemptId = randomUUID()
|
|
const traceValue = randomUUID()
|
|
const prepared = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/drain-attempt-prepare',
|
|
{
|
|
v: 1,
|
|
attemptId,
|
|
cellId: source.cellId,
|
|
cellIncarnation: runtimeIncarnation(sourceStatus, source.cellId),
|
|
traceValue,
|
|
graceMs: 120_000,
|
|
confirmation: 'PREPARE_LEGACY_DRAIN'
|
|
}
|
|
)
|
|
if (prepared.state !== 'prepared') {
|
|
throw new Error('planned drain already recorded; use recover-forward')
|
|
}
|
|
drainAttempted = true
|
|
const sending = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/drain-attempt-send',
|
|
{
|
|
v: 1,
|
|
attemptId,
|
|
cellId: source.cellId,
|
|
cellIncarnation: runtimeIncarnation(sourceStatus, source.cellId)
|
|
}
|
|
)
|
|
if (
|
|
sending.attempt?.state !== 'send-may-have-started' ||
|
|
sending.attempt.shouldSend !== true ||
|
|
!Number.isSafeInteger(sending.attempt.sendPermitExpiresAt) ||
|
|
deps.now() >= sending.attempt.sendPermitExpiresAt
|
|
) {
|
|
throw new Error('drain send permit unavailable')
|
|
}
|
|
const receipt = await drainSource(config, deps, token, source, 120_000, traceValue)
|
|
await adminPost(config.directorOrigin, '/v1/admin/drain-attempt-receipt', {
|
|
v: 1,
|
|
attemptId,
|
|
cellId: source.cellId,
|
|
cellIncarnation: runtimeIncarnation(sourceStatus, source.cellId),
|
|
traceValue,
|
|
...receipt
|
|
})
|
|
deps.emit({ event: 'source_drain_accepted', sourceCellId: source.cellId })
|
|
await waitForMultiStatus(config, deps, adminPost, targets, false)
|
|
await waitForMultiStatus(config, deps, adminPost, targets, true)
|
|
deps.emit({ event: 'multi_target_complete', sourceCellId: source.cellId })
|
|
} catch (error) {
|
|
if (drainAttempted) {
|
|
const statuses = await allPairStatuses(config, adminPost, false).catch(() => [])
|
|
deps.emit({ event: 'multi_forward_recovery_required', ...statusTotals(statuses) })
|
|
} else {
|
|
await rollbackBeforeDrain(config, deps, adminPost, targets, selectorActive)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function waitForFence(config, deps, adminPost, source) {
|
|
const deadline = deps.now() + config.timeoutMs
|
|
while (deps.now() < deadline) {
|
|
const mig = deps.commandJson([
|
|
'compute',
|
|
'instance-groups',
|
|
'managed',
|
|
'describe',
|
|
source.migName,
|
|
'--project',
|
|
config.project,
|
|
'--zone',
|
|
source.zone,
|
|
'--format=json'
|
|
])
|
|
const instances = deps.commandJson([
|
|
'compute',
|
|
'instance-groups',
|
|
'managed',
|
|
'list-instances',
|
|
source.migName,
|
|
'--project',
|
|
config.project,
|
|
'--zone',
|
|
source.zone,
|
|
'--format=json'
|
|
])
|
|
const status = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: source.cellId
|
|
})
|
|
if (
|
|
Number(mig.targetSize) === 0 &&
|
|
instances.length === 0 &&
|
|
status.status?.cellUrl === source.origin &&
|
|
status.status.enabled === false &&
|
|
!status.status.runtime?.heartbeatFresh
|
|
) {
|
|
const incarnation = status.status.runtime?.cellIncarnation
|
|
if (typeof incarnation !== 'string' || incarnation.length === 0) {
|
|
throw new Error('fenced source has no exact runtime incarnation')
|
|
}
|
|
return incarnation
|
|
}
|
|
await deps.wait(config.pollIntervalMs)
|
|
}
|
|
throw new Error('timed out waiting for durable source fence')
|
|
}
|
|
|
|
function terraformFenceConfig(config, cell, cellIncarnation) {
|
|
return {
|
|
project: config.project,
|
|
environment: config.environment,
|
|
terraformDir: config.terraformDir,
|
|
varFile: config.terraformVarFile,
|
|
lockTimeout: '5m',
|
|
fenceCommit: config.fenceCommit,
|
|
cellIncarnation,
|
|
cell
|
|
}
|
|
}
|
|
|
|
function fenceAttemptBody(attempt) {
|
|
return {
|
|
v: 1,
|
|
attemptId: attempt.attemptId,
|
|
environment: attempt.environment,
|
|
cellId: attempt.cellId,
|
|
cellIncarnation: attempt.cellIncarnation,
|
|
migName: attempt.migName,
|
|
instanceGroup: attempt.instanceGroup,
|
|
generationIdentity: attempt.generationIdentity,
|
|
fenceCommit: attempt.fenceCommit,
|
|
planSha256: attempt.planSha256,
|
|
planObjectName: attempt.planObjectName,
|
|
planObjectGeneration: attempt.planObjectGeneration,
|
|
varFileSha256: attempt.varFileSha256,
|
|
terraformStateLineage: attempt.terraformStateLineage,
|
|
terraformStateSerial: attempt.terraformStateSerial,
|
|
terraformStateObjectGeneration: attempt.terraformStateObjectGeneration,
|
|
terraformStateObjectSha256: attempt.terraformStateObjectSha256,
|
|
requestReason: attempt.requestReason,
|
|
...(attempt.gceOperation ? { gceOperation: attempt.gceOperation } : {})
|
|
}
|
|
}
|
|
|
|
async function runTerraformManagedFence(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
cell,
|
|
cellIncarnation,
|
|
alreadyFenced,
|
|
preApplyGuard,
|
|
postApplyGuard
|
|
) {
|
|
const fenceConfig = terraformFenceConfig(config, cell, cellIncarnation)
|
|
const inspectProgress = async (_expected, attempt) =>
|
|
await inspectTerraformFenceProgress(
|
|
fenceConfig,
|
|
{
|
|
terraform: deps.terraform,
|
|
gcloudJson: deps.commandJson
|
|
},
|
|
attempt
|
|
)
|
|
const attest = async (attempt) => {
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attest', {
|
|
...fenceAttemptBody(attempt),
|
|
confirmation: 'ATTEST_TERRAFORM_FENCED_CELL'
|
|
})
|
|
}
|
|
const attemptResult = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/cell-fence-attempt-status',
|
|
{ v: 1, cellId: cell.cellId }
|
|
)
|
|
let existingAttempt = attemptResult.attempt
|
|
const planStore = {
|
|
uploadPlan: async (planPath, attempt) =>
|
|
await uploadTerraformFencePlan(
|
|
fenceConfig,
|
|
{ command: deps.command, commandJson: deps.commandJson },
|
|
planPath,
|
|
attempt
|
|
),
|
|
downloadPlan: async (attempt, planPath) =>
|
|
await downloadTerraformFencePlan(
|
|
fenceConfig,
|
|
{ command: deps.command },
|
|
attempt,
|
|
planPath
|
|
),
|
|
deletePlan: async (attempt) =>
|
|
await deleteTerraformFencePlan(
|
|
fenceConfig,
|
|
{ commandResult: deps.commandResult },
|
|
attempt
|
|
),
|
|
stateObjectBinding: async (statePath) =>
|
|
await readTerraformStateObjectBinding(
|
|
fenceConfig,
|
|
{ command: deps.command, commandJson: deps.commandJson },
|
|
statePath
|
|
)
|
|
}
|
|
if (
|
|
existingAttempt &&
|
|
existingAttempt.fenceCommit !== config.fenceCommit &&
|
|
config.completedFenceRecovery
|
|
) {
|
|
await deps.terraformFenceRecoverCompleted(
|
|
fenceConfig,
|
|
{
|
|
terraform: deps.terraform,
|
|
assertCommittedFenceSet: async () =>
|
|
assertTerraformFenceSet(fenceConfig, { terraform: deps.terraform }),
|
|
loadAttempt: async () => existingAttempt,
|
|
resolvePlan: async (attempt) =>
|
|
await resolveTerraformFencePlanGeneration(
|
|
fenceConfig,
|
|
{ commandResult: deps.commandResult },
|
|
attempt
|
|
),
|
|
stateObjectBinding: planStore.stateObjectBinding,
|
|
downloadPlan: planStore.downloadPlan,
|
|
deletePlan: planStore.deletePlan,
|
|
inspectCompletedProgress: async (_expected, attempt, recovery) =>
|
|
await inspectCompletedTerraformFenceProgress(
|
|
fenceConfig,
|
|
{
|
|
terraform: deps.terraform,
|
|
gcloudJson: deps.commandJson
|
|
},
|
|
attempt,
|
|
recovery
|
|
),
|
|
markOperation: async (attempt, invocation) =>
|
|
await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/cell-fence-attempt-operation',
|
|
{
|
|
...fenceAttemptBody(attempt),
|
|
invocationId: invocation.invocationId,
|
|
invocationRequestReason: invocation.requestReason,
|
|
confirmation: 'RECORD_TERRAFORM_CELL_FENCE_OPERATION'
|
|
}
|
|
),
|
|
assertZeroDiff: async () =>
|
|
assertTerraformFenceZeroDiff(fenceConfig, {
|
|
terraform: deps.terraform
|
|
}),
|
|
postApplyGuard,
|
|
attest,
|
|
emit: deps.emit
|
|
},
|
|
config.completedFenceRecovery
|
|
)
|
|
return
|
|
}
|
|
if (
|
|
existingAttempt &&
|
|
!existingAttempt.abortedAt &&
|
|
!existingAttempt.completedAt &&
|
|
existingAttempt.fenceCommit !== config.fenceCommit
|
|
) {
|
|
await deps.terraformFenceSupersede(fenceConfig, {
|
|
assertCommittedFenceSet: async () =>
|
|
assertTerraformFenceSet(fenceConfig, { terraform: deps.terraform }),
|
|
loadAttempt: async () => existingAttempt,
|
|
resolvePlan: async (attempt) =>
|
|
await resolveTerraformFencePlanGeneration(
|
|
fenceConfig,
|
|
{ commandResult: deps.commandResult },
|
|
attempt
|
|
),
|
|
inspectProgress,
|
|
abortAttempt: async (attempt) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-abort', {
|
|
...fenceAttemptBody(attempt),
|
|
confirmation: 'ABORT_UNSTARTED_TERRAFORM_CELL_FENCE'
|
|
}),
|
|
emit: deps.emit
|
|
})
|
|
existingAttempt = null
|
|
}
|
|
if (
|
|
existingAttempt &&
|
|
!existingAttempt.abortedAt &&
|
|
(alreadyFenced || !existingAttempt.completedAt)
|
|
) {
|
|
await deps.terraformFenceResume(fenceConfig, {
|
|
terraform: deps.terraform,
|
|
assertCommittedFenceSet: async () =>
|
|
assertTerraformFenceSet(fenceConfig, { terraform: deps.terraform }),
|
|
loadAttempt: async () => existingAttempt,
|
|
resolvePlan: async (attempt) =>
|
|
await resolveTerraformFencePlanGeneration(
|
|
fenceConfig,
|
|
{ commandResult: deps.commandResult },
|
|
attempt
|
|
),
|
|
bindPlan: async (attempt) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-plan', {
|
|
...fenceAttemptBody(attempt),
|
|
confirmation: 'BIND_TERRAFORM_CELL_FENCE_PLAN'
|
|
}),
|
|
inspectProgress,
|
|
assertZeroDiff: async () =>
|
|
assertTerraformFenceZeroDiff(fenceConfig, { terraform: deps.terraform }),
|
|
assertStateFenced: async () =>
|
|
assertTerraformFenceStateFenced(fenceConfig, { terraform: deps.terraform }),
|
|
preApplyGuard,
|
|
postApplyGuard,
|
|
markOperation: async (attempt, invocation) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-operation', {
|
|
...fenceAttemptBody(attempt),
|
|
invocationId: invocation.invocationId,
|
|
invocationRequestReason: invocation.requestReason,
|
|
confirmation: 'RECORD_TERRAFORM_CELL_FENCE_OPERATION'
|
|
}),
|
|
markApplyStarted: async (attempt, invocation) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-start', {
|
|
...fenceAttemptBody(attempt),
|
|
invocationId: invocation.invocationId,
|
|
invocationRequestReason: invocation.requestReason,
|
|
confirmation: 'START_TERRAFORM_CELL_FENCE'
|
|
}),
|
|
attest,
|
|
...planStore,
|
|
emit: deps.emit
|
|
})
|
|
return
|
|
}
|
|
if (alreadyFenced) {
|
|
await deps.terraformFenceAdopt(fenceConfig, {
|
|
loadAttempt: async () =>
|
|
(
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-status', {
|
|
v: 1,
|
|
cellId: cell.cellId
|
|
})
|
|
).attempt,
|
|
assertCommittedFenceSet: async () =>
|
|
assertTerraformFenceSet(fenceConfig, { terraform: deps.terraform }),
|
|
assertStateFenced: async () =>
|
|
assertTerraformFenceStateFenced(fenceConfig, { terraform: deps.terraform }),
|
|
preApplyGuard,
|
|
postApplyGuard,
|
|
attest: async (incarnation) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-adopt-legacy', {
|
|
v: 1,
|
|
cellId: cell.cellId,
|
|
cellIncarnation: incarnation,
|
|
confirmation: 'ADOPT_LEGACY_TERRAFORM_CELL_FENCE'
|
|
}),
|
|
commitAdoption: async (incarnation) =>
|
|
await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/cell-fence-commit-legacy-adoption',
|
|
{
|
|
v: 1,
|
|
cellId: cell.cellId,
|
|
cellIncarnation: incarnation,
|
|
confirmation: 'COMMIT_LEGACY_TERRAFORM_CELL_FENCE_ADOPTION'
|
|
}
|
|
),
|
|
emit: deps.emit
|
|
})
|
|
return
|
|
}
|
|
if (existingAttempt && !existingAttempt.abortedAt && !existingAttempt.completedAt) {
|
|
throw new Error('prepared Terraform fence attempt must be aborted before replacement')
|
|
}
|
|
await deps.terraformFenceApply(fenceConfig, {
|
|
terraform: deps.terraform,
|
|
assertCommittedFenceSet: async () =>
|
|
assertTerraformFenceSet(fenceConfig, { terraform: deps.terraform }),
|
|
inspectProgress,
|
|
assertZeroDiff: async () =>
|
|
assertTerraformFenceZeroDiff(fenceConfig, { terraform: deps.terraform }),
|
|
preApplyGuard,
|
|
postApplyGuard,
|
|
...planStore,
|
|
prepareAttempt: async (attempt) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-prepare', {
|
|
...fenceAttemptBody(attempt),
|
|
confirmation: 'PREPARE_TERRAFORM_CELL_FENCE'
|
|
}),
|
|
bindPlan: async (attempt) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-plan', {
|
|
...fenceAttemptBody(attempt),
|
|
confirmation: 'BIND_TERRAFORM_CELL_FENCE_PLAN'
|
|
}),
|
|
markApplyStarted: async (attempt, invocation) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-start', {
|
|
...fenceAttemptBody(attempt),
|
|
invocationId: invocation.invocationId,
|
|
invocationRequestReason: invocation.requestReason,
|
|
confirmation: 'START_TERRAFORM_CELL_FENCE'
|
|
}),
|
|
markOperation: async (attempt, invocation) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-operation', {
|
|
...fenceAttemptBody(attempt),
|
|
invocationId: invocation.invocationId,
|
|
invocationRequestReason: invocation.requestReason,
|
|
confirmation: 'RECORD_TERRAFORM_CELL_FENCE_OPERATION'
|
|
}),
|
|
attest,
|
|
emit: deps.emit
|
|
})
|
|
}
|
|
|
|
async function abortTerraformManagedFence(config, deps, adminPost, cell) {
|
|
const result = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/cell-fence-attempt-status',
|
|
{ v: 1, cellId: cell.cellId }
|
|
)
|
|
const attempt = result.attempt
|
|
const fenceConfig = terraformFenceConfig(config, cell, attempt?.cellIncarnation)
|
|
await deps.terraformFenceAbort(fenceConfig, {
|
|
terraform: deps.terraform,
|
|
assertCommittedFenceSet: async () =>
|
|
assertTerraformFenceSet(fenceConfig, { terraform: deps.terraform }),
|
|
loadAttempt: async () => attempt,
|
|
resolvePlan: async (value) =>
|
|
await resolveTerraformFencePlanGeneration(
|
|
fenceConfig,
|
|
{ commandResult: deps.commandResult },
|
|
value
|
|
),
|
|
bindPlan: async (value) =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-plan', {
|
|
...fenceAttemptBody(value),
|
|
confirmation: 'BIND_TERRAFORM_CELL_FENCE_PLAN'
|
|
}),
|
|
inspectProgress: async () =>
|
|
await inspectTerraformFenceProgress(
|
|
fenceConfig,
|
|
{
|
|
terraform: deps.terraform,
|
|
gcloudJson: deps.commandJson
|
|
},
|
|
attempt
|
|
),
|
|
abortAttempt: async () =>
|
|
await adminPost(config.directorOrigin, '/v1/admin/cell-fence-attempt-abort', {
|
|
...fenceAttemptBody(attempt),
|
|
confirmation: 'ABORT_UNSTARTED_TERRAFORM_CELL_FENCE'
|
|
}),
|
|
deletePlan: async (value) =>
|
|
await deleteTerraformFencePlan(
|
|
fenceConfig,
|
|
{ commandResult: deps.commandResult },
|
|
value
|
|
),
|
|
emit: deps.emit
|
|
})
|
|
}
|
|
|
|
async function fenceSource(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
sourceProcess,
|
|
sourceStatus,
|
|
sourceAlreadyFenced
|
|
) {
|
|
if (sourceStatus.enabled) throw new Error('source fencing requires disabled admission')
|
|
const counts = sourceProcess
|
|
if (
|
|
(!sourceAlreadyFenced &&
|
|
(!counts ||
|
|
sourceStatus.runtime?.observedRequests !== 0 ||
|
|
counts.controls !== 0 ||
|
|
counts.splices !== 0 ||
|
|
counts.pendingSplices !== 0)) ||
|
|
sourceStatus.activityLeases !== 0 ||
|
|
sourceStatus.reservedRequests !== 0
|
|
) {
|
|
throw new Error('source fencing requires zero source-owned work')
|
|
}
|
|
const statuses = await allPairStatuses(config, adminPost, false)
|
|
const totals = statusTotals(statuses)
|
|
if (
|
|
totals.inProgress !== sourceStatus.outgoingMigrations ||
|
|
!hasDurableTargetOwnership(totals)
|
|
) {
|
|
throw new Error('source fencing requires full migration coverage and durable target ownership')
|
|
}
|
|
for (const target of targets) await targetRuntime(config, adminPost, target)
|
|
const cellIncarnation = runtimeIncarnation(sourceStatus, source.cellId)
|
|
const postApplyGuard = async (expectedIncarnation) => {
|
|
const actualIncarnation = await waitForFence(config, deps, adminPost, source)
|
|
if (actualIncarnation !== expectedIncarnation) {
|
|
throw new Error('fenced source incarnation changed')
|
|
}
|
|
const finalSourceMig = sourceMig(config, deps, source)
|
|
if (finalSourceMig.instances.length !== 0) {
|
|
throw new Error('fenced source still has an instance')
|
|
}
|
|
await inspectFencedSource(config, deps, adminPost, source, finalSourceMig.mig)
|
|
}
|
|
await runTerraformManagedFence(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
cellIncarnation,
|
|
sourceAlreadyFenced,
|
|
async () => {
|
|
const latestStatus = sourceAlreadyFenced
|
|
? await inspectFencedSource(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
sourceMig(config, deps, source).mig
|
|
)
|
|
: {
|
|
...await inspectDirectorObservedCell(config, deps, adminPost, source),
|
|
process: null
|
|
}
|
|
const latestCounts = sourceAlreadyFenced
|
|
? null
|
|
: processCounts(config, deps, latestStatus, source.cellId)
|
|
if (
|
|
latestStatus.enabled ||
|
|
runtimeIncarnation(latestStatus, source.cellId) !== cellIncarnation ||
|
|
(!sourceAlreadyFenced &&
|
|
(latestStatus.runtime?.observedRequests !== 0 ||
|
|
latestCounts.controls !== 0 ||
|
|
latestCounts.splices !== 0 ||
|
|
latestCounts.pendingSplices !== 0)) ||
|
|
latestStatus.activityLeases !== 0 ||
|
|
latestStatus.reservedRequests !== 0
|
|
) {
|
|
throw new Error('source fencing guards changed before Terraform apply')
|
|
}
|
|
const latestStatuses = await allPairStatuses(config, adminPost, false)
|
|
const latestTotals = statusTotals(latestStatuses)
|
|
if (
|
|
latestTotals.inProgress !== latestStatus.outgoingMigrations ||
|
|
!hasDurableTargetOwnership(latestTotals)
|
|
) {
|
|
throw new Error('source migration coverage or guards changed before Terraform apply')
|
|
}
|
|
for (const target of targets) {
|
|
await inspectDirectorObservedCell(config, deps, adminPost, target)
|
|
}
|
|
},
|
|
postApplyGuard
|
|
)
|
|
await waitForMultiStatus(config, deps, adminPost, targets, true, false, true)
|
|
deps.emit({ event: 'source_fenced', sourceCellId: source.cellId, targetSize: 0 })
|
|
}
|
|
|
|
async function runTargetSupersession(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
selector
|
|
) {
|
|
const failed = targets.find((target) => target.cellId === config.failedTargetCellId)
|
|
const replacement = targets.find(
|
|
(target) => target.cellId === config.replacementTargetCellId
|
|
)
|
|
if (!failed || !replacement) throw new Error('supersession topology is incomplete')
|
|
const sourceStatus = await adminPost(config.directorOrigin, '/v1/admin/cell-status', {
|
|
v: 1,
|
|
cellId: source.cellId
|
|
})
|
|
if (
|
|
sourceStatus.status?.cellUrl !== source.origin ||
|
|
(selector
|
|
? selectorCellState(selector, source.cellId) !== 'existing-only'
|
|
: sourceStatus.status.enabled)
|
|
) {
|
|
throw new Error('supersession requires retained disabled source topology')
|
|
}
|
|
const failedMig = sourceMig(config, deps, failed)
|
|
const failedStatus = await inspectFenceCandidate(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
failed,
|
|
failedMig.mig
|
|
)
|
|
if (
|
|
selector
|
|
? selectorCellState(selector, failed.cellId) !== 'existing-only'
|
|
: failedStatus.enabled
|
|
) {
|
|
throw new Error(
|
|
selector
|
|
? 'failed target admission must be existing-only'
|
|
: 'failed target admission must be disabled'
|
|
)
|
|
}
|
|
const replacementStatus = await inspectDirectorObservedCell(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
replacement
|
|
)
|
|
const migrationStatus = await pairStatus(config, adminPost, failed.cellId, false)
|
|
if (
|
|
!Number.isSafeInteger(migrationStatus.targetRegistered) ||
|
|
migrationStatus.targetRegistered < 1
|
|
) {
|
|
throw new Error('failed target has no registered migrations to supersede')
|
|
}
|
|
const failedConnections = failedStatus.runtime?.observedRequests
|
|
if (!Number.isSafeInteger(failedConnections) || failedConnections < 0) {
|
|
throw new Error('failed target has no exact runtime connection snapshot')
|
|
}
|
|
const replacementConnections = runtimeConnections(
|
|
replacementStatus.process,
|
|
replacement.cellId
|
|
)
|
|
const projectedConnections =
|
|
replacementConnections +
|
|
Math.max(failedConnections, migrationStatus.targetRegistered)
|
|
if (projectedConnections >= targetConnectionCeiling(config, replacement)) {
|
|
throw new Error('replacement target lacks conservative connection headroom')
|
|
}
|
|
const cellIncarnation = runtimeIncarnation(failedStatus, failed.cellId)
|
|
await runTerraformManagedFence(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
failed,
|
|
cellIncarnation,
|
|
Number(failedMig.mig.targetSize) === 0,
|
|
async () => {
|
|
const latestMig = sourceMig(config, deps, failed)
|
|
const latestStatus = await inspectFenceCandidate(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
failed,
|
|
latestMig.mig
|
|
)
|
|
if (
|
|
selector
|
|
? selectorCellState(selector, failed.cellId) !== 'existing-only'
|
|
: latestStatus.enabled
|
|
) {
|
|
throw new Error('failed target admission changed before apply')
|
|
}
|
|
if (runtimeIncarnation(latestStatus, failed.cellId) !== cellIncarnation) {
|
|
throw new Error('failed target incarnation changed before apply')
|
|
}
|
|
const latestMigration = await pairStatus(config, adminPost, failed.cellId, false)
|
|
if (latestMigration.targetRegistered < 1) {
|
|
throw new Error('failed target migrations changed before apply')
|
|
}
|
|
await inspectDirectorObservedCell(config, deps, adminPost, replacement)
|
|
},
|
|
async (expectedIncarnation) => {
|
|
const actualIncarnation = await waitForFence(config, deps, adminPost, failed)
|
|
if (actualIncarnation !== expectedIncarnation) {
|
|
throw new Error('failed target incarnation changed')
|
|
}
|
|
const finalFailedMig = sourceMig(config, deps, failed)
|
|
if (finalFailedMig.instances.length !== 0) {
|
|
throw new Error('failed target fence still has an instance')
|
|
}
|
|
await inspectFencedSource(config, deps, adminPost, failed, finalFailedMig.mig)
|
|
}
|
|
)
|
|
if (
|
|
selector &&
|
|
selectorCellState(selector, replacement.cellId) !== 'migration-only'
|
|
) {
|
|
throw new Error('replacement target admission must be migration-only')
|
|
}
|
|
if (!selector && !replacementStatus.enabled) {
|
|
await setCellState(config, adminPost, replacement.cellId, true)
|
|
}
|
|
let superseded = 0
|
|
while (true) {
|
|
const result = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/migration-supersede-cell',
|
|
{
|
|
v: 1,
|
|
sourceCellId: source.cellId,
|
|
currentTargetCellId: failed.cellId,
|
|
replacementTargetCellId: replacement.cellId,
|
|
limit: config.batchSize,
|
|
confirmation: 'SUPERSEDE_REGISTERED_CELL_MIGRATIONS'
|
|
}
|
|
)
|
|
if (
|
|
!Number.isSafeInteger(result.superseded) ||
|
|
result.superseded < 0 ||
|
|
result.superseded > config.batchSize
|
|
) {
|
|
throw new Error('invalid registered supersession result')
|
|
}
|
|
superseded += result.superseded
|
|
if (result.superseded === 0) break
|
|
}
|
|
const remaining = await pairStatus(config, adminPost, failed.cellId, false)
|
|
if (remaining.targetRegistered !== 0) {
|
|
throw new Error('registered target supersession did not reconcile')
|
|
}
|
|
if (superseded !== migrationStatus.targetRegistered) {
|
|
throw new Error('registered target supersession count changed')
|
|
}
|
|
deps.emit({
|
|
event: 'registered_target_superseded',
|
|
sourceCellId: source.cellId,
|
|
failedTargetCellId: failed.cellId,
|
|
replacementTargetCellId: replacement.cellId,
|
|
superseded,
|
|
remainingUnregistered: remaining.inProgress
|
|
})
|
|
}
|
|
|
|
export async function runMultiTargetDeployment(config, overrides = {}) {
|
|
const deps = {
|
|
commandJson: overrides.commandJson ?? defaultCommandJson,
|
|
command: overrides.command ?? defaultCommand,
|
|
commandResult: overrides.commandResult ?? defaultCommandResult,
|
|
terraform: overrides.terraform,
|
|
terraformFenceApply: overrides.terraformFenceApply ?? runTerraformFenceApply,
|
|
terraformFenceAdopt:
|
|
overrides.terraformFenceAdopt ?? adoptLegacyTerraformFence,
|
|
terraformFenceResume: overrides.terraformFenceResume ?? resumeTerraformFence,
|
|
terraformFenceRecoverCompleted:
|
|
overrides.terraformFenceRecoverCompleted ??
|
|
recoverSupersededCompletedTerraformFence,
|
|
terraformFenceAbort: overrides.terraformFenceAbort ?? abortTerraformFenceBeforeApply,
|
|
terraformFenceSupersede:
|
|
overrides.terraformFenceSupersede ?? abortSupersededTerraformFenceBeforeUpload,
|
|
identityToken: overrides.identityToken ?? defaultIdentityToken,
|
|
mutationIdentityToken:
|
|
overrides.mutationIdentityToken ??
|
|
(() => suppliedFenceMutationIdentityToken()),
|
|
fetch: overrides.fetch ?? fetch,
|
|
emit: overrides.emit ?? ((event) => process.stdout.write(`${JSON.stringify(event)}\n`)),
|
|
now: overrides.now ?? Date.now,
|
|
wait: overrides.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
random: overrides.random ?? Math.random,
|
|
resolve4: overrides.resolve4 ?? resolve4
|
|
}
|
|
const topology = JSON.parse(readFileSync(config.topologyFile, 'utf8'))
|
|
const { source, targets } = selectMultiTargetDeployments(
|
|
topology,
|
|
config.sourceCellId,
|
|
config.targetCellIds
|
|
)
|
|
const token = deps.identityToken(config.adminAudience)
|
|
const mutationToken =
|
|
['fence-source', 'abort-fence-source', 'supersede-target'].includes(config.mode)
|
|
? deps.mutationIdentityToken(config.adminAudience)
|
|
: null
|
|
if (
|
|
['fence-source', 'abort-fence-source', 'supersede-target'].includes(config.mode) &&
|
|
mutationToken === null
|
|
) {
|
|
throw new Error('Terraform fence mode requires a broker mutation identity token')
|
|
}
|
|
const adminPost = createAdminPost(
|
|
config,
|
|
deps,
|
|
(path) => (FENCE_BROKER_MUTATION_ROUTES.has(path) ? mutationToken : token)
|
|
)
|
|
if (config.mode === 'abort-fence-source') {
|
|
await abortTerraformManagedFence(config, deps, adminPost, source)
|
|
return
|
|
}
|
|
const selectorPost = async (path, body) =>
|
|
await adminPost(config.directorOrigin, path, body)
|
|
const selectorInspection = await inspectAdmissionSelector(selectorPost)
|
|
const selectorActive = selectorInspection.selector.generation > 0
|
|
if (config.mode === 'cutover-admission') {
|
|
const membership = cutoverMembership(topology, config)
|
|
if (
|
|
selectorActive &&
|
|
JSON.stringify(selectorInspection.selector.membership) !== JSON.stringify(membership)
|
|
) {
|
|
throw new Error('selector boundary is already active with different membership')
|
|
}
|
|
await inspectCutoverCells(topology, config, deps, adminPost, membership)
|
|
pruneIncompatibleDirectorRevisions(config, deps)
|
|
const director = verifySelectorCompatibleDirector(config, deps)
|
|
await inspectCutoverCells(topology, config, deps, adminPost, membership)
|
|
const result = await applyExactAdmissionSelector(selectorPost, membership, {
|
|
requireBoundary: false,
|
|
attemptId: config.selectorAttemptId,
|
|
expectedCurrentSelector: selectorInspection.selector
|
|
})
|
|
deps.emit({
|
|
event: 'admission_selector_cutover',
|
|
generation: result.selector.generation,
|
|
membership: result.selector.membership,
|
|
...director
|
|
})
|
|
return
|
|
}
|
|
if (config.mode === 'add-migration-cells') {
|
|
if (!selectorActive) throw new Error('admission selector boundary is not active')
|
|
pruneIncompatibleDirectorRevisions(config, deps)
|
|
const director = verifySelectorCompatibleDirector(config, deps)
|
|
if (
|
|
targets.some(
|
|
(target) =>
|
|
target.connectionHardCap === undefined ||
|
|
target.connectionUnobservedBound === undefined
|
|
)
|
|
) {
|
|
throw new Error('migration cells require reviewed connection capacity')
|
|
}
|
|
const result = await addExactMigrationCells(
|
|
selectorPost,
|
|
{
|
|
attemptId: config.selectorAttemptId,
|
|
cells: targets.map((target) => ({
|
|
cellId: target.cellId,
|
|
cellUrl: target.origin,
|
|
region: target.region,
|
|
capacityRequests: target.capacityRequests,
|
|
connectionHardCap: target.connectionHardCap,
|
|
connectionUnobservedBound: target.connectionUnobservedBound
|
|
}))
|
|
},
|
|
{ expectedCurrentSelector: selectorInspection.selector }
|
|
)
|
|
deps.emit({
|
|
event: 'migration_cells_added',
|
|
generation: result.selector.generation,
|
|
membership: result.selector.membership,
|
|
cellIds: targets.map(({ cellId }) => cellId),
|
|
...director
|
|
})
|
|
return
|
|
}
|
|
if (config.mode === 'promote-general-cell') {
|
|
if (!selectorActive) throw new Error('admission selector boundary is not active')
|
|
const [promoted] = targets
|
|
if (
|
|
!promoted ||
|
|
selectorCellState(selectorInspection.selector, promoted.cellId) !== 'migration-only'
|
|
) {
|
|
throw new Error('promoted cell admission must be migration-only')
|
|
}
|
|
const director = verifyActiveSelectorDirector(config, deps, promoted.cellId)
|
|
await inspectGeneralPromotionTarget(config, deps, adminPost, promoted)
|
|
const result = await applyExactAdmissionSelector(
|
|
selectorPost,
|
|
membershipWithStates(selectorInspection.selector, {
|
|
[promoted.cellId]: 'general'
|
|
}),
|
|
{
|
|
attemptId: config.selectorAttemptId,
|
|
expectedCurrentSelector: selectorInspection.selector
|
|
}
|
|
)
|
|
deps.emit({
|
|
event: 'migration_cell_promoted_general',
|
|
generation: result.selector.generation,
|
|
membership: result.selector.membership,
|
|
cellId: promoted.cellId,
|
|
...director
|
|
})
|
|
return
|
|
}
|
|
if (config.mode === 'retire-migration-cell') {
|
|
if (!selectorActive) throw new Error('admission selector boundary is not active')
|
|
const [retiring] = targets
|
|
if (
|
|
!retiring ||
|
|
selectorCellState(selectorInspection.selector, retiring.cellId) !== 'migration-only'
|
|
) {
|
|
throw new Error('retired cell admission must be migration-only')
|
|
}
|
|
pruneIncompatibleDirectorRevisions(config, deps)
|
|
const director = verifySelectorCompatibleDirector(config, deps)
|
|
const result = await applyExactAdmissionSelector(
|
|
selectorPost,
|
|
membershipWithStates(selectorInspection.selector, {
|
|
[retiring.cellId]: 'existing-only'
|
|
}),
|
|
{
|
|
attemptId: config.selectorAttemptId,
|
|
expectedCurrentSelector: selectorInspection.selector
|
|
}
|
|
)
|
|
deps.emit({
|
|
event: 'migration_cell_retired',
|
|
generation: result.selector.generation,
|
|
membership: result.selector.membership,
|
|
cellId: retiring.cellId,
|
|
...director
|
|
})
|
|
return
|
|
}
|
|
if (config.mode === 'supersede-target') {
|
|
await runTargetSupersession(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
selectorActive ? selectorInspection.selector : null
|
|
)
|
|
return
|
|
}
|
|
const sourceFence = config.mode === 'fence-source' ? sourceMig(config, deps, source) : null
|
|
if (
|
|
sourceFence &&
|
|
![0, 1].includes(Number(sourceFence.mig.targetSize))
|
|
) {
|
|
throw new Error('source MIG must be fixed-one or already fenced')
|
|
}
|
|
const fencedResume = sourceFence && Number(sourceFence.mig.targetSize) === 0 ? sourceFence : null
|
|
const { sourceProcess, sourceStatus, plannedTargets, sourceAlreadyFenced } = await preflight(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
fencedResume,
|
|
selectorActive ? selectorInspection.selector : null
|
|
)
|
|
if (config.mode === 'audit' || config.mode === 'preflight') return
|
|
if (config.mode === 'fence-source') {
|
|
await fenceSource(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
sourceProcess,
|
|
sourceStatus,
|
|
sourceAlreadyFenced
|
|
)
|
|
return
|
|
}
|
|
if (config.mode === 'recover-forward') {
|
|
const invalidSelectorAdmission =
|
|
selectorActive &&
|
|
(selectorCellState(selectorInspection.selector, source.cellId) !== 'existing-only' ||
|
|
plannedTargets.some(
|
|
(target) =>
|
|
selectorCellState(selectorInspection.selector, target.cellId) !==
|
|
'migration-only'
|
|
))
|
|
if (
|
|
invalidSelectorAdmission ||
|
|
(!selectorActive &&
|
|
(sourceStatus.enabled || plannedTargets.some((target) => !target.status.enabled)))
|
|
) {
|
|
throw new Error(
|
|
selectorActive
|
|
? 'forward recovery requires existing-only source and migration-only targets'
|
|
: 'forward recovery requires disabled source and enabled targets'
|
|
)
|
|
}
|
|
let coveredSourceConnections = runtimeConnections(sourceProcess, source.cellId)
|
|
let recoveryTargets = plannedTargets
|
|
for (let pass = 1; pass <= RECOVERY_CATCH_UP_PASSES; pass++) {
|
|
await publishMigrations(config, deps, adminPost, recoveryTargets)
|
|
if (
|
|
await assertRecoveryPreDrain(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
selectorPost,
|
|
selectorActive ? selectorInspection.selector : null
|
|
)
|
|
) {
|
|
break
|
|
}
|
|
if (pass === RECOVERY_CATCH_UP_PASSES) {
|
|
throw new Error('source assignments did not quiesce within bounded recovery catch-up')
|
|
}
|
|
const catchUp = await preflight(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
targets,
|
|
null,
|
|
selectorActive ? selectorInspection.selector : null,
|
|
coveredSourceConnections
|
|
)
|
|
coveredSourceConnections = Math.max(
|
|
coveredSourceConnections,
|
|
runtimeConnections(catchUp.sourceProcess, source.cellId)
|
|
)
|
|
recoveryTargets = catchUp.plannedTargets
|
|
}
|
|
if (!sourceStatus.draining) {
|
|
const activeSourceTransports =
|
|
sourceProcess.controls + sourceProcess.splices + sourceProcess.pendingSplices
|
|
if (activeSourceTransports > 0) {
|
|
const recovery = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/drain-attempt-recover-forward',
|
|
{
|
|
v: 1,
|
|
cellId: source.cellId,
|
|
cellIncarnation: runtimeIncarnation(sourceStatus, source.cellId),
|
|
confirmation: 'RECOVER_LEGACY_DRAIN'
|
|
}
|
|
)
|
|
await waitForRecoveryTargetOwnership(config, deps, adminPost, targets)
|
|
if (recovery.preparedAttempt) {
|
|
const attempt = recovery.preparedAttempt
|
|
if (
|
|
attempt.state !== 'prepared' ||
|
|
attempt.cellId !== source.cellId ||
|
|
attempt.cellIncarnation !== runtimeIncarnation(sourceStatus, source.cellId) ||
|
|
attempt.plannedGraceMs !== 120_000 ||
|
|
typeof attempt.attemptId !== 'string' ||
|
|
typeof attempt.traceValue !== 'string'
|
|
) {
|
|
throw new Error('prepared drain recovery state is invalid')
|
|
}
|
|
const sending = await adminPost(
|
|
config.directorOrigin,
|
|
'/v1/admin/drain-attempt-send',
|
|
{
|
|
v: 1,
|
|
attemptId: attempt.attemptId,
|
|
cellId: source.cellId,
|
|
cellIncarnation: attempt.cellIncarnation
|
|
}
|
|
)
|
|
if (
|
|
sending.attempt?.state !== 'send-may-have-started' ||
|
|
sending.attempt.shouldSend !== true ||
|
|
!Number.isSafeInteger(sending.attempt.sendPermitExpiresAt) ||
|
|
deps.now() >= sending.attempt.sendPermitExpiresAt
|
|
) {
|
|
throw new Error('prepared drain recovery send permit unavailable')
|
|
}
|
|
const receipt = await drainSource(
|
|
config,
|
|
deps,
|
|
token,
|
|
source,
|
|
attempt.plannedGraceMs,
|
|
attempt.traceValue
|
|
)
|
|
await adminPost(config.directorOrigin, '/v1/admin/drain-attempt-receipt', {
|
|
v: 1,
|
|
attemptId: attempt.attemptId,
|
|
cellId: source.cellId,
|
|
cellIncarnation: attempt.cellIncarnation,
|
|
traceValue: attempt.traceValue,
|
|
...receipt
|
|
})
|
|
deps.emit({
|
|
event: 'source_prepared_drain_recovered',
|
|
sourceCellId: source.cellId
|
|
})
|
|
} else if (recovery.shouldSend === true) {
|
|
await drainSource(config, deps, token, source, 0)
|
|
deps.emit({ event: 'source_recovery_drain_accepted', sourceCellId: source.cellId })
|
|
} else {
|
|
const latestSource = await inspectCell(config, deps, adminPost, source)
|
|
const expectedIncarnation = runtimeIncarnation(sourceStatus, source.cellId)
|
|
if (runtimeIncarnation(latestSource, source.cellId) !== expectedIncarnation) {
|
|
throw new Error('source incarnation changed before recovery drain reissue')
|
|
}
|
|
if (latestSource.draining) {
|
|
deps.emit({
|
|
event: 'source_recovery_drain_already_applied',
|
|
sourceCellId: source.cellId
|
|
})
|
|
} else {
|
|
const latestStatuses = await allPairStatuses(config, adminPost, false)
|
|
const latestTotals = statusTotals(latestStatuses)
|
|
assertLeaseGate(latestStatuses, config.minimumLeaseRemainingMs)
|
|
if (
|
|
!hasDurableTargetOwnership(latestTotals) &&
|
|
!boundedUnregisteredMigrations(
|
|
latestTotals,
|
|
config.unobservedConnectionBound
|
|
)
|
|
) {
|
|
throw new Error('recovery target ownership changed before drain reissue')
|
|
}
|
|
await drainSource(config, deps, token, source, 0)
|
|
deps.emit({
|
|
event: 'source_recovery_drain_reissued_after_non_delivery',
|
|
sourceCellId: source.cellId,
|
|
...latestTotals
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
deps.emit({
|
|
event: 'source_recovery_drain_not_needed',
|
|
sourceCellId: source.cellId
|
|
})
|
|
}
|
|
}
|
|
await waitForMultiStatus(config, deps, adminPost, targets, false, true)
|
|
await waitForRecoveredSourceZero(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
source,
|
|
runtimeIncarnation(sourceStatus, source.cellId)
|
|
)
|
|
await waitForMultiStatus(config, deps, adminPost, targets, true, true)
|
|
deps.emit({ event: 'multi_target_complete', sourceCellId: source.cellId })
|
|
return
|
|
}
|
|
if (
|
|
selectorActive &&
|
|
targets.some(
|
|
(target) =>
|
|
selectorCellState(selectorInspection.selector, target.cellId) !== 'migration-only'
|
|
)
|
|
) {
|
|
throw new Error('evacuation targets must be migration-only')
|
|
}
|
|
await runEvacuation(
|
|
config,
|
|
deps,
|
|
adminPost,
|
|
token,
|
|
source,
|
|
targets,
|
|
plannedTargets,
|
|
sourceStatus,
|
|
selectorActive,
|
|
selectorPost,
|
|
selectorInspection.selector
|
|
)
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
await runMultiTargetDeployment(parseMultiTargetArguments(argv))
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
process.exitCode = 1
|
|
})
|
|
}
|