Skip to content

Commit 092161a

Browse files
mbeczynskiclaude
andcommitted
feat(vm): Time Machine over SMB to a Lima machine (stage 1, next to the old layout)
macOS 27 refuses a disk image as a new Time Machine destination (tmutil setdestination -> error 45), so the attached-image layout cannot be set up from scratch there. This adds the replacement next to it; the old code stays until the first full backup through the new layout has finished. - vm/: Lima template cmsmb.yaml with idempotent provisioning (packages, rclone v1.75.1 checked against its published SHA256, user tm, directories, systemd units, Samba, FUSE, punchfs build); the units and smb.conf as files. Machine-specific values (Drive folder, image name) come from /etc/cloudmachine/*.env written by the agent - no names in the repo. Descriptor limits from the EMFILE stop of 27.10 (LimitNOFILE=524288 for punchfs and smbd, max open files = 500000). - vm/punchfs: libfuse passthrough_ll.c with hole punch emulated by zeros and xattrs kept in side files (GPLv2, that file only), plus an in-machine test. - cloudmachine-agent vm-setup, vm-prepare-bundle, vm-guard, vm-status. - launchd/vm/: vm-start and vm-guard agents, installed only by vm-setup --install-agents (install-launchd does not pick them up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1f6278d commit 092161a

31 files changed

Lines changed: 3726 additions & 1 deletion

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ on:
4747
- "mac-app/Sources/**"
4848
- "mac-app/Resources/**"
4949
- "launchd/**"
50+
- "vm/**"
5051
- "config/**"
5152
- "packaging/homebrew/**"
5253
workflow_dispatch:

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ logs/
88
# rclone config (contains OAuth tokens) - never commit
99
rclone.conf
1010
*.conf
11+
# ...except the machine's templates (Samba, FUSE, systemd drop-ins) - no
12+
# secrets in them; rclone.conf stays ignored there too.
13+
!vm/**/*.conf
14+
vm/**/rclone.conf
1115

