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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ It works two ways:
### Upgrading and uninstalling

`brew upgrade --cask renacode/tap/cloudmachine` replaces the app; the machine
keeps running. The cask in the tap can be a version behind the latest GitHub
Release: it is updated by a pull request after each release (see
keeps running. Each release updates the cask in the tap by itself (see
`packaging/README.md`). If a new version does not show up, run `brew update`
first - Homebrew refreshes the tap only now and then. When Homebrew reopens
the app, it reloads the background agents so they run the new version;
Expand Down
36 changes: 33 additions & 3 deletions mac-app/Sources/CloudMachineAgent/VMCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,40 @@ struct VMGuardCommand: AsyncParsableCommand {
)
var minFreeGiB: Int = VMGuard.Thresholds().minFreeGiB

@Flag(
name: .long,
help: ArgumentHelp(
L10n.tr(
"Only print what the guard would decide - for trying thresholds on a throwaway machine.")))
var dryRun = false

func run() async throws {
let guardian = VMGuard(
thresholds: .init(highGiB: highGiB, lowGiB: lowGiB, minFreeGiB: minFreeGiB))
await guardian.step()
let thresholds = VMGuard.Thresholds(highGiB: highGiB, lowGiB: lowGiB, minFreeGiB: minFreeGiB)
guard dryRun else {
await VMGuard(thresholds: thresholds).step()
return
}
// Time Machine is global: a guard tried on `cmtest` would pause the live
// machine's backup. Print the decision for every state it depends on.
guard let reading = await VMLayout.currentReading() else {
print(L10n.tr("No answer from the machine (rclone rc) - the guard would do nothing."))
throw ExitCode.failure
}
let unsent = reading.buffer.unsentBytes.map { "\($0 / VMGuard.gib)" } ?? "?"
print(
L10n.tr(
"Unsent %@ GiB, free %@ GiB (pause above %@ GiB unsent or below %@ GiB free, resume below %@ GiB).",
unsent, "\(reading.freeBytes / VMGuard.gib)", "\(highGiB)", "\(minFreeGiB)",
"\(lowGiB)"))
for (running, paused) in [(true, false), (true, true), (false, true)] {
let action = VMGuard.decide(
reading: reading, backupRunning: running, pausedByGuard: paused, thresholds: thresholds)
print(
L10n.tr(
"Backup %@, paused by the guard %@: %@",
running ? L10n.tr("running") : L10n.tr("stopped"),
paused ? L10n.tr("yes") : L10n.tr("no"), "\(action)"))
}
}
}

Expand Down
43 changes: 43 additions & 0 deletions mac-app/Sources/CloudMachineCore/BackupHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ public enum BackupHealth {
machineFreeGB: Int? = nil,
punchfsNearFileLimit: Bool = false,
remoteControlOpen: Bool = false,
// Seconds since the last copy of the image's metadata to Drive
// (`.infinity` = never); `nil` = not known or nothing to copy yet.
metaSyncAge: TimeInterval? = nil,
// Seconds since Google's daily upload limit last refused an upload, while
// it still holds (`VMStatus.Report.uploadLimitAge`); `nil`/0 = it does not.
uploadLimitAge: TimeInterval? = nil,
maxAgeHours: Double = BackupHealth.maxAgeHours,
// Whether the startup grace period is in progress - see `startupGraceMinutes`.
withinStartupGrace: Bool = false,
Expand Down Expand Up @@ -359,6 +365,39 @@ public enum BackupHealth {
code: "rc-port-open"))
}

// The bands on Drive cannot be opened without the image's metadata, which
// otherwise exists only on the machine's disk - kept out of Time Machine.
// A stale copy is a backup that cannot be restored after losing the Mac,
// while every other light is green. Deferred after startup: the stamp
// keeps its age across a night with the Mac off, and the timer's first
// run comes 5 min after the machine boots.
if let metaSyncAge, metaSyncAge > VMLayout.metaSyncMaxAge {
notReadyYet(
Problem(
summary: metaSyncAge.isFinite
? L10n.tr(
"The image's metadata was last copied to Drive %@ ago", formatAge(metaSyncAge))
: L10n.tr("The image's metadata has never been copied to Drive"),
detail: L10n.tr(
"Info.plist, token and mapped/ of the backup image exist only on the machine's disk; without them the bands in %@ cannot be restored. The copy (tm-meta-sync, every 15 min) goes to %@. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u tm-meta-sync",
VMLayout.remote, VMLayout.metaRemote),
code: "metadata-sync-stale"))
}

