@highstate/restic 0.4.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.
@@ -0,0 +1,82 @@
1
+ import { k8s, restic } from '@highstate/library';
2
+ import { core } from '@pulumi/kubernetes';
3
+ import { ScriptBundle, Job, CronJob, CommonArgs, ScriptEnvironment, WorkloadVolume, Container, ScriptDistribution } from '@highstate/k8s';
4
+ import { ComponentResource, Output, Input, InputArray, ComponentResourceOptions, InstanceTriggerInvocation, InstanceTrigger } from '@highstate/pulumi';
5
+
6
+ type BackupJobPairArgs = CommonArgs & {
7
+ /**
8
+ * The k8s cluster to calculate the repository path.
9
+ */
10
+ k8sCluster: Input<k8s.Cluster>;
11
+ /**
12
+ * The repository to backup/restore to/from.
13
+ */
14
+ resticRepo: Input<restic.Repo>;
15
+ /**
16
+ * The extra script environment to pass to the backup and restore scripts.
17
+ */
18
+ environment?: Input<ScriptEnvironment>;
19
+ /**
20
+ * The extra script environments to pass to the backup and restore scripts.
21
+ */
22
+ environments?: InputArray<ScriptEnvironment>;
23
+ /**
24
+ * The volume to backup.
25
+ */
26
+ volume?: Input<WorkloadVolume>;
27
+ /**
28
+ * The sup path of the volume to restore/backup.
29
+ */
30
+ subPath?: Input<string>;
31
+ /**
32
+ * The schedule for the backup job.
33
+ *
34
+ * By default, the backup job runs every day at midnight.
35
+ */
36
+ schedule?: Input<string>;
37
+ /**
38
+ * The extra options to pass to the backup script.
39
+ */
40
+ backupOptions?: InputArray<string>;
41
+ /**
42
+ * The extra options for the backup container.
43
+ */
44
+ backupContainer?: Input<Container>;
45
+ /**
46
+ * The extra options for the restore container.
47
+ */
48
+ restoreContainer?: Input<Container>;
49
+ /**
50
+ * The distribution to use for the scripts.
51
+ *
52
+ * By default, the distribution is `alpine`.
53
+ *
54
+ * You can also use `ubuntu` if you need to install packages that are not available (or working) in Alpine.
55
+ */
56
+ distribution?: ScriptDistribution;
57
+ };
58
+ declare class BackupJobPair extends ComponentResource {
59
+ private readonly name;
60
+ private readonly opts?;
61
+ /**
62
+ * The credentials used to access the repository and encrypt the backups.
63
+ */
64
+ readonly credentials: Output<core.v1.Secret>;
65
+ /**
66
+ * The script bundle used by the backup and restore jobs.
67
+ */
68
+ readonly scriptBundle: Output<ScriptBundle>;
69
+ /**
70
+ * The job resource which restores the volume from the backup before creating an application.
71
+ */
72
+ readonly restoreJob: Output<Job>;
73
+ /**
74
+ * The cron job resource which backups the volume regularly.
75
+ */
76
+ readonly backupJob: Output<CronJob>;
77
+ constructor(name: string, args: BackupJobPairArgs, opts?: ComponentResourceOptions | undefined);
78
+ handleTrigger(triggers: InstanceTriggerInvocation[]): InstanceTrigger | undefined;
79
+ private createBackupOnDestroyJob;
80
+ }
81
+
82
+ export { BackupJobPair, type BackupJobPairArgs };
package/dist/index.js ADDED
@@ -0,0 +1,267 @@
1
+ import { core, batch } from '@pulumi/kubernetes';
2
+ import { mapMetadata, ScriptBundle, Job, createScriptContainer, CronJob } from '@highstate/k8s';
3
+ import { ComponentResource, output, normalize } from '@highstate/pulumi';
4
+
5
+ function text(array, ...values) {
6
+ const str = array.reduce(
7
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
8
+ (result, part, i) => result + part + (values[i] ? String(values[i]) : ""),
9
+ ""
10
+ );
11
+ return trimIndentation(str);
12
+ }
13
+ function trimIndentation(text2) {
14
+ const lines = text2.split("\n");
15
+ const indent = lines.filter((line) => line.trim() !== "").map((line) => line.match(/^\s*/)?.[0].length ?? 0).reduce((min, indent2) => Math.min(min, indent2), Infinity);
16
+ return lines.map((line) => line.slice(indent)).join("\n").trim();
17
+ }
18
+
19
+ const backupEnvironment = {
20
+ alpine: {
21
+ packages: ["restic"]
22
+ },
23
+ ubuntu: {
24
+ packages: ["restic"]
25
+ },
26
+ scripts: {
27
+ "backup.sh": text`
28
+ #!/bin/sh
29
+ set -e
30
+
31
+ # Init the repo if it doesn't exist
32
+ echo "| Checking the repository"
33
+ if restic snapshots > /dev/null 2>&1; then
34
+ echo "| Repository is ready"
35
+ else
36
+ echo "| Initializing new repository"
37
+ restic init
38
+ fi
39
+
40
+ # Execute lock script if it exists
41
+ if [ -f /scripts/lock.sh ]; then
42
+ /scripts/lock.sh || (echo "| error: lock script failed" && exit 1)
43
+ fi
44
+
45
+ # Unlock the data source on exit
46
+ if [ -f /scripts/unlock.sh ]; then
47
+ trap "echo '/scripts/unlock.sh || (echo '| error: unlock script failed' && exit 1)" EXIT
48
+ fi
49
+
50
+ # Perform online backup if the corresponding script exists
51
+ if [ -f /scripts/online-backup.sh ]; then
52
+ /scripts/online-backup.sh || (echo "| error: online backup script failed" && exit 1)
53
+ fi
54
+
55
+ # Backup the volume
56
+ echo "| Backing up /data"
57
+ restic backup -H "$RESTIC_HOSTNAME" /data $EXTRA_BACKUP_OPTIONS
58
+
59
+ # Forget old snapshots
60
+ echo "| Forgetting old snapshots"
61
+ restic forget --host "$RESTIC_HOSTNAME" --keep-daily 7 --keep-weekly 4 --keep-monthly 6
62
+ echo "| Backup complete"
63
+ `,
64
+ "restore.sh": text`
65
+ #!/bin/sh
66
+ set -e
67
+
68
+ # Check if /data is empty
69
+ echo "| Checking if volume is empty"
70
+ if [ "$(find /data -type f -print -quit 2>/dev/null)" ]; then
71
+ echo "| Volume is not empty. Skipping restore."
72
+ exit 0
73
+ fi
74
+
75
+ # Check if at least one snapshot exists
76
+ echo "| Checking for snapshots"
77
+ if ! result=$(restic list snapshots); then
78
+ echo "| No snapshots found. Skipping restore."
79
+ exit 0
80
+ fi
81
+
82
+ if [ -z "$result" ]; then
83
+ echo "| No snapshots found. Skipping restore."
84
+ exit 0
85
+ fi
86
+
87
+ # Restore the volume
88
+ echo "| Restoring /data"
89
+ restic restore -H "$RESTIC_HOSTNAME" latest --target /
90
+ echo "| Volume restored."
91
+
92
+ # Post-restore script
93
+ if [ -f /scripts/post-restore.sh ]; then
94
+ /scripts/post-restore.sh || (echo "| error: post-restore script failed" && exit 1)
95
+ fi
96
+ `
97
+ }
98
+ };
99
+
100
+ class BackupJobPair extends ComponentResource {
101
+ constructor(name, args, opts) {
102
+ super("highstate:restic:BackupJobPair", name, args, opts);
103
+ this.name = name;
104
+ this.opts = opts;
105
+ this.credentials = output(args).apply((args2) => {
106
+ return new core.v1.Secret(
107
+ `${name}-backup-credentials`,
108
+ {
109
+ metadata: mapMetadata(args2, `${name}-backup-credentials`),
110
+ stringData: {
111
+ password: args2.resticRepo.password,
112
+ "rclone.conf": args2.resticRepo.rcloneConfig
113
+ }
114
+ },
115
+ { parent: this, ...opts }
116
+ );
117
+ });
118
+ const environment = output(args).apply((args2) => {
119
+ return {
120
+ alpine: {
121
+ packages: ["rclone"]
122
+ },
123
+ ubuntu: {
124
+ preInstallPackages: ["curl", "unzip"],
125
+ preInstallScripts: {
126
+ "rclone.sh": text`
127
+ #!/bin/sh
128
+ set -e
129
+
130
+ curl https://rclone.org/install.sh | bash
131
+ `
132
+ }
133
+ },
134
+ environment: {
135
+ RESTIC_REPOSITORY: `rclone:${args2.resticRepo.remoteName}:${args2.resticRepo.basePath}/${args2.k8sCluster.name}/${name}`,
136
+ RESTIC_PASSWORD_FILE: "/credentials/password",
137
+ RESTIC_HOSTNAME: "default",
138
+ RCLONE_CONFIG: "/credentials/rclone.conf",
139
+ EXTRA_BACKUP_OPTIONS: args2.backupOptions?.join(" ")
140
+ },
141
+ volumes: [this.credentials, ...args2.volume ? [args2.volume] : []],
142
+ volumeMounts: [
143
+ {
144
+ volume: this.credentials,
145
+ mountPath: "/credentials",
146
+ readOnly: true
147
+ },
148
+ ...args2.volume ? [
149
+ {
150
+ volume: args2.volume,
151
+ mountPath: "/data",
152
+ subPath: args2.subPath
153
+ }
154
+ ] : []
155
+ ]
156
+ };
157
+ });
158
+ this.scriptBundle = output(args).apply((args2) => {
159
+ return new ScriptBundle(
160
+ `${name}-backup-scripts`,
161
+ {
162
+ namespace: args2.namespace,
163
+ distribution: args2.distribution ?? "alpine",
164
+ environments: [
165
+ backupEnvironment,
166
+ environment,
167
+ ...normalize(args2.environment, args2.environments)
168
+ ]
169
+ },
170
+ { parent: this, ...opts }
171
+ );
172
+ });
173
+ this.restoreJob = output(args).apply((args2) => {
174
+ return new Job(
175
+ `${name}-restore`,
176
+ {
177
+ namespace: args2.namespace,
178
+ container: createScriptContainer({
179
+ ...args2.restoreContainer,
180
+ main: "restore.sh",
181
+ bundle: this.scriptBundle
182
+ })
183
+ },
184
+ { parent: this, ...opts }
185
+ );
186
+ });
187
+ this.backupJob = output(args).apply((args2) => {
188
+ return new CronJob(
189
+ `${name}-backup`,
190
+ {
191
+ namespace: args2.namespace,
192
+ container: createScriptContainer({
193
+ ...args2.backupContainer,
194
+ main: "backup.sh",
195
+ bundle: this.scriptBundle
196
+ }),
197
+ schedule: args2.schedule ?? "0 0 * * *",
198
+ concurrencyPolicy: "Forbid",
199
+ jobTemplate: {
200
+ spec: {
201
+ backoffLimit: 1,
202
+ template: {
203
+ spec: {
204
+ restartPolicy: "Never"
205
+ }
206
+ }
207
+ }
208
+ }
209
+ },
210
+ { parent: this, ...opts }
211
+ );
212
+ });
213
+ this.registerOutputs({
214
+ credentials: this.credentials,
215
+ scriptBundle: this.scriptBundle,
216
+ restoreJob: this.restoreJob,
217
+ backupJob: this.backupJob
218
+ });
219
+ }
220
+ /**
221
+ * The credentials used to access the repository and encrypt the backups.
222
+ */
223
+ credentials;
224
+ /**
225
+ * The script bundle used by the backup and restore jobs.
226
+ */
227
+ scriptBundle;
228
+ /**
229
+ * The job resource which restores the volume from the backup before creating an application.
230
+ */
231
+ restoreJob;
232
+ /**
233
+ * The cron job resource which backups the volume regularly.
234
+ */
235
+ backupJob;
236
+ handleTrigger(triggers) {
237
+ const triggerName = `restic.backup-on-destroy.${this.name}`;
238
+ const invokedTrigger = triggers.find((trigger) => trigger.name === triggerName);
239
+ if (invokedTrigger) {
240
+ this.createBackupOnDestroyJob();
241
+ return;
242
+ }
243
+ return {
244
+ name: triggerName,
245
+ title: "Backup on Destroy",
246
+ description: `Backup the "${this.name}" before destroying.`,
247
+ spec: {
248
+ type: "before-destroy"
249
+ }
250
+ };
251
+ }
252
+ createBackupOnDestroyJob() {
253
+ new batch.v1.Job(
254
+ `${this.name}-backup-on-destroy`,
255
+ {
256
+ metadata: {
257
+ name: `${this.name}-backup-on-destroy`,
258
+ namespace: this.backupJob.cronJob.metadata.namespace
259
+ },
260
+ spec: this.backupJob.cronJob.spec.jobTemplate.spec
261
+ },
262
+ { parent: this, ...this.opts }
263
+ );
264
+ }
265
+ }
266
+
267
+ export { BackupJobPair };
@@ -0,0 +1,33 @@
1
+ import { restic } from '@highstate/library';
2
+ import { forUnit, getOrCreateSecret } from '@highstate/pulumi';
3
+ import { generatePassword } from '@highstate/common';
4
+
5
+ const { args, secrets, outputs } = forUnit(restic.repo);
6
+ const remoteName = secrets.rcloneConfig.apply((config) => {
7
+ const remoteNames = Array.from(config.matchAll(/(?<=\[).+?(?=\])/g));
8
+ if (remoteNames.length === 0) {
9
+ throw new Error("No remotes found in rclone config");
10
+ }
11
+ if (remoteNames.length > 1) {
12
+ throw new Error("Multiple remotes found in rclone config");
13
+ }
14
+ return remoteNames[0][0];
15
+ });
16
+ const password = getOrCreateSecret(secrets, "password", generatePassword);
17
+ const basePath = args.basePath?.replace(/\/$/, "") ?? "backups";
18
+ var index = outputs({
19
+ repo: {
20
+ password,
21
+ type: "rclone",
22
+ rcloneConfig: secrets.rcloneConfig,
23
+ remoteName,
24
+ remoteDomains: args.remoteDomains ?? [],
25
+ basePath
26
+ },
27
+ $status: {
28
+ remoteName,
29
+ basePath
30
+ }
31
+ });
32
+
33
+ export { index as default };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@highstate/restic",
3
+ "version": "0.4.5",
4
+ "type": "module",
5
+ "files": [
6
+ "assets",
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "default": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./repo": {
15
+ "default": "./dist/repo/index.js"
16
+ }
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "pkgroll --tsconfig=tsconfig.build.json"
23
+ },
24
+ "dependencies": {
25
+ "@highstate/common": "^0.4.5",
26
+ "@highstate/k8s": "^0.4.5",
27
+ "@highstate/pulumi": "^0.4.5",
28
+ "@pulumi/kubernetes": "^4.18.0"
29
+ },
30
+ "peerDependencies": {
31
+ "@highstate/library": "workspace:^0.4.4"
32
+ },
33
+ "devDependencies": {
34
+ "pkgroll": "^2.5.1"
35
+ },
36
+ "gitHead": "afd601fdade1bcf31af58072eea3c08ee26349b8"
37
+ }