1216
# Generated launchd files (contain the user's local paths)
1317
launchd/*.plist

‎README.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@ so they read the same whoever sends them to you.
4848
with an Apple Developer ID. The Homebrew cask clears the quarantine flag; a
4949
DMG downloaded by hand gets Gatekeeper's "unidentified developer" warning.
5050

51+
### macOS 27: Time Machine over SMB to a local machine (preview)
52+
53+
macOS 27 no longer accepts a disk image as a new Time Machine destination
54+
(`tmutil setdestination` answers error 45), so the layout above cannot be set
55+
up from scratch there. The replacement, being rolled out next to it: a small
56+
Linux machine on this Mac serves Time Machine an SMB share, and the backup's
57+
bands go from it to Google Drive (details in [`vm/README.md`](vm/README.md)).
58+
59+
- Requires [Lima](https://lima-vm.io) from Homebrew (`brew install lima`) and
60+
a connected Google Drive (`configure-remote`).
61+
- `cloudmachine-agent vm-setup` creates and starts the machine and prints
62+
a `sudo tmutil setdestination smb://...` command - the one step that needs
63+
an administrator, once.
64+
- When Time Machine has created its empty image, stop the backup and run
65+
`cloudmachine-agent vm-prepare-bundle` (32 MB bands, moved to Drive).
66+
- `cloudmachine-agent vm-status` shows the machine; `vm-setup --install-agents`
67+
adds the agents that start it at login and pause Time Machine while the
68+
upload buffer is full.
69+
5170
---
5271

5372
## Getting started
@@ -176,4 +195,6 @@ repository (`renacode-infra: docs/projekty/cloudmachine/`):
176195

177196
## Licence
178197

179-
MIT. FUSE-T and rclone keep their own licences.
198+
MIT. FUSE-T and rclone keep their own licences. One file is GPLv2:
199+
`vm/punchfs/punchfs.c`, based on libfuse's passthrough example (see
200+
`vm/punchfs/README.md`).
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>Label</key>
6+
<string>com.renacode.cloudmachine.vm-guard</string>
7+
<!--
8+
The machine's buffer guard (`vm-guard`): one check per run, so
9+
StartInterval rather than KeepAlive. Installed only by
10+
`cloudmachine-agent vm-setup --install-agents`, never by install-launchd.
11+
-->
12+
<key>ProgramArguments</key>
13+
<array>
14+
<string>__CM_AGENT_BIN__</string>
15+
<string>vm-guard</string>
16+
</array>
17+
<key>StartInterval</key>
18+
<integer>60</integer>
19+
<key>RunAtLoad</key>
20+
<true/>
21+
<key>EnvironmentVariables</key>
22+
<dict>
23+
<key>PATH</key>
24+
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
25+
</dict>
26+
<key>StandardOutPath</key>
27+
<string>__CM_LOG_DIR__/launchd-vm-guard.out.log</string>
28+
<key>StandardErrorPath</key>
29+
<string>__CM_LOG_DIR__/launchd-vm-guard.err.log</string>
30+
</dict>
31+
</plist>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>Label</key>
6+
<string>com.renacode.cloudmachine.vm-start</string>
7+
<!--
8+
Starts the machine at login. `limactl start` on a running machine only
9+
says so and exits, which is why RunAtLoad without KeepAlive is enough.
10+
limactl comes from Homebrew, found through PATH below.
11+
-->
12+
<key>ProgramArguments</key>
13+
<array>
14+
<string>/usr/bin/env</string>
15+
<string>limactl</string>
16+
<string>start</string>
17+
<string>--tty=false</string>
18+
<string>cmsmb</string>
19+
</array>
20+
<key>RunAtLoad</key>
21+
<true/>
22+
<key>EnvironmentVariables</key>
23+
<dict>
24+
<key>PATH</key>
25+
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
26+
</dict>
27+
<key>StandardOutPath</key>
28+
<string>__CM_LOG_DIR__/launchd-vm-start.out.log</string>
29+
<key>StandardErrorPath</key>
30+
<string>__CM_LOG_DIR__/launchd-vm-start.err.log</string>
31+
</dict>
32+
</plist>

‎mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ struct BuildApp: AsyncParsableCommand {
130130
try fm.copyItem(
131131
at: projectRoot.appendingPathComponent("launchd"),
132132
to: resourcesDir.appendingPathComponent("launchd"))
133+
// The machine template (`vm-setup`): cmsmb.yaml reads its files relative
134+
// to itself, so the directory goes in whole.
135+
try fm.copyItem(
136+
at: projectRoot.appendingPathComponent("vm"),
137+
to: resourcesDir.appendingPathComponent("vm"))
133138
let configDir = resourcesDir.appendingPathComponent("config")
134139
try fm.createDirectory(at: configDir, withIntermediateDirectories: true)
135140
try fm.copyItem(

‎mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ struct CloudMachineAgent: AsyncParsableCommand {
3232
PrepareShutdown.self,
3333
RestartMount.self,
3434
DriveStatus.self,
35+
// The machine layout (Time Machine over SMB) - see vm/README.md.
36+
VMSetupCommand.self,
37+
VMPrepareBundle.self,
38+
VMGuardCommand.self,
39+
VMStatusCommand.self,
3540
]
3641
)
3742
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import ArgumentParser
2+
import CloudMachineCore
3+
import Foundation
4+
5+
// The machine layout (Time Machine over SMB to a Lima VM) - see vm/README.md.
6+
// It runs beside the attached-image commands; none of these touch the image.
7+
8+
struct VMSetupCommand: AsyncParsableCommand {
9+
static let configuration = CommandConfiguration(
10+
commandName: "vm-setup",
11+
abstract: L10n.tr(
12+
"Creates or starts the CloudMachine machine (Lima), gives it rclone's configuration and an SMB password, and prints the Time Machine command."
13+
))
14+
15+
@Flag(
16+
name: .long,
17+
help: ArgumentHelp(
18+
L10n.tr("Copy rclone.conf into the machine even if it already has one.")))
19+
var replaceRcloneConfig = false
20+
21+
@Flag(
22+
name: .long,
23+
help: ArgumentHelp(L10n.tr("Also install the vm-start and vm-guard launchd agents.")))
24+
var installAgents = false
25+
26+
func run() async throws {
27+
let result = await VMSetup.run(replaceRcloneConfig: replaceRcloneConfig)
28+
for line in result.messages { print(line) }
29+
guard result.succeeded else { throw ExitCode.failure }
30+
if installAgents {
31+
let agents = await LaunchdInstaller.installVMAgents()
32+
print(agents.message)
33+
if !agents.succeeded { throw ExitCode.failure }
34+
}
35+
if let command = result.destinationCommand {
36+
// Printed, never logged: it holds the password.
37+
print("")
38+
print(
39+
L10n.tr("Point Time Machine at the machine (once, needs an administrator password):"))
40+
print(" \(command)")
41+
print(
42+
L10n.tr(
43+
"When Time Machine has created its empty image, stop the backup (tmutil stopbackup) and run: cloudmachine-agent vm-prepare-bundle"
44+
))
45+
}
46+
}
47+
}
48+
49+
struct VMPrepareBundle: AsyncParsableCommand {
50+
static let configuration = CommandConfiguration(
51+
commandName: "vm-prepare-bundle",
52+
abstract: L10n.tr(
53+
"Converts Time Machine's new, empty image in the machine to 32 MB bands and moves the bands to Google Drive."
54+
))
55+
56+
@Option(
57+
name: .long,
58+
help: ArgumentHelp(
59+
L10n.tr("Image name in the machine (default: from the computer name).")))
60+
var bundle: String?
61+
62+
func run() async throws {
63+
let outcome = await VMBundlePrep.run(bundleOverride: bundle)
64+
print(outcome.message)
65+
if !outcome.succeeded { throw ExitCode.failure }
66+
}
67+
}
68+
69+
struct VMGuardCommand: AsyncParsableCommand {
70+
static let configuration = CommandConfiguration(
71+
commandName: "vm-guard",
72+
abstract: L10n.tr(
73+
"One check of the machine's upload buffer: pauses Time Machine when it fills, resumes what it paused."
74+
))
75+
76+
@Option(name: .long, help: ArgumentHelp(L10n.tr("Pause above this many GiB in the buffer.")))
77+
var highGiB: Int = VMGuard.Thresholds().highGiB
78+
79+
@Option(
80+
name: .long,
81+
help: ArgumentHelp(L10n.tr("Resume below this many GiB (only a backup the guard paused).")))
82+
var lowGiB: Int = VMGuard.Thresholds().lowGiB
83+
84+
@Option(
85+
name: .long,
86+
help: ArgumentHelp(L10n.tr("Pause when the machine has less free than this, in GiB."))
87+
)
88+
var minFreeGiB: Int = VMGuard.Thresholds().minFreeGiB
89+
90+
func run() async throws {
91+
let guardian = VMGuard(
92+
thresholds: .init(highGiB: highGiB, lowGiB: lowGiB, minFreeGiB: minFreeGiB))
93+
await guardian.step()
94+
}
95+
}
96+
97+
struct VMStatusCommand: AsyncParsableCommand {
98+
static let configuration = CommandConfiguration(
99+
commandName: "vm-status",
100+
abstract: L10n.tr(
101+
"Shows the machine: address, services, upload buffer, free space and the Time Machine destination."
102+
))
103+
104+
func run() async throws {
105+
let (lines, healthy) = await VMStatus.lines()
106+
for line in lines { print(line) }
107+
if !healthy { throw ExitCode.failure }
108+
}
109+
}

‎mac-app/Sources/CloudMachineCore/L10n.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public enum L10n {
6161
L10nPolish.app,
6262
L10nPolish.agent,
6363
L10nPolish.safety,
64+
L10nPolish.vm,
6465
]
6566
}
6667

0 commit comments

Comments
 (0)