// Not a failure that needs fixing - it passes by itself - but while it
// lasts every "successful" backup exists only on this Mac. Without this
// the panel stayed green: Time Machine finishes into the machine's
// buffer, and the guard pauses only at 110 GiB.
if let uploadLimitAge, uploadLimitAge > 0 {
problems.append(
Problem(
summary: L10n.tr("Google Drive's daily upload limit stops the upload"),
detail: L10n.tr(
"Google last refused an upload %@ ago (750 GB a day). Backups since then are only in the machine's buffer on this Mac. rclone retries every 5 min and resumes by itself when the limit passes; the guard pauses Time Machine if more than %@ GiB waits.",
formatAge(uploadLimitAge), "\(VMGuard.Thresholds().highGiB)"),
code: "drive-upload-limit"))
}

// Space on the Drive. Running out of it stops every upload, and the
// machine's buffer then fills up - this has to be known EARLIER, not from
// a failure. The threshold is counted in cycles, not in percent: at a
Expand Down Expand Up @@ -564,6 +603,10 @@ public enum BackupHealth {
machineFreeGB: reading.map { Int($0.freeBytes / VMGuard.gib) },
punchfsNearFileLimit: nearFileLimit,
remoteControlOpen: VMStatus.remoteControlOpenOnMac(),
metaSyncAge: report.flatMap {
VMStatus.metaSyncOverdue($0) != nil ? $0.metaSync?.ageSeconds : nil
},
uploadLimitAge: report?.uploadLimitAge,
maxAgeHours: maxAgeHours,
// The REAL clock, not `now`: tests substitute a `now` from the past, and
// uptime computed from it would come out negative, i.e. "startup in
Expand Down
10 changes: 10 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+Machine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ extension L10nPolish {
"Zainstaluj agentów w tle (start maszyny, strażnik bufora, czujka kopii)",
"Installs rclone through Homebrew - CloudMachine itself uses the one from install-rclone.":
"Instaluje rclone przez Homebrew - sam CloudMachine używa tego z install-rclone.",
"Info.plist, token and mapped/ of the backup image exist only on the machine's disk; without them the bands in %@ cannot be restored. The copy (tm-meta-sync, every 15 min) goes to %@. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u tm-meta-sync":
"Info.plist, token i mapped/ obrazu kopii są tylko na dysku maszyny; bez nich pasm w %@ nie da się odtworzyć. Kopia (tm-meta-sync, co 15 min) trafia do %@. Sprawdź: cloudmachine-agent vm-status, potem w maszynie: journalctl -u tm-meta-sync",
"Lima says the machine is running, but a command in it did not return. Without it nothing is known about Samba, rclone or the upload. Check: cloudmachine-agent vm-status":
"Lima mówi, że maszyna działa, ale polecenie w niej nie wróciło. Bez tego nic nie wiadomo o Sambie, rclone ani wysyłce. Sprawdź: cloudmachine-agent vm-status",
"Machine":
Expand Down Expand Up @@ -132,6 +134,10 @@ extension L10nPolish {
"Maszyna jest zatrzymana. rclone nie powiedział, ile zostało do wysłania; to, co nie wyszło, zostaje w buforze maszyny i zostanie wysłane po następnym starcie. Można zrestartować Maca.",
"The machine's disk is a file on this disk and grows as the upload buffer fills. With no space left the machine cannot write the buffer, and Time Machine's local snapshots have nowhere to go either.":
"Dysk maszyny to plik na tym dysku i rośnie razem z buforem wysyłki. Bez wolnego miejsca maszyna nie zapisze bufora, a lokalne migawki Time Machine też nie mają gdzie trafić.",
"The image's metadata has never been copied to Drive":
"Metadane obrazu kopii nigdy nie trafiły na Drive",
"The image's metadata was last copied to Drive %@ ago":
"Metadane obrazu kopii trafiły na Drive ostatnio %@ temu",
"The machine's disk is running out of space (%@ GB)":
"Na dysku maszyny kończy się miejsce (%@ GB)",
"The new sign-in does NOT see the existing backup (%@), so remote '%@' was NOT replaced and rclone.conf is unchanged. Keep the current connection, or sign in with the credential (client_id) that created the backup.":
Expand Down Expand Up @@ -172,5 +178,9 @@ extension L10nPolish {
"Zdalne sterowanie rclone jest otwarte na tym Macu",
"statfs('/System/Volumes/Data') returned an error, so nobody knows whether the machine's disk can still grow on this Mac.":
"statfs('/System/Volumes/Data') zwrócił błąd, więc nikt nie wie, czy dysk maszyny może jeszcze rosnąć na tym Macu.",
"Google Drive's daily upload limit stops the upload":
"Dzienny limit wysyłki Google Drive zatrzymał wysyłkę",
"Google last refused an upload %@ ago (750 GB a day). Backups since then are only in the machine's buffer on this Mac. rclone retries every 5 min and resumes by itself when the limit passes; the guard pauses Time Machine if more than %@ GiB waits.":
"Google ostatnio odrzucił wysyłkę %@ temu (750 GB na dobę). Kopie od tego czasu są tylko w buforze maszyny na tym Macu. rclone ponawia co 5 min i sam wznowi, gdy limit minie; strażnik wstrzyma Time Machine, jeśli czeka więcej niż %@ GiB.",
]
}
26 changes: 26 additions & 0 deletions mac-app/Sources/CloudMachineCore/L10nPolish+VM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ extension L10nPolish {
"Gotowe: %@ ma pasma %@ MB na Google Drive; oryginał leży w %@ w maszynie. Uruchom kopię: tmutil startbackup",
"exit code %@, no message":
"kod wyjścia %@, bez komunikatu",
"Image metadata: NEVER copied to %@ - check tm-meta-sync in the machine":
"Metadane obrazu: NIGDY nie skopiowane do %@ - sprawdź tm-meta-sync w maszynie",
"Image metadata: copied %@ ago to %@":
"Metadane obrazu: skopiowane %@ temu do %@",
"Image metadata: last copied %@ ago - OVERDUE (%@)":
"Metadane obrazu: ostatnia kopia %@ temu - ZALEGŁA (%@)",
"Image metadata: not copied yet - waits for vm-prepare-bundle":
"Metadane obrazu: jeszcze bez kopii - czeka na vm-prepare-bundle",
"Backup %@, paused by the guard %@: %@":
"Kopia %@, wstrzymana przez strażnika %@: %@",
"Free in machine: %@ GiB":
"Wolne w maszynie: %@ GiB",
"fsck_apfs -n found problems: %@":
Expand Down Expand Up @@ -98,6 +108,10 @@ extension L10nPolish {
"Pakowanie przerobionego obrazu nie powiodło się: %@",
"Packing the image failed: %@":
"Pakowanie obrazu nie powiodło się: %@",
"No answer from the machine (rclone rc) - the guard would do nothing.":
"Brak odpowiedzi maszyny (rclone rc) - strażnik nic by nie zrobił.",
"Only print what the guard would decide - for trying thresholds on a throwaway machine.":
"Tylko wypisz, co strażnik by zdecydował - do prób progów na jednorazowej maszynie.",
"Pause above this many GiB in the buffer.":
"Wstrzymaj powyżej tylu GiB w buforze.",
"Pause when the machine has less free than this, in GiB.":
Expand Down Expand Up @@ -162,5 +176,17 @@ extension L10nPolish {
"Bufor wysyłki: brak odpowiedzi rclone (rc %@)",
"When Time Machine has created its empty image, stop the backup (tmutil stopbackup) and run: cloudmachine-agent vm-prepare-bundle":
"Gdy Time Machine utworzy pusty obraz, zatrzymaj kopię (tmutil stopbackup) i uruchom: cloudmachine-agent vm-prepare-bundle",
"Unsent %@ GiB, free %@ GiB (pause above %@ GiB unsent or below %@ GiB free, resume below %@ GiB).":
"Niewysłane %@ GiB, wolne %@ GiB (pauza powyżej %@ GiB niewysłanych lub poniżej %@ GiB wolnego, wznowienie poniżej %@ GiB).",
"Upload: Google's daily limit refused an upload %@ ago - rclone retries every 5 min and resumes by itself":
"Wysyłka: dzienny limit Google odrzucił wysyłkę %@ temu - rclone ponawia co 5 min i sam wznowi",
"no":
"nie",
"running":
"trwa",
"stopped":
"zatrzymana",
"yes":
"tak",
]
}
20 changes: 18 additions & 2 deletions mac-app/Sources/CloudMachineCore/MachineBudget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,33 @@ public enum MachineBudget {
public struct Usage: Codable, Equatable {
public var bytes: UInt64
public var measuredAt: Date
/// The Drive folder measured. `nil` in readings stored before 1.3.16.
public var path: String?

public init(bytes: UInt64, measuredAt: Date, path: String? = nil) {
self.bytes = bytes
self.measuredAt = measuredAt
self.path = path
}

public var gb: Double { Double(bytes) / 1_073_741_824 }
}

/// A stored reading counts only for the folder backed up now. On 27.10.2026
/// the reading taken at 14:31 (101 MB, before the move to `<folder>-tm`)
/// was still shown at 18:00, when the folder held 215 GiB: it was younger
/// than `measurementMaxAge`, so nothing measured again.
static func usage(_ stored: Usage?, appliesTo path: String) -> Usage? {
stored?.path == path ? stored : nil
}

static var usageFile: URL { CMPaths.appSupportDir.appendingPathComponent("drive-usage.json") }

public static func storedUsage() -> Usage? {
guard let data = try? Data(contentsOf: usageFile) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(Usage.self, from: data)
return usage(try? decoder.decode(Usage.self, from: data), appliesTo: VMLayout.remote)
}

/// `rclone size --json` output -> bytes.
Expand All @@ -149,7 +165,7 @@ public enum MachineBudget {
CMLogger.log("Could not measure the size of \(path) on Google Drive")
return nil
}
let usage = Usage(bytes: bytes, measuredAt: now)
let usage = Usage(bytes: bytes, measuredAt: now, path: path)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(usage) {
Expand Down
26 changes: 24 additions & 2 deletions mac-app/Sources/CloudMachineCore/VMLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,22 @@ public enum VMLayout {
public static let cacheDir = "/var/cache/rclone"
public static let backupDir = "/var/lib/cloudmachine/prepare-backup"
public static let rcAddress = "127.0.0.1:5572"
/// Touched by `tm-meta-sync.service` after every successful sync.
public static let metaSyncStamp = "/var/lib/cloudmachine/tm-meta-sync.ok"
public static let rcloneLog = "/var/log/rclone-tm.log"
}

/// The systemd units the share depends on, in start order.
public static let services = ["smb-throttle", "rclone-tm", "punchfs", "tm-bands-bind", "smbd"]
/// The systemd units the share depends on, in start order, then the timer
/// that copies the image's metadata to Drive. A name without a suffix is a
/// `.service` - see `unitName(_:)`.
public static let services = [
"smb-throttle", "rclone-tm", "punchfs", "tm-bands-bind", "smbd", "tm-meta-sync.timer",
]

/// The systemd unit for an entry of `services`.
public static func unitName(_ service: String) -> String {
service.contains(".") ? service : "\(service).service"
}

/// Host-side state: `~/.cloudmachine/vm`.
public static var hostDir: URL {
Expand All @@ -59,6 +71,16 @@ public enum VMLayout {
?? "\(DriveFolder.remoteName):\(DriveFolder.remoteRoot)/\(DriveFolder.name)-tm"
}

/// `gdrive:CloudMachine/<folder>-tm-meta`: the image's files outside
/// `bands/` (Info.plist, token, mapped/, Time Machine's plists), copied by
/// `tm-meta-sync` from the machine's disk. The bands alone cannot be opened
/// again; a restore takes both folders (vm/README.md, "Restoring").
public static var metaRemote: String { remote + "-meta" }

/// Older than this, the metadata copy is reported. The timer runs every
/// 15 min, so an hour is three runs missed in a row.
public static let metaSyncMaxAge: TimeInterval = 3600

/// Lima's directory with the machine's disk (`LIMA_HOME`, else `~/.lima`).
/// The disk holds rclone's buffer: left in the backup, Time Machine copies
/// its own backlog through the machine - on 27.10.2026 that kept the first
Expand Down
8 changes: 6 additions & 2 deletions mac-app/Sources/CloudMachineCore/VMSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@ public enum VMSetup {
return fail(L10n.tr("limactl create failed: %@", VMLayout.errorTail(created)))
}
}
// The first boot of a new machine takes ~30 min on Ubuntu 24.04 (27.10.2026):
// Lima copies every `mode: data` file with `sudo -iu root`, and while
// cloud-final runs logind cannot open the session - pam_systemd waits 2 min
// per file. Lima gives up after 10 min by default; later boots take seconds.
if status != "Running" {
CMLogger.log("vm-setup: starting \(VMLayout.instanceName)")
let started = try? await VMLayout.limactl(
["start", "--tty=false", VMLayout.instanceName], timeout: 3600)
["start", "--tty=false", "--timeout", "50m", VMLayout.instanceName], timeout: 3600)
guard started?.succeeded == true else {
return fail(
L10n.tr(
Expand Down Expand Up @@ -131,7 +135,7 @@ public enum VMSetup {
messages.append(L10n.tr("SMB password stored in %@ (mode 600).", VMLayout.passwordFile.path))

// 5. Services.
let units = VMLayout.services.map { "\($0).service" }.joined(separator: " ")
let units = VMLayout.services.map(VMLayout.unitName).joined(separator: " ")
guard
let started = try? await VMLayout.rootShell(
"systemctl daemon-reload && systemctl start \(units)", timeout: 300),
Expand Down
Loading
Loading