Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d95fbfc
Add Google Drive backing layer for Time Machine
mbeczynski Sep 11, 2026
95fb6d8
Size bands by measurement and survive loss of the cloud layer
mbeczynski Sep 11, 2026
8c2796a
Guard the buffer agent against restart loops; test the launchd chain
mbeczynski Sep 11, 2026
a9a4675
Ride out FUSE-T's intermittent RPC failures; keep OAuth secrets out o…
mbeczynski Sep 11, 2026
9d52530
Keep the buffer from eating the disk during a large first backup
mbeczynski Sep 11, 2026
7dc50ee
Move the Google Drive layer into the app
mbeczynski Sep 11, 2026
b158702
Retire the shell layer; the app now owns every process
mbeczynski Sep 11, 2026
2e7606c
Delete the old architecture and rebuild the UI around the real one
mbeczynski Sep 11, 2026
8ec4402
Pull FUSE-T inside CloudMachine so there is no second app
mbeczynski Sep 11, 2026
6b993c2
Drain the queue on detach, or the volume comes back empty
mbeczynski Sep 11, 2026
bc179eb
Keep the log out of the dashboard
mbeczynski Sep 11, 2026
f44991a
Add a safe way to reboot
mbeczynski Sep 11, 2026
e0e146e
Stop mistaking Drive's throttle for its daily cap
mbeczynski Sep 11, 2026
51ed15e
Stop the UI from freezing and calling it a failure
mbeczynski Sep 11, 2026
d468535
Audit fixes: stop measuring what rclone already reports
mbeczynski Sep 11, 2026
3102bf7
Rewrite the README for the architecture that exists
mbeczynski Sep 12, 2026
4760cc4
Answer "is it safe to reboot" instead of demanding a ritual
mbeczynski Sep 12, 2026
e6df7ce
Put cloudmachine-agent on PATH
mbeczynski Sep 12, 2026
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
288 changes: 147 additions & 141 deletions README.md

Large diffs are not rendered by default.

143 changes: 143 additions & 0 deletions gdrive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Warstwa Google Drive - pomiary i wnioski

Dzialajacy system siedzi w aplikacji, nie tutaj. Ten katalog trzyma harnessy
pomiarowe i uzasadnienia decyzji, ktore z tych pomiarow wyszly.

```
Time Machine
-> /Volumes/CloudMachine obraz podpiety przez hdiutil; TM widzi zwykly APFS
-> ~/.cloudmachine/drive rclone mount na FUSE-T
-> ~/.cloudmachine/cache bufor zapisu 100 GB
-> gdrive:CloudMachine/... Google Drive
```

Sens ukladu: **w sciezce zapisu nie ma sieciowego systemu plikow.** Time Machine
pisze do lokalnie podpietego obrazu i nie wie, ze pasma leza w chmurze. Odpada
SMB, a z nim najczestsza przyczyna psucia sie backupow sieciowych.

## Gdzie co jest

| Co | Gdzie |
|---|---|
| Bufor (montowanie, cache, kolejka) | `CloudMachineCore/DriveBufferService` |
| Obraz (tworzenie, podpinanie, spojnosc) | `CloudMachineCore/BackupImageService` |
| Dozorca bufora | `CloudMachineCore/BufferGuardService` |
| Instalacja rclone z obsluga montowania | `CloudMachineCore/RcloneInstaller` |
| Rozwiazywanie narzedzi, kontrola FUSE | `CloudMachineCore/CMTooling` |
| Podkomendy | `CloudMachineAgent/DriveCommands` |
| Agenty launchd | `launchd/*.plist.template` |

```sh
cloudmachine-agent install-rclone # oficjalna binarka (ta z Homebrew nie umie montowac)
cloudmachine-agent configure-remote # OAuth, klucze z Keychaina
cloudmachine-agent create-image --size-gb 4000
cloudmachine-agent attach-image
cloudmachine-agent drive-status
sudo tmutil setdestination /Volumes/CloudMachine
```

## Rozmiar pasma

