Files
gitea-macos-vm-orchestrator/Sources/RunnerHost/ImageBuilder.swift
T

707 lines
30 KiB
Swift

import Foundation
import RunnerCore
import Virtualization
/// A coarse progress report from ``ImageBuilder``.
public enum ImageBuildStage: Sendable, Equatable {
/// Downloading the IPSW.
case downloadingIPSW(fraction: Double)
/// Reading the restore image and deriving a hardware configuration.
case preparing
/// Creating the disk, NVRAM, and bundle metadata.
case creatingBundle
/// `VZMacOSInstaller` is writing macOS onto the disk.
case installing(fraction: Double)
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
case firstBoot
/// Running guest provisioning over SSH.
case provisioning(step: String)
/// Shutting the guest down cleanly and sealing the bundle.
case finalizing
/// Done.
case done
}
/// Builds a base macOS image from an IPSW, end to end.
///
/// ## Pipeline
///
/// 1. **Load restore image** — `VZMacOSRestoreImage` from a local `.ipsw`
/// (downloaded first if the caller did not supply one).
/// 2. **Derive hardware** — `mostFeaturefulSupportedConfiguration`. A `nil`
/// here means this host cannot run this image at all; fail loudly rather
/// than trying to guess a configuration.
/// 3. **Create the bundle** — persist `hardwareModel.dataRepresentation` and a
/// fresh `VZMacMachineIdentifier`; create NVRAM with
/// `VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:)`; create the
/// disk, preferring sparse ASIF via
/// `/usr/sbin/diskutil image create blank --fs none --format ASIF --size <N>G <path>`
/// (macOS 26+) and falling back to a `truncate`-style sparse RAW file.
/// 4. **Install** — `VZMacOSInstaller` against a *stopped* VM built from that
/// configuration, observing its `Progress` via KVO.
/// 5. **First boot with Setup Assistant automation** — see
/// ``firstBootAndProvision(bundle:config:progress:)``.
/// 6. **Provision** — ``GuestProvisioner`` over SSH.
/// 7. **Finalize** — clean guest shutdown, then set
/// ``VMBundleConfig/provisioned`` to `true`. Only then is the image clonable.
public struct ImageBuilder: Sendable {
/// The store this image is built into.
public let store: VMStore
/// Creates a builder.
public init(store: VMStore) {
self.store = store
}
/// Runs the whole pipeline.
///
/// - Parameters:
/// - name: Image name under `<storeDir>/images/`, e.g. `default`.
/// - ipswPath: A local `.ipsw`. When `nil`, the latest supported image is
/// discovered and downloaded.
/// - config: Supplies guest shape (CPU/RAM/disk), credentials, and the
/// `gitea-runner` download URL.
/// - progress: Stage callback. May be invoked from arbitrary threads.
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed stage.
public func build(
name: String,
ipswPath: String?,
config: RunnerConfig,
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
) async throws {
try store.ensureLayout()
// Checked against the filesystem rather than `store.image(named:)`: a
// half-built bundle from a previous failed run is exactly the thing this
// needs to catch, and it would not read back as a valid image.
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
if FileManager.default.fileExists(atPath: bundleURL.path) {
throw CoreError.configInvalid(
"image '\(name)' already exists at \(bundleURL.path). "
+ "Delete it first (`image delete \(name)`), or build under a different --name."
)
}
// The IPSW alone is ~15 GB and the installed disk grows to tens more.
try store.ensureFreeSpace(minGB: ipswPath == nil ? 60 : 45)
// 1. Restore image.
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
let restoreImage: VZMacOSRestoreImage
if let ipswPath {
progress?(.downloadingIPSW(fraction: 1.0))
restoreImage = try await provider.load(localPath: ipswPath)
} else {
progress?(.downloadingIPSW(fraction: 0))
let (image, _) = try await provider.fetchLatest { fraction in
progress?(.downloadingIPSW(fraction: fraction))
}
restoreImage = image
}
// 2/3. Hardware model and bundle.
progress?(.preparing)
progress?(.creatingBundle)
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
// 4. Install.
progress?(.installing(fraction: 0))
try await install(bundle: bundle, restoreImage: restoreImage) { fraction in
progress?(.installing(fraction: fraction))
}
// 5/6/7. First boot, provisioning, seal.
try await firstBootAndProvision(bundle: bundle, config: config, progress: progress)
progress?(.done)
}
/// Creates the bundle directory, disk, NVRAM, and `config.json`.
///
/// - Parameters:
/// - name: Image name.
/// - restoreImage: The loaded IPSW, used for its
/// `mostFeaturefulSupportedConfiguration` and `buildVersion`.
/// - config: Guest shape and credentials.
/// - Returns: The new, uninstalled bundle.
public func createBundle(
name: String,
restoreImage: VZMacOSRestoreImage,
config: RunnerConfig
) async throws -> VMBundle {
// `mostFeaturefulSupportedConfiguration` is nil when this host cannot run
// this image at all — an Intel host, or a restore image newer than the
// host's Virtualization stack. There is nothing to fall back to, and
// guessing a hardware model produces a VM that fails to boot much later
// with a far less useful message.
guard let requirements = restoreImage.mostFeaturefulSupportedConfiguration else {
let version = restoreImage.operatingSystemVersion
throw CoreError.hostUnsupported(
"this host cannot virtualize macOS \(version.majorVersion).\(version.minorVersion) "
+ "(build \(restoreImage.buildVersion)). The restore image reports no supported "
+ "configuration — the host is either not Apple silicon or is older than the guest."
)
}
let hardwareModel = requirements.hardwareModel
guard hardwareModel.isSupported else {
throw CoreError.hostUnsupported(
"the hardware model required by build \(restoreImage.buildVersion) is not supported on this host"
)
}
// The image's own minimums win over the configured shape: a guest below
// them will not boot, and silently honouring a too-small config would
// produce that failure at first boot instead of here.
let cpuCount = max(config.guest.cpuCount, requirements.minimumSupportedCPUCount)
let minimumMemoryGB = Int(
(requirements.minimumSupportedMemorySize + (1 << 30) - 1) / (1 << 30)
)
let memoryGB = max(config.guest.memoryGB, minimumMemoryGB)
let bundle = VMBundle(rootURL: store.imagesDir.appendingPathComponent(name, isDirectory: true))
try bundle.createDirectory()
do {
let diskFormat = try createDisk(bundle: bundle, sizeGB: config.guest.diskGB)
// NVRAM must be created against the *same* hardware model that goes
// into config.json and into VZMacPlatformConfiguration. A mismatch is
// undefined behaviour in the framework, not a validation error.
_ = try VZMacAuxiliaryStorage(
creatingStorageAt: bundle.auxiliaryStorageURL,
hardwareModel: hardwareModel,
options: []
)
let osVersion = restoreImage.operatingSystemVersion
let bundleConfig = VMBundleConfig(
hardwareModelData: hardwareModel.dataRepresentation,
machineIdentifierData: VZMacMachineIdentifier().dataRepresentation,
// A placeholder: the base image is never booted on a slot. Every
// clone rewrites this with its slot's persistent MAC, which is
// what DHCP lease discovery keys on. It still has to be a valid
// locally-administered address, because the base image *is*
// booted once, here, for provisioning.
macAddress: VZMACAddress.randomLocallyAdministered().string,
diskFormat: diskFormat,
cpuCount: cpuCount,
memoryGB: memoryGB,
guestUsername: config.guest.username,
macOSVersion:
"\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion) "
+ "(\(restoreImage.buildVersion))",
provisioned: false
)
try bundle.saveConfig(bundleConfig)
} catch {
// A bundle that got partway through creation is not something a later
// run can recover from, and leaving it behind would make `build` with
// the same name fail on the "already exists" check for the wrong
// reason.
try? bundle.destroy()
throw error
}
return bundle
}
/// Creates the backing disk, preferring sparse ASIF.
///
/// ASIF (`diskutil image create blank --fs none --format ASIF`) is available
/// from macOS 26 and is the right choice here: it is sparse, so a 64 GB
/// nominal disk costs what the guest actually writes, and it CoW-clones
/// cleanly on APFS. If `diskutil` fails for any reason, a sparse RAW file is
/// created instead and the format recorded in the bundle config so
/// ``VZConfigFactory`` attaches the right file.
///
/// - Parameters:
/// - bundle: Destination bundle.
/// - sizeGB: Nominal disk size.
/// - Returns: The format that was actually used.
public func createDisk(bundle: VMBundle, sizeGB: Int) throws -> VMBundleConfig.DiskFormat {
guard sizeGB > 0 else {
throw CoreError.configInvalid("guest.diskGB must be positive, got \(sizeGB)")
}
let asifURL = bundle.asifDiskURL
try? FileManager.default.removeItem(at: asifURL)
// No `#available` guard: the package's deployment target is already
// macOS 26, so the compiler would reject the check as redundant. The
// runtime feature check that matters is whether *this* diskutil
// understands `--format ASIF`, which the exit status answers directly —
// that also covers early 26 builds where the format was still landing.
do {
try ImageBuilder.runProcess(
"/usr/sbin/diskutil",
[
"image", "create", "blank",
"--fs", "none",
"--format", "ASIF",
"--size", "\(sizeGB)G",
asifURL.path,
]
)
// diskutil occasionally appends its own extension; accept either
// spelling rather than failing on a cosmetic difference.
if !FileManager.default.fileExists(atPath: asifURL.path) {
let suffixed = URL(fileURLWithPath: asifURL.path + ".asif")
if FileManager.default.fileExists(atPath: suffixed.path) {
try FileManager.default.moveItem(at: suffixed, to: asifURL)
}
}
if FileManager.default.fileExists(atPath: asifURL.path) {
return .asif
}
} catch {
// Fall through to RAW.
}
try? FileManager.default.removeItem(at: asifURL)
// RAW fallback: an empty file extended to the nominal size. APFS keeps it
// sparse, so this costs nothing until the guest writes. Sizes are decimal
// GB (1000³) to match what `diskutil … --size NG` produces, so switching
// formats does not silently change the guest's disk size.
let rawURL = bundle.rawDiskURL
try? FileManager.default.removeItem(at: rawURL)
guard FileManager.default.createFile(atPath: rawURL.path, contents: nil) else {
throw CoreError.provisioningFailed("could not create the disk image at \(rawURL.path)")
}
let handle = try FileHandle(forWritingTo: rawURL)
defer { try? handle.close() }
do {
try handle.truncate(atOffset: UInt64(sizeGB) * 1_000_000_000)
} catch {
throw CoreError.provisioningFailed(
"could not size the disk image at \(rawURL.path) to \(sizeGB) GB: \(error.localizedDescription)"
)
}
return .raw
}
/// Runs `VZMacOSInstaller` to completion.
///
/// - Parameters:
/// - bundle: The bundle to install into.
/// - restoreImage: The loaded IPSW.
/// - progress: Called with the installer's completed fraction.
public func install(
bundle: VMBundle,
restoreImage: VZMacOSRestoreImage,
progress: (@Sendable (Double) -> Void)? = nil
) async throws {
let imageURL = restoreImage.url
guard imageURL.isFileURL else {
// The image returned by `VZMacOSRestoreImage.latestSupported` carries
// a CDN URL. Handing that to the installer fails deep inside the
// framework; catching it here names the actual mistake.
throw CoreError.configInvalid(
"VZMacOSInstaller needs a local restore image, but this one points at "
+ "\(imageURL.absoluteString). Download it first with IPSWProvider.download."
)
}
let configuration = try VZConfigFactory.makeConfiguration(bundle: bundle, headless: true)
// Everything about VZMacOSInstaller is queue-bound: the VM must be
// created on a queue, the installer must be *constructed* on that same
// queue with the VM stopped, and `install` must be *called* on it too.
// The VM is also created here rather than through VMInstance because the
// installer needs the VZVirtualMachine object itself, and because the VM
// must never be started, paused, or stopped while installing — behaviour
// VMInstance exists to provide and which would be actively harmful here.
let queue = DispatchQueue(label: "gitea-macos-runner.install.\(bundle.name)")
let session = InstallSession()
let boxedConfiguration = UncheckedBox(configuration)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
queue.async {
let virtualMachine = VZVirtualMachine(
configuration: boxedConfiguration.value,
queue: queue
)
let installer = VZMacOSInstaller(
virtualMachine: virtualMachine,
restoringFromImageAt: imageURL
)
// Held for the duration: the completion handler is the only other
// strong reference, and dropping the VM mid-install would be a
// use-after-free rather than a cancellation.
session.virtualMachine = virtualMachine
session.installer = installer
if let progress {
session.observation = installer.progress.observe(
\.fractionCompleted,
options: [.initial, .new]
) { observed, _ in
progress(observed.fractionCompleted)
}
}
installer.install { result in
session.observation = nil
session.installer = nil
session.virtualMachine = nil
switch result {
case .success:
progress?(1.0)
continuation.resume()
case .failure(let error):
continuation.resume(throwing: VMInstance.mapVZError(error))
}
}
}
}
}
/// Boots the freshly installed guest, gets it onto the network, and hands it
/// to ``GuestProvisioner``.
///
/// ## Setup Assistant
///
/// A newly installed macOS sits at Setup Assistant with no account and no
/// SSH. On a **macOS 27+ host with a macOS 27+ guest**, Virtualization can
/// automate that: build a `VZMacGuestProvisioningOptions` carrying the
/// configured username, password, and full name, with
/// `logsInAutomatically = true` and `enablesRemoteLogin = true`, and attach
/// it to the start options via
/// `VZMacOSVirtualMachineStartOptions.setGuestProvisioning(_:)` — the ObjC
/// selector is `setGuestProvisioningOptions:error:`, but Swift imports it
/// under the shorter name. The guest then creates the account and enables
/// SSH unattended.
///
/// - Important: An **older guest silently ignores** these options — no
/// error, no account, no SSH, and this method will simply time out waiting
/// for a lease or a login. When that happens the only recovery is a
/// manual, GUI-driven first boot, which is deliberately **out of v1
/// scope**: this method fails with an explanatory
/// ``CoreError/provisioningFailed(_:)`` telling the operator that a
/// `--manual-setup` flow is not implemented and that the IPSW must be
/// macOS 27 or newer.
///
/// The API itself is gated at `#available(macOS 27.0, *)`, so a macOS 26
/// host takes the same explanatory failure path.
///
/// - Parameters:
/// - bundle: The installed bundle.
/// - config: Guest credentials and timeouts.
/// - progress: Stage callback.
public func firstBootAndProvision(
bundle: VMBundle,
config: RunnerConfig,
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
) async throws {
guard #available(macOS 27.0, *) else {
throw CoreError.hostUnsupported(
"""
automating Setup Assistant requires macOS 27 or newer on the host; this host is older. \
Without it the freshly installed guest sits at the setup screen forever, with no \
account and no SSH. A manual, GUI-driven first boot is not implemented in v1.
"""
)
}
let provisioningOptions = VZMacGuestProvisioningOptions()
provisioningOptions.username = config.guest.username
provisioningOptions.password = config.guest.password
provisioningOptions.fullName = ImageBuilder.guestAccountFullName
// Auto-login keeps a GUI session alive, which codesign against the login
// keychain and the simulators both need.
provisioningOptions.logsInAutomatically = true
// This is what turns on sshd — the only channel provisioning has.
provisioningOptions.enablesRemoteLogin = true
let startOptions = VZMacOSVirtualMachineStartOptions()
do {
// The validating setter: it rejects, for instance, a password that
// the guest's account policy will not accept, here rather than by
// quietly producing a guest with no usable account.
try startOptions.setGuestProvisioning(provisioningOptions)
} catch {
throw CoreError.configInvalid(
"guest provisioning options were rejected (check guest.username / guest.password): "
+ error.localizedDescription
)
}
try await bootProvisionAndSeal(
bundle: bundle,
config: config,
startOptions: startOptions,
isFirstBoot: true,
xcodeXIPPath: nil,
progress: progress
)
}
/// Re-runs guest provisioning against an already-installed image.
///
/// Backs `image provision NAME`, which exists so that bumping the
/// `gitea-runner` version or adding Xcode does not require a 15 GB
/// reinstall.
///
/// - Parameters:
/// - name: Image name.
/// - config: Guest credentials and download URLs.
/// - xcodeXIPPath: Optional Xcode `.xip` to install as well.
/// - progress: Stage callback.
public func reprovision(
name: String,
config: RunnerConfig,
xcodeXIPPath: String? = nil,
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
) async throws {
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
guard FileManager.default.fileExists(atPath: bundleURL.path) else {
throw CoreError.notFound("image '\(name)' at \(bundleURL.path)")
}
let bundle = VMBundle(rootURL: bundleURL)
if let xcodeXIPPath {
let expanded = (xcodeXIPPath as NSString).expandingTildeInPath
guard FileManager.default.fileExists(atPath: expanded) else {
throw CoreError.notFound("Xcode .xip at \(expanded)")
}
}
// No start options: the account already exists, so there is nothing for
// Setup Assistant automation to do, and re-applying it on a guest that is
// already past first boot has no effect anyway (macOS only evaluates
// guest provisioning on the first boot after a restore).
try await bootProvisionAndSeal(
bundle: bundle,
config: config,
startOptions: nil,
isFirstBoot: false,
xcodeXIPPath: xcodeXIPPath,
progress: progress
)
}
// MARK: - Boot, provision, seal
/// The shared tail of ``firstBootAndProvision(bundle:config:progress:)`` and
/// ``reprovision(name:config:xcodeXIPPath:progress:)``: boot, find the guest
/// on the network, provision it, shut it down cleanly, mark it provisioned.
private func bootProvisionAndSeal(
bundle: VMBundle,
config: RunnerConfig,
startOptions: VZMacOSVirtualMachineStartOptions?,
isFirstBoot: Bool,
xcodeXIPPath: String?,
progress: (@Sendable (ImageBuildStage) -> Void)?
) async throws {
var bundleConfig = try bundle.loadConfig()
let macAddress = bundleConfig.macAddress
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
progress?(.firstBoot)
let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true)
do {
try await instance.start(options: startOptions)
} catch {
throw CoreError.provisioningFailed(
"could not boot image '\(bundle.name)': \(error)"
)
}
let address: String
do {
// A DHCP lease is the first observable sign of life: the guest has
// booted far enough to bring up its NIC. SSH comes tens of seconds
// later, once launchd has started sshd.
address = try await ImageBuilder.waitForDHCPLease(macAddress: macAddress, timeout: bootTimeout)
try await waitForSSH(
host: address,
username: config.guest.username,
password: config.guest.password,
timeout: bootTimeout
)
} catch {
_ = await instance.requestStopThenForce()
if isFirstBoot {
// The most likely cause by far, and the one with no diagnostic of
// its own: a pre-27 guest accepts the provisioning options and
// ignores them, so it sits at Setup Assistant with no account and
// no sshd while we wait for a login that will never be possible.
throw CoreError.provisioningFailed(
"""
the guest never became reachable over SSH within \(config.scheduler.bootTimeoutSeconds)s.
The usual cause is a guest older than macOS 27: earlier versions do not implement \
the automated setup protocol and silently ignore the provisioning options, leaving \
the VM parked at Setup Assistant with no account and no Remote Login. Rebuild with \
a macOS 27 or newer restore image.
A manual, GUI-driven first boot is not implemented in v1.
Underlying error: \(error)
"""
)
}
throw CoreError.provisioningFailed(
"image '\(bundle.name)' booted but never became reachable over SSH: \(error)"
)
}
let executor = SSHExecutor(
host: address,
username: config.guest.username,
password: config.guest.password
)
do {
let provisioner = GuestProvisioner()
try await provisioner.provision(executor: executor, config: config) { step in
progress?(.provisioning(step: step))
}
if let xcodeXIPPath {
progress?(.provisioning(step: "Xcode"))
try await provisioner.installXcode(
executor: executor,
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
)
}
} catch {
await executor.close()
_ = await instance.requestStopThenForce()
throw error
}
// Shut down from inside. A forced stop is a power cut: it leaves the
// guest's filesystem in whatever state it was in, and every clone would
// inherit that state, so the graceful path is worth waiting for.
progress?(.finalizing)
_ = try? await executor.run("sudo -n /sbin/shutdown -h now", timeout: .seconds(30))
await executor.close()
let stopped = await ImageBuilder.withTimeout(.seconds(180)) {
await instance.waitUntilStopped()
}
if stopped == nil {
_ = await instance.requestStopThenForce()
}
bundleConfig.provisioned = true
try bundle.saveConfig(bundleConfig)
}
// MARK: - Helpers
/// Full name for the account Setup Assistant automation creates.
static let guestAccountFullName = "Gitea Runner"
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
///
/// - Parameters:
/// - macAddress: The bundle's MAC, in any common formatting.
/// - timeout: Overall ceiling.
/// - pollInterval: Delay between reads. Defaults to 2 s.
/// - Returns: The leased IP address.
/// - Throws: ``CoreError/timeout(_:)`` if no lease appears in time.
static func waitForDHCPLease(
macAddress: String,
timeout: Duration,
pollInterval: Duration = .seconds(2)
) async throws -> String {
let started = ContinuousClock.now
while true {
let leases = DHCPLeaseParser.parseFile()
if let address = DHCPLeaseParser.ipAddress(forMAC: macAddress, in: leases) {
return address
}
guard ContinuousClock.now - started < timeout else { break }
try await Task.sleep(for: pollInterval)
guard ContinuousClock.now - started < timeout else { break }
}
throw CoreError.timeout("no DHCP lease for \(macAddress) in /var/db/dhcpd_leases")
}
/// Runs an async operation with a ceiling, returning `nil` if it elapses.
///
/// Used for the graceful-shutdown wait, which otherwise has no bound:
/// `waitUntilStopped()` waits forever, and a guest that hangs on shutdown
/// would hang the build with it.
static func withTimeout<T: Sendable>(
_ duration: Duration,
operation: @escaping @Sendable () async -> T
) async -> T? {
await withTaskGroup(of: Optional<T>.self) { group in
group.addTask { await operation() }
group.addTask {
try? await Task.sleep(for: duration)
return nil
}
let first = await group.next() ?? nil
group.cancelAll()
return first
}
}
/// Runs a host process and throws with its output if it exits non-zero.
@discardableResult
static func runProcess(_ executablePath: String, _ arguments: [String]) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
throw CoreError.processFailed(
command: "\(executablePath) \(arguments.joined(separator: " "))",
exitCode: -1,
output: error.localizedDescription
)
}
// Drained before waiting: a command that outfills the pipe buffer would
// block forever otherwise.
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(decoding: data, as: UTF8.self)
guard process.terminationStatus == 0 else {
throw CoreError.processFailed(
command: "\(executablePath) \(arguments.joined(separator: " "))",
exitCode: process.terminationStatus,
output: output
)
}
return output
}
}
// MARK: - Install plumbing
/// Carries a non-`Sendable` Virtualization object onto the VM's serial queue.
///
/// The framework's configuration objects are not `Sendable` and never will be,
/// but handing one to the queue that will own the VM is exactly the transfer the
/// framework itself prescribes.
private final class UncheckedBox<T>: @unchecked Sendable {
let value: T
init(_ value: T) { self.value = value }
}
/// Owns the VM, installer, and KVO observation for one install.
///
/// Every field is read and written only on the install queue, which is what
/// makes the unchecked conformance sound.
private final class InstallSession: @unchecked Sendable {
var virtualMachine: VZVirtualMachine?
var installer: VZMacOSInstaller?
var observation: NSKeyValueObservation?
}