Skip to content

Commit 9a89bd8

Browse files
committed
Release simple-volume v0.2.0
1 parent 262cfdb commit 9a89bd8

27 files changed

Lines changed: 3437 additions & 284 deletions

‎README.md‎

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# simple-volume
22

3-
`simple-volume` is a Kubernetes-native async replicated local-volume prototype.
3+
`simple-volume` is a Kubernetes-native async replicated local-volume system.
44
Applications use normal PVCs backed by the `simple-volume.shipstuff.io` CSI
55
driver. The controller and node agents handle the local replica lifecycle,
66
freshness policy, and promotion state.
@@ -26,9 +26,10 @@ acceptable fit for many single-writer workloads.
2626
See [docs/why-simple-volume.md](docs/why-simple-volume.md) for the full design
2727
rationale, replication model, and storage options we evaluated.
2828

29-
## V0 Scope
29+
## Initial Release Scope
3030

31-
The V0 scope is intentionally narrow and complete enough for initial release:
31+
The `v0.2.0` release is intentionally narrow and ready for initial real
32+
workload use with understood async-replication tradeoffs:
3233

3334
- dynamic PVC provisioning into logical SimpleVolumes
3435
- chart-configured local storage pools
@@ -40,38 +41,56 @@ The V0 scope is intentionally narrow and complete enough for initial release:
4041

4142
Replication logic does not run inside CSI. CSI is the Kubernetes mount boundary;
4243
the controller owns policy and the node agent owns local filesystem operations.
43-
Like `local-path`, v0 does not enforce PV capacity at the filesystem layer. The
44-
PVC request is used for Kubernetes API shape, placement decisions, and operator
45-
visibility; workloads can still consume available space in the backing local
46-
pool unless the host filesystem enforces its own limits.
44+
Like `local-path`, `simple-volume` does not enforce PV capacity at the
45+
filesystem layer. The PVC request is used for Kubernetes API shape, placement
46+
decisions, and operator visibility; workloads can still consume available space
47+
in the backing local pool unless the host filesystem enforces its own limits.
4748

4849
## Replication Model
4950

50-
The intended V0 replication path is watch-driven:
51+
The intended replication path is watch-driven and, by default, shadow-backed:
5152

5253
- the active node-agent watches configured durable paths inside the active
5354
volume
5455
- file events are debounced into batches
55-
- replica agents receive batch sync requests
56-
- replica agents pull changed files from the active agent's read-only rclone
57-
WebDAV endpoint
56+
- in `shadow` consistency mode, the source agent first stages those changes into
57+
a local shadow tree
58+
- replica agents receive batch sync requests and pull from the completed shadow
59+
tree through the active agent's read-only rclone WebDAV endpoint
5860
- the WebDAV server uses a short directory cache so newly written files are
5961
visible to watch-triggered pulls
62+
- a generation is considered successfully delivered only after the configured
63+
number of replicas confirms it
6064
- an off-hours full resync schedule, such as `0 4 * * *`, provides a safety net
6165
for missed events or agent restarts
6266

6367
Volumes can replicate only selected folders/files. For game servers, this keeps
6468
large reconstructable game downloads out of the hot replication path while still
6569
protecting save/config state.
6670

71+
The default chart resources are sized for a low-memory steady state. The node
72+
agent uses conservative rclone settings for replication work: single-transfer
73+
copying, one checker, no explicit transfer buffer, and no multi-threaded stream
74+
copying. That keeps memory predictable on small clusters, including Pi-class
75+
nodes, but it also means large initial seeds can be slow. Operators that want
76+
faster whole-volume seeding should raise the agent memory limit and the rclone
77+
concurrency/buffer settings together once those knobs are exposed. Raising
78+
memory alone only increases headroom; it does not make a single-transfer sync
79+
faster.
80+
81+
Treat 1:1 full-volume seeding as an operational bootstrap path, not the normal
82+
replication profile. It walks many more files, includes reconstructable game
83+
runtime data, and can consume noticeably more accounted memory from kernel page
84+
cache and rclone directory/check state than scoped steady-state replication.
85+
6786
The operator chooses the storage-capable node set when installing the chart by
6887
scheduling the node-agent DaemonSet with normal Kubernetes selectors, affinity,
6988
and tolerations. Workloads that consume `simple-volume` storage should also
7089
declare matching scheduling intent in their own manifests. `simple-volume` owns
7190
the per-volume active/fresh labels, but it should not depend on a mutating
7291
admission webhook to make unrelated workload charts schedulable.
7392