Ustawiany wylacznie przy tworzeniu obrazu; pozniej nie da sie go zmienic bez
zaczynania backupu od zera. Dwie sily ciagna w przeciwne strony: Google Drive
przepuszcza okolo **dwoch operacji na plik na sekunde** i ma limit **400 000
plikow**, co premiuje duze pasma - ale kazda zmiana brudzi **cale** pasmo, co
przy dobowym limicie **750 GB** premiuje male.

Zmierzone (`poc-amplification.sh`, obraz 3 GB, zmiana 300 MB):

| Pasmo | Pasm na 3 GB | Rozrzucona zmiana | Dopisanie (jak TM) | Plikow na 200 GB |
|-------|--------------|-------------------|--------------------|------------------|
| 8 MB | 381 | 2712 MB | 384 MB | 25 600 |
| 16 MB | 193 | 3040 MB | 480 MB | 12 800 |
| 32 MB | 99 | 3072 MB | **672 MB** | **6 400** |
| 64 MB | 52 | 3136 MB | 768 MB | 3 200 |

Przy zmianie **rozrzuconej** po calym wolumenie rozmiar pasma nie ma znaczenia -
brudzi sie prawie kazde pasmo i wysyla sie w praktyce caly obraz. To jednak
najgorszy przypadek, nie ten, ktory nas dotyczy.

Przy **dopisywaniu**, czyli tym, co faktycznie robi Time Machine, transfer
rosnie monotonicznie z rozmiarem pasma: 64 MB kosztuje dokladnie dwa razy tyle
co 8 MB. Duze pasma nie sa darmowe.

Stad **32 MB**: najmniejsze pasmo, przy ktorym pierwsza wysylka przestaje byc
ograniczona tempem operacji Drive'a (6 400 plikow, ~0,9 h) i zaczyna byc
ograniczona pasmem lacza (~1,3 h przy 332 Mb/s).

## Kaprysy montowania FUSE-T

FUSE-T montuje przez NFS, a `hdiutil` na takim wolumenie bywa odrzucany bledem
**`RPC version wrong`**. Zmierzone: blad nie zalezy od rozmiaru obrazu ani od
danych (jeden przebieg padl dla 100 GB i 400 GB, a przeszedl dla 600, 1000
i 1500 GB), tylko od **chwili** - przy pustej kolejce wysylki 5 prob na 5
udanych, przy rclone zajetym losowo. Stad czekanie na cisze i ponawianie
w `BackupImageService`; przy tworzeniu produkcyjnego obrazu pierwsza proba padla,
druga przeszla.

**Obraz trzeba tworzyc na miejscu, na zamontowanym Drive.** Utworzenie go
lokalnie i przeniesienie daje obraz, ktorego `hdiutil` pozniej nie otwiera
(`CBSDBackingStore::newProbe stat() failed`), mimo ze wszystkie pliki i pasma sa
na swoim miejscu i daja sie czytac.

Pozostale backendy FUSE-T nie pomagaja: `backend=fskit` w ogole sie nie montuje,
`backend=smb` montuje sie, ale `hdiutil create` konczy sie `Is a directory`.

## Co przetrwa smierc warstwy chmurowej

`poc-pullplug.sh` odpina zastepnik montowania w trakcie zapisu, czyli symuluje
padniecie procesu rclone albo wysypanie sie FUSE-T. Trzy rundy, **zero
nieodwracalnych strat** - obraz za kazdym razem przeszedl `fsck_apfs`.

Zerwanie samego lacza jest lagodniejsze: przy `--vfs-cache-mode full` zapis idzie
do bufora, montowanie stoi i Time Machine niczego nie zauwaza.

Dwie pulapki, ktore ten test ujawnil, obie zalatane w `BackupImageService`:

