@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 +42 -0
- package/README.md +16 -3
- package/assets/rsync-backup.sh +161 -26
- package/lib/version.d.ts +1 -1
- package/lib/version.js +2 -2
- package/package.json +1 -1
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
|
|
54
|
-
EBS snapshot.
|
|
55
|
-
|
|
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
|
|
package/assets/rsync-backup.sh
CHANGED
|
@@ -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
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
gzip -c "$logfile" >"$logfile_gz"
|
|
94
163
|
|
|
95
|
-
|
|
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
|
-
|
|
166
|
+
rm -f "$logfile" "$logfile_gz"
|
|
101
167
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
5
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
4
|
+
exports.LIB_VERSION = "0.5.6";
|
|
5
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmVyc2lvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInZlcnNpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7O0FBQWEsUUFBQSxXQUFXLEdBQUcsT0FBTyxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGNvbnN0IExJQl9WRVJTSU9OID0gXCIwLjUuNlwiO1xuIl19
|