74-
The node agent exposes the V0 watch control surface used by the controller and
93+
The node agent exposes the watch control surface used by the controller and
7594
manual E2E validation:
7695

7796
- `POST /replication/watch/start` starts or replaces an active watch for a
@@ -104,7 +123,7 @@ Example start request:
104123
}
105124
```
106125

107-
The controller reconciles this same behavior for annotated PVCs. For V0 it
126+
The controller reconciles this same behavior for annotated PVCs. It
108127
discovers the active/source node from the running pod that mounts the claim,
109128
uses all other ready node-agent pods as replicas, starts the source watch, and
110129
optionally runs a scoped full sync against each replica.
@@ -115,15 +134,33 @@ metadata:
115134
simple-volume.shipstuff.io/replication-enabled: "true"
116135
simple-volume.shipstuff.io/replication-include-paths: "writes.log,saves/**"
117136
simple-volume.shipstuff.io/replication-exclude-paths: "downloads/**"
137+
simple-volume.shipstuff.io/replication-prune-excluded: "false"
118138
simple-volume.shipstuff.io/replication-debounce: "2s"
139+
simple-volume.shipstuff.io/replication-consistency-mode: "shadow"
140+
simple-volume.shipstuff.io/replication-confirmed-replicas: "1"
119141
simple-volume.shipstuff.io/replication-full-sync-on-start: "true"
120142
simple-volume.shipstuff.io/replication-full-sync-schedule: "0 4 * * *"
121143
```
122144
145+
`shadow` is the safe default for DB-like file trees because replicas pull from a
146+
source-side staged copy instead of the live directory while the app is mutating
147+
it. Workloads that do not need this can opt out with
148+
`replication-consistency-mode: "live"`. Shadow mode writes an additional local
149+
copy of the included paths on the active node, so narrow include paths are the
150+
main mitigation for disk growth and SSD wear.
151+
152+
Excluded paths are preserved on replicas by default. This is important for
153+
large reconstructable caches such as Steam game installs: they can be excluded
154+
from replication without being deleted during a full sync. Set
155+
`simple-volume.shipstuff.io/replication-prune-excluded: "true"` only when the
156+
replica must be an exact filtered mirror and deleting excluded files is
157+
acceptable.
158+
123159
Replication uses rclone metadata preservation and the controller infers replica
124160
file ownership from the active pod's `runAsUser`, `runAsGroup`, or `fsGroup`
125161
when available. PVC annotations can override this for workloads without a pod
126-
security context:
162+
security context. File-mode overrides preserve existing executable bits so
163+
launch scripts and runtimes are not made non-executable by normalization:
127164

128165
```yaml
129166
metadata:
@@ -138,7 +175,7 @@ The current schedule parser intentionally supports exact minute/hour cron
138175
windows such as `0 4 * * *`; ranges and step expressions are left for the
139176
controller-runtime implementation.
140177

141-
Opt-in V0 failover is PVC annotation driven:
178+
Opt-in automatic failover is PVC annotation driven:
142179

143180
```yaml
144181
metadata:

‎cmd/simple-volume/main.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func runController(args []string) {
4848
tokenSecretName := fs.String("sync-token-secret-name", getenv("SIMPLE_VOLUME_TOKEN_SECRET_NAME", "simple-volume-sync-token"), "secret containing the agent sync token")
4949
tokenSecretKey := fs.String("sync-token-secret-key", getenv("SIMPLE_VOLUME_TOKEN_SECRET_KEY", "token"), "secret key containing the agent sync token")
5050
reconcileInterval := fs.Duration("reconcile-interval", 30*time.Second, "replication reconcile interval")
51+
failoverTimeout := fs.Duration("failover-timeout", 10*time.Second, "maximum time to spend on failover reconciliation before continuing replication")
5152
_ = fs.Parse(args)
5253
log.Printf("starting simple-volume controller driver=%s addr=%s replication=%t", v1alpha1.DriverName, *addr, *enableReplication)
5354
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -63,6 +64,7 @@ func runController(args []string) {
6364
TokenSecretName: *tokenSecretName,
6465
TokenSecretKey: *tokenSecretKey,
6566
ReconcileInterval: *reconcileInterval,
67+
FailoverTimeout: *failoverTimeout,
6668
})
6769
if err != nil && err != context.Canceled {
6870
log.Fatalf("replication controller: %v", err)
@@ -131,6 +133,8 @@ func runAgent(args []string) {
131133
})
132134
mux.HandleFunc("/replication/sync-batch", agent.SyncBatchHandler(pool, auth, agent.ExecRunner{}, 10*time.Minute, replicationLocks))
133135
mux.HandleFunc("/replication/full-sync", agent.FullSyncHandler(pool, auth, agent.ExecRunner{}, time.Hour, replicationLocks))
136+
mux.HandleFunc("/replication/shadow/prepare", agent.ShadowPrepareHandler(pool, auth, agent.ExecRunner{}, time.Hour, replicationLocks))
137+
mux.HandleFunc("/replication/archive", agent.ArchiveHandler(pool, auth))
134138
mux.HandleFunc("/replication/watch/start", watchManager.StartHandler(auth))
135139
mux.HandleFunc("/replication/watch/stop", watchManager.StopHandler(auth))
136140
mux.HandleFunc("/replication/watch/status", watchManager.StatusHandler(auth))