**Zombie urzadzenia.** Po wymuszonym odpieciu urzadzenie obrazu potrafi zostac
w systemie. Podpiecie zwraca wtedy martwy uchwyt, na ktorym `fsck_apfs` melduje
`failed to read container superblock` z UUID z samych zer. Wyglada to jak
skasowany backup, a jest tylko nieczytelnym urzadzeniem - pierwsza wersja tego
testu na tej podstawie trzy razy z rzedu orzekla utrate danych, ktore byly cale.

**Osierocony punkt montowania.** Po nieczystym odpieciu katalog
`/Volumes/CloudMachine` zostaje i blokuje ponowne podpiecie komunikatem
`no mountable file systems`. Nalezy do uzytkownika, ale lezy w `/Volumes`
nalezacym do roota, wiec `rmdir` odmawia - **agent dzialajacy jako uzytkownik
nie posprzata po sobie sam**. Aplikacja wykrywa to i podaje dokladne polecenie.

## Bufor jest limitem miekkim

`--vfs-cache-max-size` nie jest granica twarda: rclone usuwa z bufora tylko dane
juz wyslane, wiec gdy wszystko czeka w kolejce, bufor rosnie dalej i moze
zapelnic dysk. Time Machine pisze do obrazu z predkoscia SSD (zmierzone
267 MB/s), rclone wysyla z predkoscia lacza (~41 MB/s) - na starcie pierwszego
backupu bufor rosl netto o 32 MB/s.

Dlatego `BufferGuardService` wstrzymuje Time Machine powyzej progu i wznawia,
gdy wysylka nadgoni. Pilnuje tez dobowego limitu Drive'a: po jego przekroczeniu
rclone konczy prace z zalozenia i podnoszenie go nic nie da, dopoki limit sie
nie odnowi.

## Harnessy pomiarowe

Nie sa czescia dzialajacego systemu - uruchamia sie je recznie, gdy trzeba cos
zmierzyc albo potwierdzic regresje. Mierza zachowanie `hdiutil` i FUSE-T, czyli
rzeczy, ktorych testem jednostkowym sie nie zmierzy.

```sh
BAND_MB=32 WORKLOAD=append ./poc-amplification.sh # wzmocnienie zapisu
BAND_MB=32 ROUNDS=3 ./poc-pullplug.sh # smierc warstwy chmurowej
```

## Czego nadal nie wiadomo

- Jak montowanie zachowa sie pod obciazeniem pelnego, wielogodzinnego backupu.
Pojedyncze operacje dzialaja (zapis 50 MB przy 267 MB/s), ale to inna skala.
- Czy rclone nigdy nie usuwa z bufora danych jeszcze niewyslanych.
`poc-pullplug.sh` pokrywa mocniejszy przypadek - smierc calej warstwy - ale
nie ten konkretny, bo wymaga dzialajacego rclone.
- Czy `tmutil setdestination` przyjmie cel spoza `/Volumes`. Od tego zalezy, czy
da sie usunac koniecznosc recznej interwencji po nieczystym odpieciu.
- Czy `--rc-no-auth` na petli zwrotnej jest akceptowalne. Kazdy lokalny proces
moze przez ten interfejs sterowac montowaniem.
91 changes: 91 additions & 0 deletions gdrive/poc-amplification.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/bin/bash
# Mierzy wzmocnienie zapisu: ile megabajtow trzeba wyslac do Drive'a,
# zeby utrwalic jeden megabajt faktycznej zmiany.
#
# To jest liczba, ktora decyduje o rozmiarze pasma. Duze pasma oszczedzaja
# operacje na plikach (Drive przepuszcza ~2/s i ma limit 400 000 plikow), ale
# kazda drobna zmiana kaze wyslac cale pasmo od nowa. Jesli wzmocnienie okaze
# sie wysokie, 64 MB jest bledem i trzeba zejsc nizej.
#
# Uruchamiane dla kazdego rozmiaru pasma osobno; wynik to tabela do porownania.

set -euo pipefail

