@anyakichi/cdk-rsync-backup 0.5.4 → 0.5.5

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,21 @@ 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
+ fi
51
84
  else
52
85
  args+=(--size "$size")
53
86
  fi
@@ -78,8 +111,17 @@ main() {
78
111
  mkfs.ext4 -q "$dev"
79
112
  fi
80
113
  mount "$dev" "$mnt"
114
+ else
115
+ dev=$(findmnt -no SOURCE "$mnt")
81
116
  fi
82
117
 
118
+ # Expand the filesystem to the volume size. This is a no-op unless
119
+ # the volume was created larger than the snapshot.
120
+ resize2fs "$dev" >>"$logfile" 2>&1
121
+
122
+ volume_size=$(aws ec2 describe-volumes --volume-ids "$volume_id" \
123
+ --query "Volumes[0].Size" --output text)
124
+
83
125
  mkdir -p "$mnt/$host"
84
126
 
85
127
  df -h "$dev" >>"$logfile"
@@ -88,39 +130,127 @@ main() {
88
130
 
89
131
  df -h "$dev" >>"$logfile"
90
132
 
91
- umount "$mnt"
133
+ # If the requested size is smaller than the current volume, migrate
134
+ # the data to a new smaller volume after the transfer so that the
135
+ # client does not have to wait.
136
+ migrate=""
137
+ if ((volume_size > size)); then
138
+ used=$(df -BG --output=used "$mnt" | tail -n 1 | tr -dc '0-9')
139
+ if ((used * 100 < size * 90)); then
140
+ migrate=1
141
+ else
142
+ echo "data (${used}G used) does not fit into ${size}G;" \
143
+ "keeping ${volume_size}G volume" >>"$logfile"
144
+ fi
145
+ fi
146
+
147
+ if [[ -z $migrate ]]; then
148
+ umount "$mnt"
149
+
150
+ aws ec2 detach-volume --volume-id "$volume_id" &>/dev/null
151
+
152
+ snapshot_id=$(aws ec2 create-snapshot --volume-id "$volume_id" \
153
+ --description "$name-$timestamp" \
154
+ --tag-specifications "ResourceType=snapshot,Tags=[{Key=Name,Value=$name},{Key=rsync-backup,Value=''}]" \
155
+ --query SnapshotId --output text)
92
156
 
93
- aws ec2 detach-volume --volume-id "$volume_id" &>/dev/null
157
+ gzip -c "$logfile" >"$logfile_gz"
94
158
 
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)
159
+ aws s3 cp "$logfile_gz" "$s3base"
99
160
 
100
- gzip -c "$logfile" >"$logfile_gz"
161
+ rm -f "$logfile" "$logfile_gz"
101
162
 
102
- aws s3 cp "$logfile_gz" "$s3base"
163
+ (
164
+ while true; do
165
+ aws ec2 wait snapshot-completed --snapshot-ids "$snapshot_id" &&
166
+ break
167
+ done
168
+ aws ec2 wait volume-available --volume-ids "$volume_id"
169
+ aws ec2 delete-volume --volume-id "$volume_id"
170
+
171
+ prune_snapshots "$name"
172
+ ) &>/dev/null &
173
+ disown
174
+ return
175
+ fi
103
176
 
104
- rm -f "$logfile" "$logfile_gz"
177
+ new_mnt="$mnt.new"
178
+ # Attach in the q-z range so we never collide with module devices
179
+ # (f-p). The real device node is resolved by volume ID anyway.
180
+ new_device="${device%?}$(tr 'f-p' 'q-z' <<<"${device: -1}")"
105
181
 
106
182
  (
183
+ # Clean up a stale volume from a previously failed migration.
184
+ umount "$new_mnt" 2>/dev/null || true
185
+ stale_volume_ids=$(aws ec2 describe-volumes \
186
+ --filter "Name=tag:Name,Values=$name-migrating" \
187
+ --query "Volumes[].VolumeId" --output text)
188
+ for v in $stale_volume_ids; do
189
+ aws ec2 detach-volume --volume-id "$v" &>/dev/null || true
190
+ aws ec2 wait volume-available --volume-ids "$v"
191
+ aws ec2 delete-volume --volume-id "$v"
192
+ done
193
+
194
+ new_volume_id=$(aws ec2 create-volume \
195
+ --availability-zone "$INSTANCE_AZ" \
196
+ --encrypted --volume-type gp3 --size "$size" \
197
+ --tag-specifications "ResourceType=volume,Tags=[{Key=Name,Value=$name-migrating},{Key=rsync-backup,Value=''}]" \
198
+ --query VolumeId --output text)
199
+
200
+ aws ec2 wait volume-available --volume-ids "$new_volume_id"
201
+ aws ec2 attach-volume --volume-id "$new_volume_id" \
202
+ --instance-id "$INSTANCE_ID" --device "$new_device" &>/dev/null
203
+
204
+ if [[ -e /dev/disk/by-id ]]; then
205
+ new_dev="/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${new_volume_id/-/}_1"
206
+ else
207
+ new_dev="${new_device/sd/xvd}"
208
+ fi
209
+
210
+ while true; do
211
+ if [[ -e $new_dev ]]; then
212
+ break
213
+ fi
214
+ sleep 1
215
+ done
216
+
217
+ mkfs.ext4 -q "$new_dev"
218
+ mkdir -p "$new_mnt"
219
+ mount "$new_dev" "$new_mnt"
220
+
221
+ rsync -aAXHS --numeric-ids --exclude=/lost+found \
222
+ "$mnt/" "$new_mnt/" >>"$logfile" 2>&1
223
+
224
+ df -h "$new_dev" >>"$logfile"
225
+
226
+ umount "$new_mnt" "$mnt"
227
+
228
+ aws ec2 detach-volume --volume-id "$volume_id" &>/dev/null
229
+ aws ec2 detach-volume --volume-id "$new_volume_id" &>/dev/null
230
+
231
+ aws ec2 create-tags --resources "$new_volume_id" \
232
+ --tags "Key=Name,Value=$name"
233
+
234
+ snapshot_id=$(aws ec2 create-snapshot --volume-id "$new_volume_id" \
235
+ --description "$name-$timestamp" \
236
+ --tag-specifications "ResourceType=snapshot,Tags=[{Key=Name,Value=$name},{Key=rsync-backup,Value=''}]" \
237
+ --query SnapshotId --output text)
238
+
239
+ gzip -c "$logfile" >"$logfile_gz"
240
+
241
+ aws s3 cp "$logfile_gz" "$s3base"
242
+
243
+ rm -f "$logfile" "$logfile_gz"
244
+
107
245
  while true; do
108
246
  aws ec2 wait snapshot-completed --snapshot-ids "$snapshot_id" &&
109
247
  break
110
248
  done
111
- aws ec2 wait volume-available --volume-ids "$volume_id"
249
+ aws ec2 wait volume-available --volume-ids "$volume_id" "$new_volume_id"
112
250
  aws ec2 delete-volume --volume-id "$volume_id"
251
+ aws ec2 delete-volume --volume-id "$new_volume_id"
113
252
 
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
253
+ prune_snapshots "$name"
124
254
  ) &>/dev/null &
125
255
  disown
126
256
  }
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.5";
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.5";
5
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmVyc2lvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInZlcnNpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7O0FBQWEsUUFBQSxXQUFXLEdBQUcsT0FBTyxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGNvbnN0IExJQl9WRVJTSU9OID0gXCIwLjUuNVwiO1xuIl19
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.5",
4
4
  "description": "AWS CDK L3 construct for cloud backup system with rsync",
5
5
  "keywords": [
6
6
  "awscdk",