Skip to content

Commit 4bc3e42

Browse files
authored
Merge pull request #20 from Automattic/fix/vm-image-sync
Fix sync
2 parents 038a2fd + 135cbcd commit 4bc3e42

16 files changed

+606
-179
lines changed

.buildkite/pipeline.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ common_params:
44
- &bash_cache automattic/bash-cache#v1.3.2: ~
55
# Common environment values to use with the `env` key.
66
env: &common_env
7-
IMAGE_ID: xcode-12.5.1
7+
IMAGE_ID: xcode-13.4.1
88

99
# This is the default pipeline – it will build and test the app
1010
steps:

Sources/hostmgr/SyncCommand.swift

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,11 @@ struct SyncCommand: ParsableCommand {
1515
@Flag(help: "List available sync tasks")
1616
var list: Bool = false
1717

18-
@Flag(help: "Force the job to run immediately, ignoring the schedule")
19-
var force: Bool = false
20-
21-
@Argument
22-
var task: Configuration.SchedulableSyncCommand?
18+
@OptionGroup
19+
var options: SharedSyncOptions
2320

2421
func run() throws {
2522

26-
if let task = task {
27-
try perform(task: task, immediately: force)
28-
return
29-
}
30-
3123
if list {
3224
Configuration.SchedulableSyncCommand.allCases.forEach { print($0) }
3325
return
@@ -38,15 +30,19 @@ struct SyncCommand: ParsableCommand {
3830
try GenerateGitMirrorManifestTask().run()
3931

4032
try Configuration.shared.syncTasks.forEach { command in
41-
force ? print("Force-running \(command.rawValue)") : print("Running \(command.rawValue)")
42-
try perform(task: command, immediately: self.force)
33+
options.force ? print("Force-running \(command.rawValue)") : print("Running \(command.rawValue)")
34+
try perform(task: command, immediately: options.force)
4335
}
4436
}
4537

4638
private func perform(task: Configuration.SchedulableSyncCommand, immediately: Bool) throws {
4739
switch task {
48-
case .authorizedKeys: try SyncAuthorizedKeysTask().run(force: immediately)
49-
case .vmImages: try SyncVMImagesTask().run(force: immediately)
40+
case .authorizedKeys:
41+
let command = SyncAuthorizedKeysCommand(options: self._options)
42+
try command.run()
43+
case .vmImages:
44+
let command = SyncVMImagesCommand(options: self._options)
45+
try command.run()
5046
}
5147
}
5248
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import ArgumentParser
2+
3+
struct SharedSyncOptions: ParsableArguments {
4+
@Flag(help: "Force all jobs to run immediately, ignoring the schedule")
5+
var force: Bool = false
6+
}

Sources/hostmgr/commands/sync/SyncAuthorizedKeysCommand.swift

Lines changed: 12 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import ArgumentParser
33
import SotoS3
44
import libhostmgr
55

6-
struct SyncAuthorizedKeysCommand: ParsableCommand {
6+
struct SyncAuthorizedKeysCommand: ParsableCommand, FollowsCommandPolicies {
77

88
static let configuration = CommandConfiguration(
99
commandName: "authorized_keys",
@@ -34,40 +34,20 @@ struct SyncAuthorizedKeysCommand: ParsableCommand {
3434
)
3535
var destination: String = Configuration.shared.localAuthorizedKeys
3636

37-
func run() throws {
38-
try SyncAuthorizedKeysTask(bucket: bucket, region: region, key: key, destination: destination).run()
39-
}
40-
}
41-
42-
struct SyncAuthorizedKeysTask {
43-
44-
private let bucket: String
45-
private let region: Region
46-
private let key: String
47-
private let destination: String
48-
49-
init(
50-
bucket: String = Configuration.shared.authorizedKeysBucket,
51-
region: Region = Configuration.shared.authorizedKeysRegion,
52-
key: String = "authorized_keys",
53-
destination: String = Configuration.shared.localAuthorizedKeys
54-
) {
55-
self.bucket = bucket
56-
self.region = region
57-
self.key = key
58-
self.destination = destination
59-
}
37+
@OptionGroup
38+
var options: SharedSyncOptions
6039

61-
func run(force: Bool = false) throws {
62-
let state = State.get()
40+
static let commandIdentifier: String = "authorized-key-sync"
6341

64-
logger.debug("Downloading file from s3://\(bucket)/\(key) in \(region) to \(destination)")
42+
/// A set of command policies that control the circumstances under which this command can be run
43+
static let commandPolicies: [CommandPolicy] = [
44+
.scheduled(every: 3600)
45+
]
6546

66-
guard state.shouldRun && force else {
67-
print("This job is not scheduled to run until \(state.nextRunTime)")
68-
return
69-
}
47+
func run() throws {
48+
try to(evaluateCommandPolicies(), unless: options.force)
7049

50+
logger.debug("Downloading file from s3://\(bucket)/\(key) in \(region) to \(destination)")
7151
logger.trace("Job schedule allows for running")
7252

7353
guard let bytes = try S3Manager().getFileBytes(region: region, bucket: bucket, key: key) else {
@@ -89,29 +69,6 @@ struct SyncAuthorizedKeysTask {
8969
.posixPermissions: 0o600
9070
], ofItemAtPath: destination)
9171

92-
try State.set(state: State(lastRunAt: Date()))
93-
}
94-
95-
struct State: Codable {
96-
private static let key = "authorized-key-sync-state"
97-
var lastRunAt: Date = Date.distantPast
98-
99-
var shouldRun: Bool {
100-
let runInterval = TimeInterval(Configuration.shared.authorizedKeysSyncInterval)
101-
return self.lastRunAt < Date().addingTimeInterval(runInterval * -1)
102-
}
103-
104-
var nextRunTime: Date {
105-
let runInterval = TimeInterval(Configuration.shared.authorizedKeysSyncInterval)
106-
return self.lastRunAt.addingTimeInterval(runInterval)
107-
}
108-
109-
static func get() -> State {
110-
(try? StateManager.load(key: key)) ?? State()
111-
}
112-
113-
static func set(state: State) throws {
114-
try StateManager.store(key: key, value: state)
115-
}
72+
try recordLastRun()
11673
}
11774
}

Sources/hostmgr/commands/sync/SyncVMImagesCommand.swift

Lines changed: 21 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,26 @@ import SotoS3
44
import prlctl
55
import libhostmgr
66

7-
struct SyncVMImagesCommand: ParsableCommand {
7+
struct SyncVMImagesCommand: ParsableCommand, FollowsCommandPolicies {
88

99
static let configuration = CommandConfiguration(
1010
commandName: "vm_images",
1111
abstract: "Sync this machine's VM images with those avaiable remotely"
1212
)
1313

14-
func run() throws {
15-
try SyncVMImagesTask().run()
16-
}
17-
}
14+
@OptionGroup
15+
var options: SharedSyncOptions
1816

19-
struct SyncVMImagesTask {
17+
static let commandIdentifier: String = "sync-vm-images"
2018

21-
let storageDirectory = Configuration.shared.vmStorageDirectory
19+
/// A set of command policies that control the circumstances under which this command can be run
20+
static let commandPolicies: [CommandPolicy] = [
21+
.serialExecution,
22+
.scheduled(every: 3600)
23+
]
2224

23-
func run(force: Bool = false, state: State = State.get()) throws {
24-
guard !state.isRunning else {
25-
print("Already syncing VMs, so we won't try to run again")
26-
return
27-
}
28-
29-
guard state.shouldRun && force else {
30-
print("This job is not scheduled to run until \(state.nextRunTime)")
31-
return
32-
}
25+
func run() throws {
26+
try to(evaluateCommandPolicies(), unless: options.force)
3327

3428
/// The manifest defines which images should be distributed to VM hosts
3529
let manifest = try VMRemoteImageManager().getManifest()
@@ -53,31 +47,27 @@ struct SyncVMImagesTask {
5347
try VMLocalImageManager().delete(images: imagesToDelete)
5448

5549
try imagesToDownload.forEach {
56-
try download(image: $0, state: state)
50+
try download(image: $0)
5751
}
5852

59-
try State.set(state: State(lastRunAt: Date()))
53+
try recordLastRun()
6054
}
6155

62-
private func download(image: VMRemoteImageManager.RemoteImage, state: State = State.get()) throws {
56+
private func download(image: VMRemoteImageManager.RemoteImage) throws {
57+
let storageDirectory = Configuration.shared.vmStorageDirectory
6358
let destination = storageDirectory.appendingPathComponent(image.fileName)
6459

6560
logger.info("Downloading the VM – this will take a few minutes")
6661
logger.trace("Downloading \(image.basename) to \(destination)")
6762

63+
let limiter = Limiter(policy: .throttle, operationsPerSecond: 1)
64+
6865
try VMRemoteImageManager().download(image: image, to: destination) { _, downloaded, total in
69-
/// Only update the heartbeat every 5 seconds to avoid thrashing the disk
70-
guard abs(state.heartBeat.timeIntervalSinceNow) > 5 else {
71-
return
66+
limiter.perform {
67+
try? recordHeartbeat()
68+
let percent = String(format: "%.2f", Double(downloaded) / Double(total) * 100)
69+
logger.trace("\(percent)% complete")
7270
}
73-
74-
try? State.set(state: State(
75-
lastRunAt: state.lastRunAt,
76-
heartBeat: Date()
77-
))
78-
79-
let percent = String(format: "%.2f", Double(downloaded) / Double(total) * 100)
80-
logger.trace("\(percent)% complete")
8171
}
8272

8373
logger.info("Download Complete")
@@ -99,34 +89,4 @@ struct SyncVMImagesTask {
9989
logger.info("\tUUID:\t\(vmToImport.uuid)")
10090

10191
}
102-
103-
struct State: Codable {
104-
private static let key = "sync-vm-images-state"
105-
var lastRunAt: Date = Date.distantPast
106-
107-
var shouldRun: Bool {
108-
let runInterval = TimeInterval(Configuration.shared.authorizedKeysSyncInterval)
109-
return self.lastRunAt < Date().addingTimeInterval(runInterval * -1)
110-
}
111-
112-
var nextRunTime: Date {
113-
let runInterval = TimeInterval(Configuration.shared.authorizedKeysSyncInterval)
114-
return self.lastRunAt.addingTimeInterval(runInterval)
115-
}
116-
117-
var heartBeat: Date = Date.distantPast
118-
119-
// if we haven't hear from a job in 60 seconds, assume it's failed and we should try again
120-
var isRunning: Bool {
121-
abs(heartBeat.timeIntervalSinceNow) < 60
122-
}
123-
124-
static func get() -> State {
125-
(try? StateManager.load(key: key)) ?? State()
126-
}
127-
128-
static func set(state: State) throws {
129-
try StateManager.store(key: key, value: state)
130-
}
131-
}
13292
}

Sources/hostmgr/helpers/Foundation.swift

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,6 @@
11
import Foundation
22
import ArgumentParser
3-
4-
extension FileManager {
5-
6-
func createTemporaryFile(containing string: String = "") throws -> URL {
7-
let file = temporaryDirectory.appendingPathComponent(UUID().uuidString)
8-
try string.write(to: file, atomically: false, encoding: .utf8)
9-
return file
10-
}
11-
12-
func directoryExists(atUrl url: URL) -> Bool {
13-
var isDirectory: ObjCBool = false
14-
let exists = self.fileExists(atPath: url.path, isDirectory: &isDirectory)
15-
return exists && isDirectory.boolValue
16-
}
17-
18-
func createDirectoryTree(atUrl url: URL) throws {
19-
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
20-
}
21-
22-
func subpaths(at url: URL) -> [String] {
23-
self.subpaths(atPath: url.path) ?? []
24-
}
25-
26-
func displayName(at url: URL) -> String {
27-
displayName(atPath: url.path)
28-
}
29-
}
3+
import libhostmgr
304

315
extension URL: ExpressibleByArgument {
326
public init?(argument: String) {

Sources/hostmgr/helpers/S3.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ struct S3Manager {
9191
let estimatedDownloadSpeedInMBPS: Int64 = 10
9292
let minimalTimeout: Int64 = 60
9393
let timeout = max(totalMB / estimatedDownloadSpeedInMBPS, minimalTimeout)
94-
logger.info("Download timeout: \(timeout / 60) minutes")
94+
logger.info("Estimated Download Time: \(timeout / 60) minutes")
9595

9696
let s3Client = try getS3Client(from: client, for: bucket, in: region).with(timeout: .seconds(timeout))
9797
let objectRequest = S3.GetObjectRequest(bucket: bucket, key: key)

0 commit comments

Comments
 (0)