POC_ROOT="${POC_ROOT:-/tmp/cm-amp}"
BAND_MB="${BAND_MB:-64}"
SEED_FILES="${SEED_FILES:-3000}" # plikow w pierwszym zapisie
FILE_KB="${FILE_KB:-64}" # rozmiar pojedynczego pliku
TOUCH_FILES="${TOUCH_FILES:-300}" # ile plikow zmieniamy w drugim przebiegu
WORKLOAD="${WORKLOAD:-scatter}" # scatter = przepisanie rozrzuconych plikow
# append = dopisanie nowych, jak robi TM

BAND_SECTORS=$((BAND_MB * 1024 * 1024 / 512))
ROOT="$POC_ROOT/b$BAND_MB"
OUTER="$ROOT/stand-in.sparseimage"
MOUNT="$ROOT/drive"
IMAGE="$MOUNT/amp.sparsebundle"
TARGET="/Volumes/AmpPOC$BAND_MB"

cleanup() {
hdiutil detach "$TARGET" -quiet 2>/dev/null || true
hdiutil detach "$MOUNT" -quiet 2>/dev/null || true
}
trap cleanup EXIT

[ "${1:-}" = "clean" ] && { cleanup; rm -rf "$POC_ROOT"; echo "Posprzatane."; exit 0; }

cleanup; rm -rf "$ROOT"; mkdir -p "$MOUNT"

hdiutil create -type SPARSE -size 40g -fs APFS -volname AmpStandIn "$OUTER" >/dev/null
hdiutil attach "$OUTER" -mountpoint "$MOUNT" -nobrowse >/dev/null
hdiutil create -type SPARSEBUNDLE -size 30g -fs "Case-sensitive APFS" \
-volname "AmpPOC$BAND_MB" -imagekey "sparse-band-size=$BAND_SECTORS" "$IMAGE" >/dev/null
hdiutil attach "$IMAGE" -nobrowse -mountpoint "$TARGET" >/dev/null

# Pierwszy zapis - odpowiednik pelnego backupu.
mkdir -p "$TARGET/dane"
for i in $(seq 1 "$SEED_FILES"); do
dd if=/dev/urandom of="$TARGET/dane/plik-$i.bin" bs=1k count="$FILE_KB" 2>/dev/null
done
sync; hdiutil detach "$TARGET" -quiet

SEED_BANDS=$(ls "$IMAGE/bands" | wc -l | tr -d ' ')
SEED_MB=$(du -sk "$IMAGE" | awk '{print int($1/1024)}')

# Znacznik czasu, wzgledem ktorego liczymy zmienione pasma.
MARK="$ROOT/mark"; touch "$MARK"; sleep 1

# Drugi przebieg - odpowiednik backupu przyrostowego. Zmieniamy rozrzucone
# pliki, zeby trafic w mozliwie wiele roznych pasm; to najgorszy realny
# przypadek, nie sredni.
hdiutil attach "$IMAGE" -nobrowse -mountpoint "$TARGET" >/dev/null
CHANGED_KB=0
if [ "$WORKLOAD" = "append" ]; then
# Time Machine nie przepisuje istniejacych danych w miejscu - kazdy backup
# dokłada nowe pliki. Zapis jest wtedy skupiony, nie rozrzucony.
mkdir -p "$TARGET/dane/przyrost"
for i in $(seq 1 "$TOUCH_FILES"); do
dd if=/dev/urandom of="$TARGET/dane/przyrost/nowy-$i.bin" bs=1k count="$FILE_KB" 2>/dev/null
CHANGED_KB=$((CHANGED_KB + FILE_KB))
done
else
STEP=$((SEED_FILES / TOUCH_FILES))
for i in $(seq 1 "$STEP" "$SEED_FILES"); do
dd if=/dev/urandom of="$TARGET/dane/plik-$i.bin" bs=1k count="$FILE_KB" conv=notrunc 2>/dev/null
CHANGED_KB=$((CHANGED_KB + FILE_KB))
done
fi
sync; hdiutil detach "$TARGET" -quiet

DIRTY=$(find "$IMAGE/bands" -type f -newer "$MARK" | wc -l | tr -d ' ')
UPLOAD_MB=$((DIRTY * BAND_MB))
CHANGED_MB=$((CHANGED_KB / 1024))
[ "$CHANGED_MB" -eq 0 ] && CHANGED_MB=1

