mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
5 Commits
v0.11.1-be
...
feat/limit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccece42f0 | ||
|
|
043f73ea87 | ||
|
|
518700eef6 | ||
|
|
a250c442f8 | ||
|
|
6981600ad7 |
@@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.1"
|
||||
|
||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
|
||||
|
||||
|
||||
# ------------------------------
|
||||
|
||||
10
README.md
10
README.md
@@ -36,7 +36,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
||||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
@@ -72,7 +72,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
@@ -138,7 +138,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
@@ -195,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -224,7 +224,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.10
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -756,6 +756,15 @@ export type ListRepositoriesResponses = {
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -763,7 +772,7 @@ export type ListRepositoriesResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
@@ -823,6 +832,15 @@ export type CreateRepositoryData = {
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
name: string;
|
||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
||||
@@ -952,6 +970,15 @@ export type GetRepositoryResponses = {
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -959,7 +986,7 @@ export type GetRepositoryResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -1208,6 +1235,15 @@ export type ListBackupSchedulesResponses = {
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1215,7 +1251,7 @@ export type ListBackupSchedulesResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
@@ -1430,6 +1466,15 @@ export type GetBackupScheduleResponses = {
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1437,7 +1482,7 @@ export type GetBackupScheduleResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
@@ -1633,6 +1678,15 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1640,7 +1694,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
@@ -53,6 +54,7 @@ const defaultValuesForType = {
|
||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
||||
};
|
||||
|
||||
export const CreateRepositoryForm = ({
|
||||
@@ -141,6 +143,7 @@ export const CreateRepositoryForm = ({
|
||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||
<SelectItem value="rest">REST Server</SelectItem>
|
||||
<SelectItem value="sftp">SFTP</SelectItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||
@@ -268,18 +271,11 @@ export const CreateRepositoryForm = ({
|
||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowPathWarning(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
The directory where the repository will be stored.
|
||||
</FormDescription>
|
||||
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||
@@ -290,13 +286,9 @@ export const CreateRepositoryForm = ({
|
||||
Important: Host Mount Required
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-3">
|
||||
<p>
|
||||
When selecting a custom path, ensure it is mounted from the host machine into the
|
||||
container.
|
||||
</p>
|
||||
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||
<p className="font-medium">
|
||||
If the path is not a host mount, you will lose your repository data when the container
|
||||
restarts.
|
||||
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||
@@ -703,6 +695,89 @@ export const CreateRepositoryForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "sftp" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backup-user" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>SSH username for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backups/ironmount" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="privateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Save Changes
|
||||
|
||||
@@ -546,7 +546,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend !== "directory" && (
|
||||
{watchedBackend && watchedBackend !== "directory" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,7 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
||||
case "gcs":
|
||||
return <Cloud className={className} />;
|
||||
case "rest":
|
||||
case "sftp":
|
||||
return <Server className={className} />;
|
||||
default:
|
||||
return <Database className={className} />;
|
||||
|
||||
@@ -29,6 +29,7 @@ const internalFormSchema = type({
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
weeklyDay: "string?",
|
||||
limitUploadKbps: "number?",
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
@@ -86,6 +87,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
|
||||
weeklyDay,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
limitUploadKbps: schedule.limitUploadKbps || undefined,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
@@ -247,6 +249,29 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="limitUploadKbps"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Upload speed limit (KB/s)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Unlimited"
|
||||
onChange={(v) => field.onChange(v.target.value ? Number(v.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Limit upload bandwidth in kilobytes per second. Leave empty for unlimited speed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -482,6 +507,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{formValues.limitUploadKbps && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Upload speed limit</p>
|
||||
<p className="font-medium">{formValues.limitUploadKbps} KB/s</p>
|
||||
</div>
|
||||
)}
|
||||
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||
|
||||
@@ -156,6 +156,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
limitUploadKbps: formValues.limitUploadKbps,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -170,6 +171,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatterns: schedule.excludePatterns || undefined,
|
||||
limitUploadKbps: schedule.limitUploadKbps || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
limitUploadKbps: formValues.limitUploadKbps,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -181,8 +181,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This will not remove the
|
||||
actual data from the backend storage, only the repository configuration will be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
||||
1
app/drizzle/0011_lazy_havok.sql
Normal file
1
app/drizzle/0011_lazy_havok.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `backup_schedules_table` ADD `limit_upload_kbps` integer;
|
||||
466
app/drizzle/meta/0011_snapshot.json
Normal file
466
app/drizzle/meta/0011_snapshot.json
Normal file
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "3ad94485-0846-44f1-8430-44d75bf16f69",
|
||||
"prevId": "17f234ba-4123-4951-a39f-6002d537435f",
|
||||
"tables": {
|
||||
"backup_schedules_table": {
|
||||
"name": "backup_schedules_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"volume_id": {
|
||||
"name": "volume_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"cron_expression": {
|
||||
"name": "cron_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retention_policy": {
|
||||
"name": "retention_policy",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"name": "exclude_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"include_patterns": {
|
||||
"name": "include_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"limit_upload_kbps": {
|
||||
"name": "limit_upload_kbps",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_at": {
|
||||
"name": "last_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_status": {
|
||||
"name": "last_backup_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_error": {
|
||||
"name": "last_backup_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"next_backup_at": {
|
||||
"name": "next_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "volumes_table",
|
||||
"columnsFrom": [
|
||||
"volume_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"compression_mode": {
|
||||
"name": "compression_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_table_name_unique": {
|
||||
"name": "repositories_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions_table": {
|
||||
"name": "sessions_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_table_user_id_users_table_id_fk": {
|
||||
"name": "sessions_table_user_id_users_table_id_fk",
|
||||
"tableFrom": "sessions_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_downloaded_restic_password": {
|
||||
"name": "has_downloaded_restic_password",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_table_username_unique": {
|
||||
"name": "users_table_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"volumes_table": {
|
||||
"name": "volumes_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unmounted'"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_health_check": {
|
||||
"name": "last_health_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auto_remount": {
|
||||
"name": "auto_remount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"volumes_table_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,90 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763728410318,
|
||||
"tag": "0011_lazy_havok",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const REPOSITORY_BACKENDS = {
|
||||
azure: "azure",
|
||||
rclone: "rclone",
|
||||
rest: "rest",
|
||||
sftp: "sftp",
|
||||
} as const;
|
||||
|
||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||
@@ -69,13 +70,23 @@ export const restRepositoryConfigSchema = type({
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const sftpRepositoryConfigSchema = type({
|
||||
backend: "'sftp'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||
user: "string",
|
||||
path: "string",
|
||||
privateKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||
.or(r2RepositoryConfigSchema)
|
||||
.or(localRepositoryConfigSchema)
|
||||
.or(gcsRepositoryConfigSchema)
|
||||
.or(azureRepositoryConfigSchema)
|
||||
.or(rcloneRepositoryConfigSchema)
|
||||
.or(restRepositoryConfigSchema);
|
||||
.or(restRepositoryConfigSchema)
|
||||
.or(sftpRepositoryConfigSchema);
|
||||
|
||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||
}>(),
|
||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
limitUploadKbps: int("limit_upload_kbps", { mode: "number" }),
|
||||
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
|
||||
lastBackupError: text("last_backup_error"),
|
||||
|
||||
@@ -24,6 +24,7 @@ const backupScheduleSchema = type({
|
||||
retentionPolicy: retentionPolicySchema.or("null"),
|
||||
excludePatterns: "string[] | null",
|
||||
includePatterns: "string[] | null",
|
||||
limitUploadKbps: "number | null",
|
||||
lastBackupAt: "number | null",
|
||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
|
||||
lastBackupError: "string | null",
|
||||
@@ -114,6 +115,7 @@ export const createBackupScheduleBody = type({
|
||||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
limitUploadKbps: "number?",
|
||||
tags: "string[]?",
|
||||
});
|
||||
|
||||
@@ -149,6 +151,7 @@ export const updateBackupScheduleBody = type({
|
||||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
limitUploadKbps: "number?",
|
||||
tags: "string[]?",
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||
retentionPolicy: data.retentionPolicy ?? null,
|
||||
excludePatterns: data.excludePatterns ?? [],
|
||||
includePatterns: data.includePatterns ?? [],
|
||||
limitUploadKbps: data.limitUploadKbps ?? null,
|
||||
nextBackupAt: nextBackupAt,
|
||||
})
|
||||
.returning();
|
||||
@@ -212,9 +213,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
limitUploadKbps?: number | null;
|
||||
signal?: AbortSignal;
|
||||
} = {
|
||||
tags: [schedule.id.toString()],
|
||||
limitUploadKbps: schedule.limitUploadKbps,
|
||||
signal: abortController.signal,
|
||||
};
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ export const repositoriesController = new Hono()
|
||||
const { name, snapshotId } = c.req.param();
|
||||
const { path } = c.req.valid("query");
|
||||
|
||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
||||
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
|
||||
|
||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
||||
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||
}
|
||||
break;
|
||||
case "sftp":
|
||||
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
|
||||
break;
|
||||
}
|
||||
|
||||
return encryptedConfig as RepositoryConfig;
|
||||
|
||||
@@ -88,6 +88,8 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
const path = config.path ? `/${config.path}` : "";
|
||||
return `rest:${config.url}${path}`;
|
||||
}
|
||||
case "sftp":
|
||||
return `sftp:${config.user}@${config.host}:${config.path}`;
|
||||
default: {
|
||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||
}
|
||||
@@ -146,6 +148,43 @@ const buildEnv = async (config: RepositoryConfig) => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sftp": {
|
||||
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
|
||||
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
||||
|
||||
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||
if (!normalizedKey.endsWith("\n")) {
|
||||
normalizedKey += "\n";
|
||||
}
|
||||
|
||||
if (normalizedKey.includes("ENCRYPTED")) {
|
||||
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.");
|
||||
throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.");
|
||||
}
|
||||
|
||||
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
env._SFTP_KEY_PATH = keyPath;
|
||||
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"LogLevel=VERBOSE",
|
||||
"-i",
|
||||
keyPath,
|
||||
];
|
||||
|
||||
if (config.port && config.port !== 22) {
|
||||
sshArgs.push("-p", String(config.port));
|
||||
}
|
||||
|
||||
env._SFTP_SSH_ARGS = sshArgs.join(" ");
|
||||
logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -160,7 +199,11 @@ const init = async (config: RepositoryConfig) => {
|
||||
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
||||
const args = ["init", "--repo", repoUrl, "--json"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic init failed: ${res.stderr}`);
|
||||
@@ -191,6 +234,7 @@ const backup = async (
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
limitUploadKbps?: number | null;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: BackupProgress) => void;
|
||||
},
|
||||
@@ -200,6 +244,10 @@ const backup = async (
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"];
|
||||
|
||||
if (options?.limitUploadKbps) {
|
||||
args.push("--limit-upload", String(options.limitUploadKbps));
|
||||
}
|
||||
|
||||
if (options?.tags && options.tags.length > 0) {
|
||||
for (const tag of options.tags) {
|
||||
args.push("--tag", tag);
|
||||
@@ -225,6 +273,7 @@ const backup = async (
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const logData = throttle((data: string) => {
|
||||
@@ -265,6 +314,7 @@ const backup = async (
|
||||
},
|
||||
finally: async () => {
|
||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -335,11 +385,13 @@ const restore = async (
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||
@@ -397,9 +449,11 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||
@@ -445,9 +499,11 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||
}
|
||||
|
||||
args.push("--prune");
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||
@@ -462,8 +518,10 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||
@@ -510,7 +568,10 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||
@@ -518,7 +579,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||
}
|
||||
|
||||
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||
const stdout = res.text();
|
||||
const stdout = res.stdout;
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
@@ -557,7 +618,11 @@ const unlock = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
||||
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||
@@ -578,7 +643,10 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||
args.push("--read-data");
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
@@ -608,7 +676,11 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
||||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
@@ -626,6 +698,22 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
};
|
||||
};
|
||||
|
||||
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
|
||||
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
|
||||
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
|
||||
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
|
||||
Reference in New Issue
Block a user