Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ enum ForgePlatformInstallerCLIApplication {
InstallerCLIHelperRegistration.liveRegistrar,
replaceQualificationHelper: @escaping InstallerCLIHelperRegistration.Registrar =
InstallerCLIHelperRegistration.liveQualificationReplacer,
replaceIdleHelper: @escaping InstallerCLIHelperRegistration.Registrar =
InstallerCLIHelperRegistration.liveIdleReplacer,
challengeWriter: ChallengeWriter = writeChallengeToTTY,
stdout: Writer,
stderr: Writer
Expand Down Expand Up @@ -208,6 +210,16 @@ enum ForgePlatformInstallerCLIApplication {
register: replaceQualificationHelper,
replacingQualification: true
)
case .helperReplaceIdle:
result = await InstallerCLIHelperRegistration.run(
startup: startup,
currentVersion: currentVersion,
currentRelease: currentRelease,
options: invocation.options,
confirm: confirm,
register: replaceIdleHelper,
replacingOlder: true
)
case .deploymentList:
result = await workflow.listDeployments()
case .deploymentPlan(let deployment):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,42 @@ enum InstallerCLIHelperRegistration {
typealias Registrar = @Sendable (InstallerVersion) async -> ManagedInstallerPrivilegedHelperRegistrationResult

static let liveRegistrar: Registrar = { expectedVersion in
let service = MacOSManagedInstallerPrivilegedHelperServiceController()
guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production(
service: service
) else { return .failed(.transitionBusy) }
guard let coordinator = liveCoordinator() else { return .failed(.transitionBusy) }
return await coordinator.ensureRegistered(expectedVersion: expectedVersion)
}

static let liveQualificationReplacer: Registrar = { expectedVersion in
let service = MacOSManagedInstallerPrivilegedHelperServiceController()
guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production(
service: service
) else { return .failed(.transitionBusy) }
guard let coordinator = liveCoordinator() else { return .failed(.transitionBusy) }
return await coordinator.replaceLegacyQualification(expectedVersion: expectedVersion)
}

static let liveIdleReplacer: Registrar = { expectedVersion in
guard let coordinator = liveCoordinator() else { return .failed(.transitionBusy) }
return await coordinator.replaceIdleOlderRegistration(expectedVersion: expectedVersion)
}

private static func liveCoordinator() -> ManagedInstallerPrivilegedHelperRegistrationCoordinator? {
let service = MacOSManagedInstallerPrivilegedHelperServiceController()
return ManagedInstallerPrivilegedHelperRegistrationCoordinator.production(service: service)
}

static func run(
startup: any InstallerCLIStarting,
currentVersion: InstallerVersion,
currentRelease: VerifiedInstallerRelease,
options: InstallerCLIOptions,
confirm: ForgePlatformInstallerCLIApplication.Confirmation,
register: Registrar,
replacingQualification: Bool = false
replacingQualification: Bool = false,
replacingOlder: Bool = false
) async -> InstallerCLIResult {
if !options.assumeYes {
let accepted = options.nonInteractive ? false : await confirm(
replacingQualification
? "Vervang de exacte inactieve 0.2.4-kwalificatiehelper door de geverifieerde systeemhelper?"
: "Registreer de geverifieerde installer-helper als systeemdaemon?"
: replacingOlder
? "Vervang de exacte inactieve oudere installer-helper door de geverifieerde actuele systeemhelper?"
: "Registreer de geverifieerde installer-helper als systeemdaemon?"
)
if !accepted {
return InstallerCLIResult(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public enum InstallerCLICommand: Equatable, Sendable {
case status
case helperRegister
case helperReplaceQualification
case helperReplaceIdle
case selfUpdateCheck
case selfUpdateApply
case deploymentList
Expand Down Expand Up @@ -115,6 +116,7 @@ public enum InstallerCLIParser {
forge-platform-installer status [--json]
forge-platform-installer helper register [--yes] [--non-interactive] [--json]
forge-platform-installer helper replace-qualification [--yes] [--non-interactive] [--json]
forge-platform-installer helper replace-idle [--yes] [--non-interactive] [--json]
forge-platform-installer self-update check [--json]
forge-platform-installer self-update apply [--yes] [--json]
forge-platform-installer deployment list [--json]
Expand Down Expand Up @@ -241,6 +243,8 @@ public enum InstallerCLIParser {
command = .helperRegister
case ["helper", "replace-qualification"]:
command = .helperReplaceQualification
case ["helper", "replace-idle"]:
command = .helperReplaceIdle
case ["self-update", "check"]:
command = .selfUpdateCheck
case ["self-update", "apply"]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,22 +313,51 @@ public actor ManagedInstallerPrivilegedHelperRegistrationCoordinator {
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
withRegistrationLock {
replaceLegacyQualificationWhileLocked(expectedVersion: expectedVersion)
guard let legacy = try? InstallerVersion("0.2.4") else {
return .failed(.registeredParentMismatch)
}
return replaceIdleParentWhileLocked(
expectedVersion: expectedVersion,
minimumPriorVersion: legacy,
maximumPriorVersion: legacy
)
}
}

private func replaceLegacyQualificationWhileLocked(
/// Upgrade a previously released, signed installer helper only while its
/// fixed ServiceManagement job has never run since boot. The sealed CLI
/// supplies the target version; no request can choose a label or parent.
public func replaceIdleOlderRegistration(
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
withRegistrationLock {
guard let firstSupported = try? InstallerVersion("0.3.7") else {
return .failed(.registeredParentMismatch)
}
return replaceIdleParentWhileLocked(
expectedVersion: expectedVersion,
minimumPriorVersion: firstSupported,
maximumPriorVersion: nil
)
}
}

private func replaceIdleParentWhileLocked(
expectedVersion: InstallerVersion,
minimumPriorVersion: InstallerVersion,
maximumPriorVersion: InstallerVersion?
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
let initialStatus = service.readStatus()
guard let legacy = try? InstallerVersion("0.2.4"),
legacy < expectedVersion,
guard let prior = service.readIdleRegisteredParentVersion(),
prior >= minimumPriorVersion,
maximumPriorVersion.map({ prior <= $0 }) ?? true,
prior < expectedVersion,
initialStatus != .requiresApproval,
service.readRegisteredParentVersion() == legacy,
service.readIdleRegisteredParentVersion() == legacy,
service.readRegisteredParentVersion() == prior,
service.readIdleRegisteredParentVersion() == prior,
service.readStatus() == initialStatus,
service.readRegisteredParentVersion() == legacy,
service.readIdleRegisteredParentVersion() == legacy else {
service.readRegisteredParentVersion() == prior,
service.readIdleRegisteredParentVersion() == prior else {
return .failed(.registeredParentMismatch)
}
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,76 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase {
XCTAssertTrue(mismatch.stdout.joined().contains("registered-parent-mismatch"))
}

func testIdleOlderHelperReplacementRequiresConsentAndExactNativeResult() async throws {
let current = try release("1.2.3")
let registrar = CLIHelperRegistrarSpy(result: .ready(
try ManagedInstallerPrivilegedHelperRegistrationReceipt(status: .enabled)
))
let startup = CLIStartupSpy(
outcome: .ready(currentRelease: current, coordinator: CLIReadyCoordinator())
)
let denied = await run(
["helper", "replace-idle", "--non-interactive"],
startup: startup, version: "1.2.3",
idleReplacement: { _ in await registrar.invoke() }
)
XCTAssertEqual(denied.code, InstallerCLIExitCode.confirmationRequired.rawValue)
let callsAfterDenial = await registrar.calls()
XCTAssertEqual(callsAfterDenial, 0)

let replaced = await run(
["helper", "replace-idle", "--yes", "--non-interactive", "--json"],
startup: startup, version: "1.2.3",
idleReplacement: { _ in await registrar.invoke() }
)
XCTAssertEqual(replaced.code, InstallerCLIExitCode.success.rawValue)
XCTAssertTrue(replaced.stdout.joined().contains("helper-enabled"))
let callsAfterReplacement = await registrar.calls()
XCTAssertEqual(callsAfterReplacement, 1)

let interactive = await run(
["helper", "replace-idle", "--json"],
startup: startup, version: "1.2.3",
idleReplacement: { _ in await registrar.invoke() }
)
XCTAssertEqual(interactive.code, InstallerCLIExitCode.success.rawValue)
let callsAfterInteractive = await registrar.calls()
XCTAssertEqual(callsAfterInteractive, 2)

let approvalReceipt = try ManagedInstallerPrivilegedHelperRegistrationReceipt(
status: .requiresApproval
)
let approval = await run(
["helper", "replace-idle", "--yes", "--json"],
startup: startup, version: "1.2.3",
idleReplacement: { _ in .requiresApproval(approvalReceipt) }
)
XCTAssertEqual(approval.code, InstallerCLIExitCode.interactionRequired.rawValue)
XCTAssertTrue(approval.stdout.joined().contains("REQUIRES_APPROVAL"))

let newer = try release("1.2.4")
let stale = await run(
["helper", "replace-idle", "--yes", "--json"],
startup: CLIStartupSpy(
outcome: .ready(currentRelease: current, coordinator: CLIReadyCoordinator()),
recheckOutcome: .updateRequired(newer)
),
version: "1.2.3",
idleReplacement: { _ in await registrar.invoke() }
)
XCTAssertEqual(stale.code, InstallerCLIExitCode.installerUpdateRequired.rawValue)
let callsAfterStale = await registrar.calls()
XCTAssertEqual(callsAfterStale, 2)

let mismatch = await run(
["helper", "replace-idle", "--yes", "--json"],
startup: startup, version: "1.2.3",
idleReplacement: { _ in .failed(.registeredParentMismatch) }
)
XCTAssertEqual(mismatch.code, InstallerCLIExitCode.executionFailed.rawValue)
XCTAssertTrue(mismatch.stdout.joined().contains("registered-parent-mismatch"))
}

func testHelperRegistrationPreservesApprovalFailureAndReleaseDrift() async throws {
let current = try release("1.2.3")
let next = try release("1.2.4")
Expand Down Expand Up @@ -692,6 +762,9 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase {
},
replacement: @escaping InstallerCLIHelperRegistration.Registrar = { _ in
.failed(.serviceUnavailable)
},
idleReplacement: @escaping InstallerCLIHelperRegistration.Registrar = { _ in
.failed(.serviceUnavailable)
}
) async -> (code: Int32, stdout: [String], stderr: [String]) {
let output = LockedStrings()
Expand All @@ -706,6 +779,7 @@ final class ForgePlatformInstallerCLIApplicationTests: XCTestCase {
confirm: { _ in confirmation },
registerHelper: registration,
replaceQualificationHelper: replacement,
replaceIdleHelper: idleReplacement,
stdout: { output.append($0) },
stderr: { errors.append($0) }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ final class InstallerCLITests: XCTestCase {
try InstallerCLIParser.parse(["helper", "replace-qualification", "--yes"]).command,
.helperReplaceQualification
)
XCTAssertEqual(
try InstallerCLIParser.parse(["helper", "replace-idle", "--yes"]).command,
.helperReplaceIdle
)
XCTAssertThrowsError(try InstallerCLIParser.parse([
"helper", "replace-idle", "--deployment", "another-target",
]))
XCTAssertEqual(try InstallerCLIParser.parse(["self-update", "check"]).command, .selfUpdateCheck)
XCTAssertEqual(try InstallerCLIParser.parse(["self-update", "apply"]).command, .selfUpdateApply)
XCTAssertEqual(try InstallerCLIParser.parse(["deployment", "list"]).command, .deploymentList)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,47 @@ final class ManagedInstallerPrivilegedHelperRegistrationTests: XCTestCase {
XCTAssertEqual(interrupted.counts(), [1, 2])
}

func testIdleSignedOlderReleaseTransitionRequiresExactMonotonicParent() async throws {
let current = try InstallerVersion("0.3.8")
let service = LegacyTransitionService(oldVersion: "0.3.7", newVersion: "0.3.8")
let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator(service: service)
let first = await coordinator.replaceIdleOlderRegistration(expectedVersion: current)
XCTAssertNotNil(first.readyReceipt)
XCTAssertEqual(service.counts(), [1, 1])
let duplicate = await coordinator.replaceIdleOlderRegistration(expectedVersion: current)
XCTAssertEqual(duplicate.failure, .registeredParentMismatch)
XCTAssertEqual(service.counts(), [1, 1])

for candidate in [
LegacyTransitionService(idle: false, oldVersion: "0.3.7"),
LegacyTransitionService(oldVersion: "0.3.6"),
LegacyTransitionService(oldVersion: "0.3.8"),
LegacyTransitionService(oldVersion: "0.3.9"),
LegacyTransitionService(oldVersion: "0.2.4"),
] {
let result = await ManagedInstallerPrivilegedHelperRegistrationCoordinator(
service: candidate
).replaceIdleOlderRegistration(expectedVersion: current)
XCTAssertEqual(result.failure, .registeredParentMismatch)
XCTAssertEqual(candidate.counts(), [0, 0])
}
}

func testIdleSignedOlderReleaseTransitionResumesAfterRegistrationFailure() async throws {
let current = try InstallerVersion("0.3.8")
let service = LegacyTransitionService(
oldVersion: "0.3.7", newVersion: "0.3.8", registerFails: true
)
let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator(service: service)
let interrupted = await coordinator.replaceIdleOlderRegistration(expectedVersion: current)
XCTAssertEqual(interrupted.failure, .registrationFailed)
XCTAssertEqual(service.counts(), [1, 1])
service.allowRegistration()
let resumed = await coordinator.ensureRegistered(expectedVersion: current)
XCTAssertNotNil(resumed.readyReceipt)
XCTAssertEqual(service.counts(), [1, 2])
}

func testRegistrationAndTransitionShareCrossProcessLease() async throws {
let version = try InstallerVersion("0.3.6")
let busy = RegistrationLockStub(failsAcquisition: true)
Expand Down Expand Up @@ -355,15 +396,17 @@ private final class LegacyTransitionService:
private let jobAbsentReadbackFails: Bool
private let idle: Bool
private let oldVersion: String
private let newVersion: String

init(idle: Bool = true, oldVersion: String = "0.2.4",
init(idle: Bool = true, oldVersion: String = "0.2.4", newVersion: String = "0.3.6",
unregisterFails: Bool = false, registerFails: Bool = false,
staysEnabledAfterUnregister: Bool = false,
notFoundAfterUnregister: Bool = false,
reportedStatusInitiallyNotRegistered: Bool = false,
jobAbsentReadbackFails: Bool = false) {
self.idle = idle
self.oldVersion = oldVersion
self.newVersion = newVersion
self.unregisterFails = unregisterFails
self.registerFails = registerFails
self.staysEnabledAfterUnregister = staysEnabledAfterUnregister
Expand All @@ -381,7 +424,7 @@ private final class LegacyTransitionService:
}

func readRegisteredParentVersion() -> InstallerVersion? {
lock.withLock { try? InstallerVersion(currentIsNew ? "0.3.6" : oldVersion) }
lock.withLock { try? InstallerVersion(currentIsNew ? newVersion : oldVersion) }
}

func readIdleRegisteredParentVersion() -> InstallerVersion? {
Expand Down
Loading