printf '%s\n' "---"
printf 'pasmo : %s MB (scenariusz: %s)\n' "$BAND_MB" "$WORKLOAD"
printf 'po pelnym zapisie : %s pasm, %s MB\n' "$SEED_BANDS" "$SEED_MB"
printf 'zmieniono realnie : %s MB w %s plikach\n' "$CHANGED_MB" "$TOUCH_FILES"
printf 'pobrudzonych pasm : %s\n' "$DIRTY"
printf 'do wyslania : %s MB\n' "$UPLOAD_MB"
printf 'WZMOCNIENIE : %sx\n' "$((UPLOAD_MB / CHANGED_MB))"
29 changes: 29 additions & 0 deletions gdrive/poc-common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/bin/bash
# Wspolne czesci harnessow pomiarowych.
#
# Same harnessy mierza zachowanie hdiutil i FUSE-T, a nie nasz kod - dlatego
# zostaly, gdy reszta skryptow trafila do aplikacji (CloudMachineCore).
# Nie sa czescia dzialajacego systemu; uruchamia sie je recznie, gdy trzeba
# cos zmierzyc albo potwierdzic regresje.

CM_AGENT="${CM_AGENT:-/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent}"

# Urzadzenia /dev/diskN podpiete pod wskazany obraz.
#
# Po wymuszonym odpieciu urzadzenie potrafi zostac w systemie jako zombie.
# Podpiecie zwraca wtedy martwy uchwyt, na ktorym fsck_apfs melduje
# "failed to read container superblock" z UUID z samych zer - wyglada to jak
# skasowany backup, a jest tylko nieczytelnym urzadzeniem.
cm_devices_for_image() {
hdiutil info | awk -v img="$1" '
/^image-path/ { sub(/^image-path[ \t]*:[ \t]*/, ""); path = $0; next }
/^\/dev\/disk[0-9]+[ \t]/ { if (path == img) print $1 }
' | sort -u
}

cm_purge_stale_devices() {
local d
for d in $(cm_devices_for_image "$1"); do
hdiutil detach "$d" -force -quiet 2>/dev/null || true
done
}
106 changes: 106 additions & 0 deletions gdrive/poc-pullplug.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/bin/bash
# Symuluje smierc warstwy chmurowej w trakcie zapisu.
#
# Najgrozniejszy scenariusz tej architektury: Time Machine pisze do podpietego
# obrazu, a pod spodem znika montowanie rclone - bo padl proces, bo FUSE-T sie
# wysypal, bo system uspil dysk. Obraz traci swoje pasma w srodku zapisu.
#
# Test odpina zewnetrzny obraz (zastepnik montowania rclone) w trakcie zapisu
# do wewnetrznego, potem podpina wszystko z powrotem i sprawdza fsck_apfs.
#
# Interesuje nas nie to, czy zapis przezyje - nie przezyje - tylko czy obraz
# da sie pozniej naprawic, czy jest do wyrzucenia. Roznica miedzy "backup
# przerwany, wznowi sie" a "backup stracony, zaczynamy od zera".

set -euo pipefail
source "$(dirname "$0")/poc-common.sh"

POC_ROOT="${POC_ROOT:-/tmp/cm-plug}"
BAND_MB="${BAND_MB:-64}"
ROUNDS="${ROUNDS:-3}"

BAND_SECTORS=$((BAND_MB * 1024 * 1024 / 512))
OUTER="$POC_ROOT/stand-in.sparseimage"
MOUNT="$POC_ROOT/drive"
IMAGE="$MOUNT/plug.sparsebundle"
TARGET="/Volumes/PlugPOC"
FSCK=/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs

detach_all() {
hdiutil detach "$TARGET" -force -quiet 2>/dev/null || true
hdiutil detach "$MOUNT" -force -quiet 2>/dev/null || true
}
trap detach_all EXIT