‎docs/e2e-deployment-tasks.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# Simple Volume E2E Deployment Tasks
22

3-
This is the implementation checklist to get `simple-volume` from the current
4-
V0 scaffold to a cluster-tested E2E flow.
3+
This is the implementation checklist that moved `simple-volume` from scaffold
4+
to a cluster-tested E2E flow. The current release gate is `v0.2.0`.
55

66
## 1. Publish The Image
77

88
- Commit the current scaffold and push `main`.
99
- Confirm `.github/workflows/publish-images.yml` publishes
1010
`ghcr.io/shipstuff/simple-volume:sha-<sha>` on the `main` push.
1111
- Use the `sha-<sha>` tag for first cluster installs.
12-
- Cut `v0.1.0` only after the CSI smoke test passes.
12+
- Cut `v0.2.0` only after local checks, image/chart publish workflows, and the
13+
Windrose canary two-leg fire drill pass.
1314

1415
## 2. Define A Safe Test Pool
1516

@@ -74,7 +75,7 @@ kubectl exec deploy/simple-volume-demo -- sh -c 'echo ok >> /data/e2e.txt && cat
7475
source agent's read-only WebDAV endpoint.
7576
- Add cron-style off-hours full resync from the controller as a safety net,
7677
using `sync.fullResyncSchedule` such as `0 4 * * *`; do not create one
77-
Kubernetes CronJob per volume in V0.
78+
Kubernetes CronJob per volume.
7879
- Make CSI node authorization read the controller/volume status instead of the
7980
current local static authorization scaffold.
8081
- Add per-volume node labels for active/healthy scheduling.
@@ -90,7 +91,7 @@ kubectl exec deploy/simple-volume-demo -- sh -c 'echo ok >> /data/e2e.txt && cat
9091
local copy under `.simple-volume-backups/`, restores from the promoted leader,
9192
and remains a replica unless an explicit move-back is requested.
9293

93-
Recommended V0 replication policy shape:
94+
Recommended replication policy shape:
9495

9596
```yaml
9697
sync:
@@ -110,7 +111,7 @@ save/config state and reconstructable game files.
110111
111112
## 6. Release Gate
112113
113-
Before tagging `v0.1.0`, all of these should pass:
114+
Before tagging `v0.2.0`, all of these should pass:
114115

115116
```bash
116117
go test ./...
@@ -126,3 +127,5 @@ And in-cluster:
126127
- demo PVC binds.
127128
- demo pod mounts and writes data.
128129
- non-empty uninitialized pool adoption fails by default.
130+
- Windrose canary two-leg failover completes with confirmed post-promotion full
131+
sync and watch restart on each promoted active node.

‎docs/failover-selection-tasks.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Failover Selection Tasks
22

3-
This tracks the post-`v0.1.0` failover selection work discussed after the
4-
initial release tag.
3+
This tracks failover selection work included in the `v0.2.0` initial usable
4+
release.
55

66
## Implemented
77

@@ -20,6 +20,10 @@ initial release tag.
2020
requests and node allocatable capacity.
2121
- Keep PV/PVC annotations as the status source of truth and keep CSI as the
2222
final mount authorization guard.
23+
- Do not delete unbound Pending consumer pods during failover. After promotion,
24+
a Pending pod can be scheduled naturally once the active node label moves.
25+
Only delete/evict consumer pods that are already bound to a non-promoted node
26+
and need fencing or owner-controller recreation.
2327

2428
## Still Future
2529

