@anyakichi/cdk-rsync-backup 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,42 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ - `npm run build` — compile TypeScript (`lib/*.ts` → `lib/*.js` + `.d.ts`). `prebuild` regenerates `lib/version.ts` from `package.json` version.
8
+ - `npm test` — run Jest tests (`ts-jest`, matches `test/**/*.test.ts`).
9
+ - `npm run watch` — `tsc -w` for incremental compilation.
10
+ - `npm run format` — run `sort-package-json` + Prettier over `package.json` and `lib`. Prettier sorts imports (`prettier.config.js`).
11
+
12
+ Compiled JS/`.d.ts` output is committed alongside the sources in `lib/` (this is the published npm package, `main: lib/index.js`). Run `npm run build` after editing `lib/index.ts` so the shipped `.js` stays in sync.
13
+
14
+ There is no lint step and no CDK app here — this is a library (L3 construct) consumed by other CDK stacks. Publishing is automated: `.github/workflows/publish.yml` runs `npm publish` (npm trusted publishing / OIDC) on GitHub release creation.
15
+
16
+ ## Architecture
17
+
18
+ This package exports one L3 construct, `RsyncBackup` (`lib/index.ts`). The design splits responsibilities between **synth time** (TypeScript, generates CloudFormation) and **backup runtime** (bash on the EC2 instance).
19
+
20
+ ### Synth time — `lib/index.ts`
21
+
22
+ The constructor provisions: an S3 logs bucket, a dual-stack VPC (public subnet, no NAT), a security group (SSH open to the world), an IAM role granting the EC2/S3/SSM actions the backup script needs, an EC2 key pair, and a single long-lived EC2 instance (Ubuntu 24.04, `t4g.nano` default). Most props have sensible defaults and can be overridden (`vpc`, `securityGroup`, `instanceType`, `logsBucket`, etc.).
23
+
24
+ Nearly all behavior is wired through **EC2 UserData** built up incrementally in the constructor:
25
+ - Installs `aws-cli`, downloads the `assets/` directory (packaged as an `s3assets.Asset`, unzipped to `/srv/rsync-backup`), installs `rsync-backup.sh` as `/usr/local/bin/rsync-backup`.
26
+ - Writes `MAX_SNAPSHOTS` and `S3_LOGS_BUCKET` into `/etc/environment`.
27
+ - For each `RsyncBackupModule`, renders a per-module `rsyncd.<name>.conf` (via `sed` on `@host@`) and appends a **forced-command** line to `/root/.ssh/authorized_keys`. That forced command is what pins an incoming SSH/rsync session to `rsync-backup <name> <size> /dev/sd<X>` — the client-supplied rsync command is ignored. Device letters are assigned sequentially from `f` per module index.
28
+ - With no `modules`, a single `backup` module is configured as a fallback.
29
+
30
+ Two notable optional flags:
31
+ - `swapSize` — appends `fallocate`/`mkswap`/`swapon` + `/etc/fstab` commands (helps small instance types survive heavy rsync).
32
+ - `useEIP` — allocates an EIP **and** injects a UserData block that persists SSH host keys to SSM Parameter Store (`/ec2/hostkeys/rsync-backup/*`), so a re-created fixed-IP instance keeps the same host keys.
33
+
34
+ The instance's CDK logical ID embeds the library version (`Instance-<major.minor>`, optionally `-<instanceVersion>`); bumping it forces instance replacement.
35
+
36
+ ### Runtime — `assets/rsync-backup.sh`
37
+
38
+ This is the actual backup engine, invoked once per rsync connection via the authorized_keys forced command. Per run it: finds/creates an encrypted gp3 EBS volume (from the latest matching snapshot if one exists, else fresh + `mkfs.ext4`), attaches and mounts it, runs `rsync --server --daemon` with the module's config to receive data, unmounts/detaches, creates a snapshot tagged `Name=backup-<host>` + `rsync-backup`, uploads a gzipped log to S3, then **backgrounds** (via `disown`) the snapshot-wait → volume-delete → old-snapshot pruning (keeping `MAX_SNAPSHOTS`, default 15). Size changes are handled at runtime: growing creates the volume larger from the snapshot + `resize2fs`; shrinking receives the transfer on the old-size volume, then migrates data to a fresh smaller volume in the disowned background block (interim tag `Name=backup-<host>-migrating`, module device letter mapped f–p→q–z) and snapshots the new volume. Runs are serialized per host with `flock` on `/srv/rsync-backup/rsync-backup.<host>.lock`, held by the background block until cleanup/migration completes. Volumes and snapshots are correlated by the `Name=backup-<host>` tag. `assets/rsyncd.conf` + `rsyncd.inc` are the rsyncd templates (`@host@` is substituted at synth time).
39
+
40
+ ### Tests
41
+
42
+ `test/cdk-rsync-backup.test.ts` is currently a commented-out placeholder — there is no meaningful test coverage yet. New tests would use `aws-cdk-lib/assertions` `Template.fromStack` against a stack containing `RsyncBackup`.
package/README.md CHANGED
@@ -50,9 +50,22 @@ export class CdkDemoStack extends cdk.Stack {
50
50
  If no EBS snapshot for `hostname` does not exist, a new EBS volume is
51
51
  created on rsync execution with specified `size`.
52
52
 
53
- If an EBS snapshot exist, a new EBS volume is created from the latest
54
- EBS snapshot. The volume size is depends on the snapshot and `size`
55
- parameter is ignored.
53
+ If an EBS snapshot exists, a new EBS volume is created from the latest
54
+ EBS snapshot. When the `size` parameter differs from the snapshot
55
+ size:
56
+
57
+ - Growing: the volume is created with the new size and the filesystem
58
+ is expanded automatically before receiving data.
59
+ - Shrinking: the backup is received on a volume of the previous size
60
+ first, so the client never has to wait. After the transfer
61
+ completes, the data is copied to a new smaller volume in the
62
+ background and the snapshot is taken from the new volume. If the
63
+ data does not fit into the new size, shrinking is skipped and the
64
+ previous size is kept.
65
+
66
+ Note that `size` is embedded in the instance's authorized_keys at
67
+ first boot. To apply a size change to an existing deployment, replace
68
+ the instance by setting or bumping the `instanceVersion` property.
56
69
 
57
70
  When you create a new backup, simply execute rsync.
58
71
 
@@ -11,6 +11,23 @@ AWS_DEFAULT_REGION=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.25
11
11
  INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
12
12
  INSTANCE_AZ=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
13
13
 
14
+ prune_snapshots() {
15
+ local name="$1"
16
+ local i
17
+ local -a snapshot_ids
18
+
19
+ if ((MAX_SNAPSHOTS > 0)); then
20
+ read -r -a snapshot_ids <<<"$(aws ec2 describe-snapshots \
21
+ --owner-ids self \
22
+ --filter "Name=tag:Name,Values=$name" \
23
+ --query "reverse(sort_by(Snapshots,&StartTime))[].SnapshotId" \
24
+ --output text)"
25
+ for ((i = MAX_SNAPSHOTS; i < ${#snapshot_ids[@]}; i++)); do
26
+ aws ec2 delete-snapshot --snapshot-id "${snapshot_ids[$i]}"
27
+ done
28
+ fi
29
+ }
30
+
14
31
  main() {
15
32
  local host="$1"
16
33
  local size="$2"
@@ -18,8 +35,18 @@ main() {
18
35
  local name="backup-$1"
19
36
  local mnt="/srv/rsync-backup/mnt/$host"
20
37
  local logfile="/srv/rsync-backup/rsync-backup.$host.log"
21
- local args dev logfile_gz s3base snapshot_id timestamp volume_id
22
- local -a snapshot_ids
38
+ local args dev logfile_gz migrate new_dev new_device new_mnt \
39
+ new_volume_id s3base snapshot_id snapshot_size stale_volume_ids \
40
+ timestamp used v volume_id volume_size
41
+
42
+ # Serialize runs per host. The lock is inherited by the disowned
43
+ # background jobs, so it is held until the snapshot cleanup or the
44
+ # shrink migration is finished.
45
+ exec 9>"/srv/rsync-backup/rsync-backup.$host.lock"
46
+ if ! flock -n 9; then
47
+ echo "backup or migration for $host is in progress" >&2
48
+ exit 1
49
+ fi
23
50
 
24
51
  timestamp="$(date -u "+%Y%m%dT%H%MZ")"
25
52
  s3base=s3://$S3_LOGS_BUCKET/RsyncBackupLogs/$(date -u "+%Y/%m/%d")/
@@ -39,15 +66,26 @@ main() {
39
66
  exit 1
40
67
  fi
41
68
 
42
- # Get the latest snapshot ID.
43
- snapshot_id=$(aws ec2 describe-snapshots --owner-ids self \
69
+ # Get the latest snapshot ID and its volume size.
70
+ read -r snapshot_id snapshot_size <<<"$(aws ec2 describe-snapshots \
71
+ --owner-ids self \
44
72
  --filter "Name=tag:Name,Values=$name" \
45
- --query "reverse(sort_by(Snapshots,&StartTime))[0].SnapshotId" \
46
- --output text)
73
+ --query "reverse(sort_by(Snapshots,&StartTime))[0].[SnapshotId,VolumeSize]" \
74
+ --output text)"
47
75
 
48
76
  args=()
49
77
  if [[ $snapshot_id != None ]]; then
50
78
  args+=(--snapshot-id "$snapshot_id")
79
+ if ((size > snapshot_size)); then
80
+ # Grow the volume. The filesystem is expanded with
81
+ # resize2fs after mount.
82
+ args+=(--size "$size")
83
+ elif ((size < snapshot_size)); then
84
+ # The shrink migration reads the whole volume, which is
85
+ # painfully slow against lazily-loaded snapshot blocks.
86
+ # Pay for provisioned-rate initialization instead.
87
+ args+=(--volume-initialization-rate 100)
88
+ fi
51
89
  else
52
90
  args+=(--size "$size")
53
91
  fi
@@ -78,8 +116,17 @@ main() {
78
116
  mkfs.ext4 -q "$dev"
79
117
  fi
80
118
  mount "$dev" "$mnt"
119
+ else
120
+ dev=$(findmnt -no SOURCE "$mnt")
81
121
  fi
82
122
 
123
+ # Expand the filesystem to the volume size. This is a no-op unless
124
+ # the volume was created larger than the snapshot.
125
+ resize2fs "$dev" >>"$logfile" 2>&1
126
+
127
+ volume_size=$(aws ec2 describe-volumes --volume-ids "$volume_id" \
128
+ --query "Volumes[0].Size" --output text)
129
+
83
130
  mkdir -p "$mnt/$host"
84
131
 
85
132
  df -h "$dev" >>"$logfile"
@@ -88,39 +135,127 @@ main() {
88
135
 
89
136
  df -h "$dev" >>"$logfile"
90
137
 
91
- umount "$mnt"
138
+ # If the requested size is smaller than the current volume, migrate
139
+ # the data to a new smaller volume after the transfer so that the
140
+ # client does not have to wait.
141
+ migrate=""
142
+ if ((volume_size > size)); then
143
+ used=$(df -BG --output=used "$mnt" | tail -n 1 | tr -dc '0-9')
144
+ if ((used * 100 < size * 90)); then
145
+ migrate=1
146
+ else
147
+ echo "data (${used}G used) does not fit into ${size}G;" \
148
+ "keeping ${volume_size}G volume" >>"$logfile"
149
+ fi
150
+ fi
151
+
152
+ if [[ -z $migrate ]]; then
153
+ umount "$mnt"
154
+
155
+ aws ec2 detach-volume --volume-id "$volume_id" &>/dev/null
156
+
157
+ snapshot_id=$(aws ec2 create-snapshot --volume-id "$volume_id" \
158
+ --description "$name-$timestamp" \
159
+ --tag-specifications "ResourceType=snapshot,Tags=[{Key=Name,Value=$name},{Key=rsync-backup,Value=''}]" \
160
+ --query SnapshotId --output text)
92
161
 
93
- aws ec2 detach-volume --volume-id "$volume_id" &>/dev/null
162
+ gzip -c "$logfile" >"$logfile_gz"
94
163
 
95
- snapshot_id=$(aws ec2 create-snapshot --volume-id "$volume_id" \
96
- --description "$name-$timestamp" \
97
- --tag-specifications "ResourceType=snapshot,Tags=[{Key=Name,Value=$name},{Key=rsync-backup,Value=''}]" \
98
- --query SnapshotId --output text)
164
+ aws s3 cp "$logfile_gz" "$s3base"
99
165
 
100
- gzip -c "$logfile" >"$logfile_gz"
166
+ rm -f "$logfile" "$logfile_gz"
101
167
 
102
- aws s3 cp "$logfile_gz" "$s3base"
168
+ (
169
+ while true; do
170
+ aws ec2 wait snapshot-completed --snapshot-ids "$snapshot_id" &&
171
+ break
172
+ done
173
+ aws ec2 wait volume-available --volume-ids "$volume_id"
174
+ aws ec2 delete-volume --volume-id "$volume_id"
175
+
176
+ prune_snapshots "$name"
177
+ ) &>/dev/null &
178
+ disown
179
+ return
180
+ fi
103
181
 
104
- rm -f "$logfile" "$logfile_gz"
182
+ new_mnt="$mnt.new"
183
+ # Attach in the q-z range so we never collide with module devices
184
+ # (f-p). The real device node is resolved by volume ID anyway.
185
+ new_device="${device%?}$(tr 'f-p' 'q-z' <<<"${device: -1}")"
105
186
 
106
187
  (
188
+ # Clean up a stale volume from a previously failed migration.
189
+ umount "$new_mnt" 2>/dev/null || true
190
+ stale_volume_ids=$(aws ec2 describe-volumes \
191
+ --filter "Name=tag:Name,Values=$name-migrating" \
192
+ --query "Volumes[].VolumeId" --output text)
193
+ for v in $stale_volume_ids; do
194
+ aws ec2 detach-volume --volume-id "$v" &>/dev/null || true
195
+ aws ec2 wait volume-available --volume-ids "$v"
196
+ aws ec2 delete-volume --volume-id "$v"
197
+ done
198
+
199
+ new_volume_id=$(aws ec2 create-volume \
200
+ --availability-zone "$INSTANCE_AZ" \
201
+ --encrypted --volume-type gp3 --size "$size" \
202
+ --tag-specifications "ResourceType=volume,Tags=[{Key=Name,Value=$name-migrating},{Key=rsync-backup,Value=''}]" \
203
+ --query VolumeId --output text)
204
+
205
+ aws ec2 wait volume-available --volume-ids "$new_volume_id"
206
+ aws ec2 attach-volume --volume-id "$new_volume_id" \
207
+ --instance-id "$INSTANCE_ID" --device "$new_device" &>/dev/null
208
+
209
+ if [[ -e /dev/disk/by-id ]]; then
210
+ new_dev="/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${new_volume_id/-/}_1"
211
+ else
212
+ new_dev="${new_device/sd/xvd}"
213
+ fi
214
+
215
+ while true; do
216
+ if [[ -e $new_dev ]]; then
217
+ break
218
+ fi
219
+ sleep 1
220
+ done
221
+
222
+ mkfs.ext4 -q "$new_dev"
223
+ mkdir -p "$new_mnt"
224
+ mount "$new_dev" "$new_mnt"
225
+
226
+ rsync -aAXHS --numeric-ids --exclude=/lost+found \
227
+ "$mnt/" "$new_mnt/" >>"$logfile" 2>&1
228
+
229
+ df -h "$new_dev" >>"$logfile"
230
+
231
+ umount "$new_mnt" "$mnt"
232
+
233
+ aws ec2 detach-volume --volume-id "$volume_id" &>/dev/null
234
+ aws ec2 detach-volume --volume-id "$new_volume_id" &>/dev/null
235
+
236
+ aws ec2 create-tags --resources "$new_volume_id" \
237
+ --tags "Key=Name,Value=$name"
238
+
239
+ snapshot_id=$(aws ec2 create-snapshot --volume-id "$new_volume_id" \
240
+ --description "$name-$timestamp" \
241
+ --tag-specifications "ResourceType=snapshot,Tags=[{Key=Name,Value=$name},{Key=rsync-backup,Value=''}]" \
242
+ --query SnapshotId --output text)
243
+
244
+ gzip -c "$logfile" >"$logfile_gz"
245
+
246
+ aws s3 cp "$logfile_gz" "$s3base"
247
+
248
+ rm -f "$logfile" "$logfile_gz"
249
+
107
250
  while true; do
108
251
  aws ec2 wait snapshot-completed --snapshot-ids "$snapshot_id" &&
109
252
  break
110
253
  done
111
- aws ec2 wait volume-available --volume-ids "$volume_id"
254
+ aws ec2 wait volume-available --volume-ids "$volume_id" "$new_volume_id"
112
255
  aws ec2 delete-volume --volume-id "$volume_id"
256
+ aws ec2 delete-volume --volume-id "$new_volume_id"
113
257
 
114
- if ((MAX_SNAPSHOTS > 0)); then
115
- read -r -a snapshot_ids <<<"$(aws ec2 describe-snapshots \
116
- --owner-ids self \
117
- --filter "Name=tag:Name,Values=$name" \
118
- --query "reverse(sort_by(Snapshots,&StartTime))[].SnapshotId" \
119
- --output text)"
120
- for ((i = MAX_SNAPSHOTS; i < ${#snapshot_ids}; i++)); do
121
- aws ec2 delete-snapshot --snapshot-id "${snapshot_ids[$i]}"
122
- done
123
- fi
258
+ prune_snapshots "$name"
124
259
  ) &>/dev/null &
125
260
  disown
126
261
  }
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const LIB_VERSION = "0.5.4";
1
+ export declare const LIB_VERSION = "0.5.6";
package/lib/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LIB_VERSION = void 0;
4
- exports.LIB_VERSION = "0.5.4";
5
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmVyc2lvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInZlcnNpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7O0FBQWEsUUFBQSxXQUFXLEdBQUcsT0FBTyxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGNvbnN0IExJQl9WRVJTSU9OID0gXCIwLjUuNFwiO1xuIl19
4
+ exports.LIB_VERSION = "0.5.6";
5
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmVyc2lvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInZlcnNpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7O0FBQWEsUUFBQSxXQUFXLEdBQUcsT0FBTyxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGNvbnN0IExJQl9WRVJTSU9OID0gXCIwLjUuNlwiO1xuIl19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyakichi/cdk-rsync-backup",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "AWS CDK L3 construct for cloud backup system with rsync",
5
5
  "keywords": [
6
6
  "awscdk",