[ "${1:-}" = "clean" ] && { detach_all; rm -rf "$POC_ROOT"; echo "Posprzatane."; exit 0; }

detach_all; rm -rf "$POC_ROOT"; mkdir -p "$MOUNT"

echo "Przygotowanie"
hdiutil create -type SPARSE -size 20g -fs APFS -volname PlugStandIn "$OUTER" >/dev/null
hdiutil attach "$OUTER" -mountpoint "$MOUNT" -nobrowse >/dev/null
hdiutil create -type SPARSEBUNDLE -size 10g -fs "Case-sensitive APFS" \
-volname PlugPOC -imagekey "sparse-band-size=$BAND_SECTORS" "$IMAGE" >/dev/null

FAILED=0
for r in $(seq 1 "$ROUNDS"); do
echo
echo "--- runda $r ---"
hdiutil attach "$IMAGE" -nobrowse -mountpoint "$TARGET" >/dev/null

# Zapis w tle, zeby wyrwac podloge w jego trakcie.
( dd if=/dev/urandom of="$TARGET/obciazenie-$r.bin" bs=1m count=1500 2>/dev/null ) &
WRITER=$!
sleep 3

echo "Wyrywam podloge (odpinam zastepnik montowania)"
hdiutil detach "$MOUNT" -force -quiet 2>/dev/null || true
wait "$WRITER" 2>/dev/null || echo " zapis przerwany, zgodnie z oczekiwaniem"
hdiutil detach "$TARGET" -force -quiet 2>/dev/null || true

echo "Przywracam warstwe i sprawdzam obraz"
# Nie sprawdzamy obrazu w miejscu. Po wymuszonym odpieciu urzadzenie potrafi
# zostac w systemie jako zombie; podpiecie zwraca wtedy martwy uchwyt, a
# fsck_apfs melduje "failed to read container superblock" z UUID z samych zer.
# Wyglada to jak nieodwracalne uszkodzenie, a jest tylko nieczytelnym
# urzadzeniem - wczesniejsza wersja tego testu na tej podstawie trzy razy
# z rzedu orzekla utrate backupu, ktory byl caly.
#
# Kopia pod swieza sciezka jest odporna na ten artefakt: nowy plik, nowe
# urzadzenie, zaden stary uchwyt nie ma z nim zwiazku.
hdiutil detach "$TARGET" -force -quiet 2>/dev/null || true
cm_purge_stale_devices "$IMAGE"
sleep 2
hdiutil attach "$OUTER" -mountpoint "$MOUNT" -nobrowse >/dev/null
sleep 1

COPY="$POC_ROOT/kontrola-$r.sparsebundle"
rm -rf "$COPY"
cp -R "$IMAGE" "$COPY"

DEV=$(hdiutil attach "$COPY" -nomount 2>/dev/null | awk '/41504653/ {print $1; exit}')
if [ -z "$DEV" ]; then
echo " WYNIK: obrazu nie da sie nawet podpiac - stracony"
FAILED=$((FAILED + 1))
break
fi
if "$FSCK" -n "$DEV" >"$POC_ROOT/fsck-$r.log" 2>&1; then
echo " WYNIK: spojny"
else
echo " WYNIK: niespojny - probuje naprawic"
if "$FSCK" -y "$DEV" >>"$POC_ROOT/fsck-$r.log" 2>&1; then
echo " naprawa udana - backup do uratowania"
else
echo " naprawa nieudana - backup stracony (log: $POC_ROOT/fsck-$r.log)"
FAILED=$((FAILED + 1))
fi
fi
hdiutil detach "$DEV" -force -quiet 2>/dev/null || true
rm -rf "$COPY"
done

echo
echo "==================================="
echo "Rund: $ROUNDS nieodwracalnych strat: $FAILED"
[ "$FAILED" -eq 0 ] && echo "Obraz przezyl kazde wyrwanie podlogi." \
|| echo "UWAGA: architektura gubi backup przy utracie warstwy chmurowej."
Loading
Loading