‎docs/replication-pruning-tasks.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Replication Pruning Tasks
2+
3+
## Decision
4+
5+
Excluded paths are preserved on replicas by default. Scoped replication treats
6+
included paths as authoritative durable data and excluded paths as node-local
7+
cache unless an operator explicitly opts into pruning.
8+
9+
Use:
10+
11+
```yaml
12+
metadata:
13+
annotations:
14+
simple-volume.shipstuff.io/replication-prune-excluded: "true"
15+
```
16+
17+
only when the replica should be an exact filtered mirror and deleting excluded
18+
files is acceptable.
19+
20+
## Rationale
21+
22+
For game servers, excluded paths often contain large reconstructable runtime
23+
data such as Steam libraries, game binaries, shader caches, or logs. Deleting
24+
those directories during full sync saves disk but can turn failover into a slow
25+
redownload. Preserving them gives each node a warm local cache while still
26+
replicating the durable save/config paths.
27+
28+
## Tasks
29+
30+
- [x] Default rclone full sync to preserve excluded paths.
31+
- [x] Add opt-in `replication-prune-excluded` PVC annotation.
32+
- [x] Pass prune policy from controller to source shadow prepare and target
33+
full sync requests.
34+
- [x] Cover default preservation and opt-in pruning with unit tests.
35+
- [x] Run a Windrose canary failover after a confirmed full sync and verify the
36+
already-warm replica does not redownload the excluded game install on
37+
promotion.
38+
- [ ] Document any workloads that require exact filtered mirrors and should set
39+
`replication-prune-excluded: "true"`.

‎docs/shadow-consistency-tasks.md‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Shadow Consistency Tasks
2+
3+
This tracks the next replication reliability step after the Windrose canary
4+
fire drills showed that live file replication can still expose an app-level
5+
inconsistent RocksDB state.
6+
7+
## Implemented In This Pass
8+
9+
- Add `simple-volume.shipstuff.io/replication-consistency-mode`.
10+
- Default consistency mode to `shadow`; allow opt-out with `live`.
11+
- Add global `simple-volume.shipstuff.io/replication-confirmed-replicas`.
12+
- Default confirmed replicas to `1`.
13+
- Let the source agent prepare a local shadow copy under
14+
`.simple-volume-shadows/<namespace>/<volume>/current/data`.
15+
- Keep shadow metadata beside the payload so internal marker files are not
16+
restored into the user volume.
17+
- Let full sync replicas pull from the prepared shadow path instead of the live
18+
volume path when shadow mode is active.
19+
- Let watch batches update the source shadow before replicas pull changed files.
20+
- Treat deletes from shadow batches as authoritative so replicas can prune files
21+
without waiting for an off-hours full sync.
22+
- Mark a generation successful only after the configured number of replicas
23+
confirms delivery.
24+
25+
## Validated
26+
27+
- Windrose canary two-leg fire drills pass with shadow mode enabled.
28+
- Startup full sync completes before the source watch starts.
29+
- Each promotion runs a post-promotion startup full sync and restarts the watch
30+
from the promoted active node.
31+
- Returning stale nodes back up local state before restoring from the promoted
32+
leader.
33+
34+
## Still To Measure
35+
36+
- Source-node disk growth during active gameplay.
37+
- Shadow bytes copied per batch for RocksDB-heavy workloads.
38+
- Scheduled maintenance-window full sync behavior over longer runs.
39+
40+
## Operational Caveats
41+
42+
Shadow mode increases source-node writes because durable data is written once by
43+
the app and again into the local shadow. The expected max active-node footprint
44+
is approximately:
45+
46+
```text
47+
live included data + shadow included data
48+
```
49+
50+
This is why scoped `replication-include-paths` matter. Avoid `**` for workloads
51+
that constantly rewrite large binary trees unless the extra disk use and SSD
52+
wear are acceptable.
53+
54+
Mitigations:
55+
56+
- replicate only durable paths, not reconstructable runtime downloads
57+
- debounce hot file changes into batches
58+
- keep full sync as startup, repair, and maintenance-window work
59+
- expose metrics for shadow bytes copied, generation duration, confirmation
60+
lag, and failed confirmations
61+
- later prefer reflink copies where the filesystem supports them
62+
- later add a snapshot backend for ZFS or Btrfs hosts
63+
64+
## Backend Boundary
65+
66+
The default backend remains file-shadow plus rclone/WebDAV because it keeps
67+
`simple-volume` portable and lightweight.
68+
69+
ZFS would be useful as an optional future backend:
70+
71+
- atomic source snapshots
72+
- cheap local generation creation
73+
- block-level incremental send/receive
74+
- stronger checksums and rollback tools
75+
76+
It should stay optional. Making ZFS mandatory would turn this project into a
77+
Kubernetes operator for replicated ZFS datasets instead of a simple local-volume
78+
controller with async recovery points.

0 commit comments

Comments
 (0)