mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
11 Commits
v0.9.0-bet
...
altendorfm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c2791102f | ||
|
|
b70f973c12 | ||
|
|
14dadc85e7 | ||
|
|
ff16c6914d | ||
|
|
b333489ae6 | ||
|
|
0efe57b62e | ||
|
|
0b6f64e16d | ||
|
|
eb28667d90 | ||
|
|
c0bef7f65e | ||
|
|
29c96c9fc6 | ||
|
|
2c0f22af59 |
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -77,7 +77,8 @@ jobs:
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-images]
|
||||
needs: [build-images, determine-release-type]
|
||||
if: needs.determine-release-type.outputs.release_type == 'release'
|
||||
outputs:
|
||||
id: ${{ steps.create_release.outputs.id }}
|
||||
steps:
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 \
|
||||
mariadb-client \
|
||||
mysql-client \
|
||||
postgresql-client
|
||||
|
||||
# ------------------------------
|
||||
# DEPENDENCIES
|
||||
|
||||
10
README.md
10
README.md
@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
||||
```yaml
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
@@ -68,7 +68,7 @@ If you want to track a local directory on the same server where Ironmount is run
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
@@ -133,7 +133,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
@@ -189,7 +189,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -217,7 +217,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetErrors, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
@@ -422,7 +422,7 @@ export const stopBackup = <ThrowOnError extends boolean = false>(options: Option
|
||||
* Manually apply retention policy to clean up old snapshots
|
||||
*/
|
||||
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<RunForgetResponses, RunForgetErrors, ThrowOnError>({
|
||||
return (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}/forget',
|
||||
...options
|
||||
});
|
||||
|
||||
@@ -157,6 +157,22 @@ export type ListVolumesResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -164,6 +180,15 @@ export type ListVolumesResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -190,7 +215,7 @@ export type ListVolumesResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
@@ -203,6 +228,22 @@ export type CreateVolumeData = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -210,6 +251,15 @@ export type CreateVolumeData = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -247,6 +297,22 @@ export type CreateVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -254,6 +320,15 @@ export type CreateVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -280,7 +355,7 @@ export type CreateVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -293,6 +368,22 @@ export type TestConnectionData = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -300,6 +391,15 @@ export type TestConnectionData = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -390,6 +490,22 @@ export type GetVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -397,6 +513,15 @@ export type GetVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -423,7 +548,7 @@ export type GetVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -438,6 +563,22 @@ export type UpdateVolumeData = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -445,6 +586,15 @@ export type UpdateVolumeData = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -490,6 +640,22 @@ export type UpdateVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -497,6 +663,15 @@ export type UpdateVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -523,7 +698,7 @@ export type UpdateVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -711,30 +886,42 @@ export type ListRepositoriesResponses = {
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -757,30 +944,42 @@ export type CreateRepositoryData = {
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
name: string;
|
||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
||||
@@ -865,30 +1064,42 @@ export type GetRepositoryResponses = {
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1079,30 +1290,42 @@ export type ListBackupSchedulesResponses = {
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1130,6 +1353,22 @@ export type ListBackupSchedulesResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -1137,6 +1376,15 @@ export type ListBackupSchedulesResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -1163,7 +1411,7 @@ export type ListBackupSchedulesResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
volumeId: number;
|
||||
@@ -1280,30 +1528,42 @@ export type GetBackupScheduleResponses = {
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1331,6 +1591,22 @@ export type GetBackupScheduleResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -1338,6 +1614,15 @@ export type GetBackupScheduleResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -1364,7 +1649,7 @@ export type GetBackupScheduleResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
volumeId: number;
|
||||
@@ -1462,30 +1747,42 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1513,6 +1810,22 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -1520,6 +1833,15 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -1546,7 +1868,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
volumeId: number;
|
||||
@@ -1611,13 +1933,6 @@ export type RunForgetData = {
|
||||
url: '/api/v1/backups/{scheduleId}/forget';
|
||||
};
|
||||
|
||||
export type RunForgetErrors = {
|
||||
/**
|
||||
* No retention policy configured for this schedule
|
||||
*/
|
||||
400: unknown;
|
||||
};
|
||||
|
||||
export type RunForgetResponses = {
|
||||
/**
|
||||
* Retention policy applied successfully
|
||||
|
||||
@@ -67,8 +67,11 @@ export const CreateRepositoryForm = ({
|
||||
|
||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
||||
...listRcloneRemotesOptions(),
|
||||
enabled: capabilities.rclone,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,8 +83,6 @@ export const CreateRepositoryForm = ({
|
||||
});
|
||||
}, [watchedBackend, form]);
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cloud, Folder, Server, Share2 } from "lucide-react";
|
||||
import { Cloud, Database, Folder, Server, Share2 } from "lucide-react";
|
||||
import type { BackendType } from "~/schemas/volumes";
|
||||
|
||||
type VolumeIconProps = {
|
||||
@@ -32,6 +32,24 @@ const getIconAndColor = (backend: BackendType) => {
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
label: "WebDAV",
|
||||
};
|
||||
case "mariadb":
|
||||
return {
|
||||
icon: Database,
|
||||
color: "text-teal-600 dark:text-teal-400",
|
||||
label: "MariaDB",
|
||||
};
|
||||
case "mysql":
|
||||
return {
|
||||
icon: Database,
|
||||
color: "text-cyan-600 dark:text-cyan-400",
|
||||
label: "MySQL",
|
||||
};
|
||||
case "postgres":
|
||||
return {
|
||||
icon: Database,
|
||||
color: "text-indigo-600 dark:text-indigo-400",
|
||||
label: "PostgreSQL",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Folder,
|
||||
|
||||
@@ -6,13 +6,31 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { DirectoryBrowser } from "./directory-browser";
|
||||
import { Button } from "./ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { volumeConfigSchema } from "~/schemas/volumes";
|
||||
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
|
||||
import { testConnectionMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/client/components/ui/select";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { DirectoryBrowser } from "~/client/components/directory-browser";
|
||||
|
||||
const SUPPORTS_CONNECTION_TEST = ["nfs", "smb", "webdav", "mariadb", "mysql", "postgres"];
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
@@ -35,6 +53,9 @@ const defaultValuesForType = {
|
||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
||||
mariadb: { backend: "mariadb" as const, port: 3306 },
|
||||
mysql: { backend: "mysql" as const, port: 3306 },
|
||||
postgres: { backend: "postgres" as const, port: 5432, dumpFormat: "custom" as const },
|
||||
};
|
||||
|
||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||
@@ -81,7 +102,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
const handleTestConnection = async () => {
|
||||
const formValues = getValues();
|
||||
|
||||
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
||||
if (SUPPORTS_CONNECTION_TEST.includes(formValues.backend)) {
|
||||
testBackendConnection.mutate({
|
||||
body: { config: formValues },
|
||||
});
|
||||
@@ -121,15 +142,26 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
<FormLabel>Backend</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select a backend" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Network Storage</SelectLabel>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Databases</SelectLabel>
|
||||
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
||||
@@ -536,6 +568,258 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "mariadb" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="localhost" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MariaDB server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
defaultValue={3306}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3306" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MariaDB server port (default: 3306).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Database user with backup privileges.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for database authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myapp_production" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Name of the database to backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "mysql" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="localhost" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MySQL server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
defaultValue={3306}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3306" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MySQL server port (default: 3306).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Database user with backup privileges.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for database authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myapp_production" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Name of the database to backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "postgres" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="localhost" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>PostgreSQL server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
defaultValue={5432}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="5432" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>PostgreSQL server port (default: 5432).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="postgres" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Database user with backup privileges.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for database authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myapp_production" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Name of the database to backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dumpFormat"
|
||||
defaultValue="custom"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Dump Format</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value || "custom"}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select dump format" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">Custom (Compressed)</SelectItem>
|
||||
<SelectItem value="plain">Plain SQL</SelectItem>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Format for database dumps (custom recommended).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend !== "directory" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -4,12 +4,12 @@ import { useId } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { Route } from "./+types/create-volume";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
||||
|
||||
@@ -119,6 +119,8 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { volume, statfs } = data;
|
||||
const dockerAvailable = capabilities.docker;
|
||||
|
||||
const isDatabaseVolume = ["mariadb", "mysql", "postgres"].includes(volume.config.backend);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
@@ -152,7 +154,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger disabled={isDatabaseVolume} value="files">
|
||||
Files
|
||||
</TabsTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
||||
@@ -167,9 +171,11 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<TabsContent value="info">
|
||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||
</TabsContent>
|
||||
{!isDatabaseVolume && (
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{dockerAvailable && (
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
|
||||
@@ -109,6 +109,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,6 +16,7 @@ import type { StatFs, Volume } from "~/client/lib/types";
|
||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||
import { StorageChart } from "../components/storage-chart";
|
||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
|
||||
@@ -5,6 +5,9 @@ export const BACKEND_TYPES = {
|
||||
smb: "smb",
|
||||
directory: "directory",
|
||||
webdav: "webdav",
|
||||
mariadb: "mariadb",
|
||||
mysql: "mysql",
|
||||
postgres: "postgres",
|
||||
} as const;
|
||||
|
||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||
@@ -47,7 +50,47 @@ export const webdavConfigSchema = type({
|
||||
ssl: "boolean?",
|
||||
});
|
||||
|
||||
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
||||
export const mariadbConfigSchema = type({
|
||||
backend: "'mariadb'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
|
||||
username: "string",
|
||||
password: "string",
|
||||
database: "string",
|
||||
dumpOptions: "string[]?",
|
||||
readOnly: "false?",
|
||||
});
|
||||
|
||||
export const mysqlConfigSchema = type({
|
||||
backend: "'mysql'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
|
||||
username: "string",
|
||||
password: "string",
|
||||
database: "string",
|
||||
dumpOptions: "string[]?",
|
||||
readOnly: "false?",
|
||||
});
|
||||
|
||||
export const postgresConfigSchema = type({
|
||||
backend: "'postgres'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(5432),
|
||||
username: "string",
|
||||
password: "string",
|
||||
database: "string",
|
||||
dumpFormat: type("'plain' | 'custom' | 'directory'").default("custom"),
|
||||
dumpOptions: "string[]?",
|
||||
readOnly: "false?",
|
||||
});
|
||||
|
||||
export const volumeConfigSchema = nfsConfigSchema
|
||||
.or(smbConfigSchema)
|
||||
.or(webdavConfigSchema)
|
||||
.or(directoryConfigSchema)
|
||||
.or(mariadbConfigSchema)
|
||||
.or(mysqlConfigSchema)
|
||||
.or(postgresConfigSchema);
|
||||
|
||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { volumeService } from "../modules/volumes/volume.service";
|
||||
import { readMountInfo } from "../utils/mountinfo";
|
||||
import { getVolumePath } from "../modules/volumes/helpers";
|
||||
import { createVolumeBackend } from "../modules/backends/backend";
|
||||
import { logger } from "../utils/logger";
|
||||
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
||||
import { toMessage } from "../utils/errors";
|
||||
@@ -16,7 +16,11 @@ export class CleanupDanglingMountsJob extends Job {
|
||||
|
||||
for (const mount of allSystemMounts) {
|
||||
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
||||
const matchingVolume = allVolumes.find((v) => {
|
||||
const backend = createVolumeBackend(v);
|
||||
return backend.getVolumePath() === mount.mountPoint;
|
||||
});
|
||||
|
||||
if (!matchingVolume) {
|
||||
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||
await executeUnmount(mount.mountPoint).catch((err) => {
|
||||
@@ -36,7 +40,10 @@ export class CleanupDanglingMountsJob extends Job {
|
||||
|
||||
for (const dir of allIronmountDirs) {
|
||||
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
|
||||
const matchingVolume = allVolumes.find((v) => {
|
||||
const backend = createVolumeBackend(v);
|
||||
return backend.getVolumePath() === volumePath;
|
||||
});
|
||||
if (!matchingVolume) {
|
||||
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
|
||||
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { BackendStatus } from "~/schemas/volumes";
|
||||
import type { Volume } from "../../db/schema";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { makeDirectoryBackend } from "./directory/directory-backend";
|
||||
import { makeNfsBackend } from "./nfs/nfs-backend";
|
||||
import { makeSmbBackend } from "./smb/smb-backend";
|
||||
import { makeWebdavBackend } from "./webdav/webdav-backend";
|
||||
import { makeMariaDBBackend } from "./mariadb/mariadb-backend";
|
||||
import { makeMySQLBackend } from "./mysql/mysql-backend";
|
||||
import { makePostgresBackend } from "./postgres/postgres-backend";
|
||||
|
||||
type OperationResult = {
|
||||
error?: string;
|
||||
@@ -15,23 +17,35 @@ export type VolumeBackend = {
|
||||
mount: () => Promise<OperationResult>;
|
||||
unmount: () => Promise<OperationResult>;
|
||||
checkHealth: () => Promise<OperationResult>;
|
||||
getVolumePath: () => string;
|
||||
getBackupPath: () => Promise<string>;
|
||||
};
|
||||
|
||||
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||
const path = getVolumePath(volume);
|
||||
|
||||
switch (volume.config.backend) {
|
||||
case "nfs": {
|
||||
return makeNfsBackend(volume.config, path);
|
||||
return makeNfsBackend(volume.config, volume.name);
|
||||
}
|
||||
case "smb": {
|
||||
return makeSmbBackend(volume.config, path);
|
||||
return makeSmbBackend(volume.config, volume.name);
|
||||
}
|
||||
case "directory": {
|
||||
return makeDirectoryBackend(volume.config, path);
|
||||
return makeDirectoryBackend(volume.config, volume.name);
|
||||
}
|
||||
case "webdav": {
|
||||
return makeWebdavBackend(volume.config, path);
|
||||
return makeWebdavBackend(volume.config, volume.name);
|
||||
}
|
||||
case "mariadb": {
|
||||
return makeMariaDBBackend(volume.config);
|
||||
}
|
||||
case "mysql": {
|
||||
return makeMySQLBackend(volume.config);
|
||||
}
|
||||
case "postgres": {
|
||||
return makePostgresBackend(volume.config);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported backend type: ${(volume.config as any).backend}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,8 +52,18 @@ const checkHealth = async (config: BackendConfig) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getVolumePath = (config: BackendConfig): string => {
|
||||
if (config.backend !== "directory") {
|
||||
throw new Error("Invalid backend type");
|
||||
}
|
||||
|
||||
return config.path;
|
||||
};
|
||||
|
||||
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
|
||||
mount: () => mount(config, volumePath),
|
||||
unmount,
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => getVolumePath(config),
|
||||
getBackupPath: async () => getVolumePath(config),
|
||||
});
|
||||
|
||||
81
app/server/modules/backends/mariadb/mariadb-backend.ts
Normal file
81
app/server/modules/backends/mariadb/mariadb-backend.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { $ } from "bun";
|
||||
|
||||
const checkHealth = async (config: BackendConfig) => {
|
||||
if (config.backend !== "mariadb") {
|
||||
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Testing MariaDB connection to: ${config.host}:${config.port}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--database=${config.database}`,
|
||||
"--skip-ssl",
|
||||
"--execute=SELECT 1",
|
||||
];
|
||||
|
||||
const env = {
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
await $`mariadb ${args.join(" ")}`.env(env);
|
||||
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
} catch (error) {
|
||||
logger.error("MariaDB health check failed:", error);
|
||||
return { status: BACKEND_STATUS.error, error: toMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const getBackupPath = async (config: BackendConfig) => {
|
||||
const dumpDir = await fs.mkdtemp(`/tmp/ironmount-mariadb-`);
|
||||
|
||||
if (config.backend !== "mariadb") {
|
||||
throw new Error("Invalid backend type for MariaDB dump");
|
||||
}
|
||||
|
||||
logger.info(`Starting MariaDB dump for database: ${config.database}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--skip-ssl`,
|
||||
`--single-transaction`,
|
||||
`--quick`,
|
||||
`--lock-tables=false`,
|
||||
...(config.dumpOptions || []),
|
||||
config.database,
|
||||
];
|
||||
|
||||
const env = {
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
const result = await $`mariadb-dump ${args}`.env(env).nothrow();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`mariadb-dump failed with exit code ${result.exitCode}: ${result.stderr}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(`${dumpDir}/dump.sql`, result.stdout);
|
||||
logger.info(`MariaDB dump completed: ${dumpDir}/dump.sql`);
|
||||
|
||||
return `${dumpDir}/dump.sql`;
|
||||
};
|
||||
|
||||
export const makeMariaDBBackend = (config: BackendConfig): VolumeBackend => ({
|
||||
mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }),
|
||||
unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }),
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => "/tmp",
|
||||
getBackupPath: () => getBackupPath(config),
|
||||
});
|
||||
76
app/server/modules/backends/mysql/mysql-backend.ts
Normal file
76
app/server/modules/backends/mysql/mysql-backend.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { $ } from "bun";
|
||||
|
||||
const checkHealth = async (config: BackendConfig) => {
|
||||
if (config.backend !== "mysql") {
|
||||
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||
}
|
||||
|
||||
logger.debug(`Testing MySQL connection to: ${config.host}:${config.port}`);
|
||||
try {
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--database=${config.database}`,
|
||||
"--skip-ssl",
|
||||
"--execute=SELECT 1",
|
||||
];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
await $`mysql ${args.join(" ")}`.env(env);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
} catch (error) {
|
||||
logger.error("MySQL health check failed:", error);
|
||||
return { status: BACKEND_STATUS.error, error: toMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const getBackupPath = async (config: BackendConfig) => {
|
||||
if (config.backend !== "mysql") {
|
||||
throw new Error("Invalid backend type");
|
||||
}
|
||||
|
||||
logger.info(`Starting MySQL dump for database: ${config.database}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--skip-ssl`,
|
||||
`--single-transaction`,
|
||||
`--quick`,
|
||||
`--lock-tables=false`,
|
||||
...(config.dumpOptions || []),
|
||||
config.database,
|
||||
];
|
||||
|
||||
const env = {
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
const result = await $`mysql ${args}`.env(env).nothrow();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`MySQL dump failed: ${result.stderr}`);
|
||||
}
|
||||
|
||||
console.log(result.stdout);
|
||||
|
||||
return "Nothing for now";
|
||||
};
|
||||
|
||||
export const makeMySQLBackend = (config: BackendConfig): VolumeBackend => ({
|
||||
mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }),
|
||||
unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }),
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => "/tmp",
|
||||
getBackupPath: () => getBackupPath(config),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
@@ -9,7 +9,8 @@ import type { VolumeBackend } from "../backend";
|
||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
const mount = async (config: BackendConfig, name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
logger.debug(`Mounting volume ${path}...`);
|
||||
|
||||
if (config.backend !== "nfs") {
|
||||
@@ -22,13 +23,13 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
|
||||
}
|
||||
|
||||
const { status } = await checkHealth(path, config.readOnly ?? false);
|
||||
const { status } = await checkHealth(name, config.readOnly ?? false);
|
||||
if (status === "mounted") {
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
}
|
||||
|
||||
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
||||
await unmount(path);
|
||||
await unmount(name);
|
||||
|
||||
const run = async () => {
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
@@ -57,7 +58,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unmount = async (path: string) => {
|
||||
const unmount = async (name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
if (os.platform() !== "linux") {
|
||||
logger.error("NFS unmounting is only supported on Linux hosts.");
|
||||
return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." };
|
||||
@@ -87,7 +90,9 @@ const unmount = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (path: string, readOnly: boolean) => {
|
||||
const checkHealth = async (name: string, readOnly: boolean) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
const run = async () => {
|
||||
logger.debug(`Checking health of NFS volume at ${path}...`);
|
||||
await fs.access(path);
|
||||
@@ -114,8 +119,14 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||
mount: () => mount(config, path),
|
||||
unmount: () => unmount(path),
|
||||
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||
const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
|
||||
export const makeNfsBackend = (config: BackendConfig, name: string): VolumeBackend => ({
|
||||
mount: () => mount(config, name),
|
||||
unmount: () => unmount(name),
|
||||
checkHealth: () => checkHealth(name, config.readOnly ?? false),
|
||||
getVolumePath: () => getVolumePath(name),
|
||||
getBackupPath: async () => getVolumePath(name),
|
||||
});
|
||||
|
||||
80
app/server/modules/backends/postgres/postgres-backend.ts
Normal file
80
app/server/modules/backends/postgres/postgres-backend.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { $ } from "bun";
|
||||
|
||||
const checkHealth = async (config: BackendConfig) => {
|
||||
if (config.backend !== "postgres") {
|
||||
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||
}
|
||||
|
||||
if (config.backend !== "postgres") {
|
||||
throw new Error("Invalid backend type for PostgreSQL connection test");
|
||||
}
|
||||
|
||||
logger.debug(`Testing PostgreSQL connection to: ${config.host}:${config.port}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--username=${config.username}`,
|
||||
`--dbname=${config.database}`,
|
||||
"--command=SELECT 1",
|
||||
"--no-password",
|
||||
];
|
||||
|
||||
const env = {
|
||||
PGPASSWORD: config.password,
|
||||
PGSSLMODE: "disable",
|
||||
};
|
||||
|
||||
logger.debug(`Running psql with args: ${args.join(" ")}`);
|
||||
const res = await $`psql ${args}`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
return { status: BACKEND_STATUS.error, error: res.stderr.toString() };
|
||||
}
|
||||
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
|
||||
const getBackupPath = async (config: BackendConfig) => {
|
||||
if (config.backend !== "postgres") {
|
||||
throw new Error("Invalid backend type for PostgreSQL dump");
|
||||
}
|
||||
|
||||
const dumpDir = await fs.mkdtemp(`/tmp/ironmount-postgres-`);
|
||||
const outputPath = `${dumpDir}/${config.dumpFormat === "plain" ? "dump.sql" : "dump.dump"}`;
|
||||
|
||||
logger.info(`Starting PostgreSQL dump for database: ${config.database}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--username=${config.username}`,
|
||||
`--dbname=${config.database}`,
|
||||
`--format=${config.dumpFormat}`,
|
||||
`--file=${outputPath}`,
|
||||
"--no-password",
|
||||
...(config.dumpOptions || []),
|
||||
];
|
||||
|
||||
const env = {
|
||||
PGPASSWORD: config.password,
|
||||
PGSSLMODE: "disable",
|
||||
};
|
||||
|
||||
await $`pg_dump ${args}`.env(env);
|
||||
|
||||
return outputPath;
|
||||
};
|
||||
|
||||
export const makePostgresBackend = (config: BackendConfig): VolumeBackend => ({
|
||||
mount: () => Promise.resolve({ status: "mounted" }),
|
||||
unmount: () => Promise.resolve({ status: "unmounted" }),
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => "/tmp",
|
||||
getBackupPath: () => getBackupPath(config),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
@@ -9,7 +9,8 @@ import type { VolumeBackend } from "../backend";
|
||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
const mount = async (config: BackendConfig, name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
logger.debug(`Mounting SMB volume ${path}...`);
|
||||
|
||||
if (config.backend !== "smb") {
|
||||
@@ -22,13 +23,13 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
|
||||
}
|
||||
|
||||
const { status } = await checkHealth(path, config.readOnly ?? false);
|
||||
const { status } = await checkHealth(name, config.readOnly ?? false);
|
||||
if (status === "mounted") {
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
}
|
||||
|
||||
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
||||
await unmount(path);
|
||||
await unmount(name);
|
||||
|
||||
const run = async () => {
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
@@ -70,7 +71,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unmount = async (path: string) => {
|
||||
const unmount = async (name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
if (os.platform() !== "linux") {
|
||||
logger.error("SMB unmounting is only supported on Linux hosts.");
|
||||
return { status: BACKEND_STATUS.error, error: "SMB unmounting is only supported on Linux hosts." };
|
||||
@@ -100,7 +103,9 @@ const unmount = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (path: string, readOnly: boolean) => {
|
||||
const checkHealth = async (name: string, readOnly: boolean) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
const run = async () => {
|
||||
logger.debug(`Checking health of SMB volume at ${path}...`);
|
||||
await fs.access(path);
|
||||
@@ -127,8 +132,14 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||
mount: () => mount(config, path),
|
||||
unmount: () => unmount(path),
|
||||
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||
const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
|
||||
export const makeSmbBackend = (config: BackendConfig, name: string): VolumeBackend => ({
|
||||
mount: () => mount(config, name),
|
||||
unmount: () => unmount(name),
|
||||
checkHealth: () => checkHealth(name, config.readOnly ?? false),
|
||||
getVolumePath: () => getVolumePath(name),
|
||||
getBackupPath: async () => getVolumePath(name),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execFile as execFileCb } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
@@ -13,7 +13,8 @@ import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
const mount = async (config: BackendConfig, name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
logger.debug(`Mounting WebDAV volume ${path}...`);
|
||||
|
||||
if (config.backend !== "webdav") {
|
||||
@@ -104,7 +105,8 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unmount = async (path: string) => {
|
||||
const unmount = async (name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
if (os.platform() !== "linux") {
|
||||
logger.error("WebDAV unmounting is only supported on Linux hosts.");
|
||||
return { status: BACKEND_STATUS.error, error: "WebDAV unmounting is only supported on Linux hosts." };
|
||||
@@ -134,7 +136,9 @@ const unmount = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (path: string, readOnly: boolean) => {
|
||||
const checkHealth = async (name: string, readOnly: boolean) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
const run = async () => {
|
||||
logger.debug(`Checking health of WebDAV volume at ${path}...`);
|
||||
await fs.access(path);
|
||||
@@ -161,8 +165,14 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||
mount: () => mount(config, path),
|
||||
unmount: () => unmount(path),
|
||||
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||
const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
|
||||
export const makeWebdavBackend = (config: BackendConfig, name: string): VolumeBackend => ({
|
||||
mount: () => mount(config, name),
|
||||
unmount: () => unmount(name),
|
||||
checkHealth: () => checkHealth(name, config.readOnly ?? false),
|
||||
getVolumePath: () => getVolumePath(name),
|
||||
getBackupPath: async () => getVolumePath(name),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { serverEvents } from "../../core/events";
|
||||
@@ -206,7 +206,8 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
runningBackups.set(scheduleId, abortController);
|
||||
|
||||
try {
|
||||
const volumePath = getVolumePath(volume);
|
||||
const backend = createVolumeBackend(volume);
|
||||
const backupPath = await backend.getBackupPath();
|
||||
|
||||
const backupOptions: {
|
||||
exclude?: string[];
|
||||
@@ -226,7 +227,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
backupOptions.include = schedule.includePatterns;
|
||||
}
|
||||
|
||||
await restic.backup(repository.config, volumePath, {
|
||||
await restic.backup(repository.config, backupPath, {
|
||||
...backupOptions,
|
||||
onProgress: (progress) => {
|
||||
serverEvents.emit("backup:progress", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
|
||||
export const driverController = new Hono()
|
||||
.post("/VolumeDriver.Capabilities", (c) => {
|
||||
@@ -31,9 +31,11 @@ export const driverController = new Hono()
|
||||
}
|
||||
|
||||
const volumeName = body.Name.replace(/^im-/, "");
|
||||
const { volume } = await volumeService.getVolume(volumeName);
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
return c.json({
|
||||
Mountpoint: getVolumePath(volumeName),
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
});
|
||||
})
|
||||
.post("/VolumeDriver.Unmount", (c) => {
|
||||
@@ -49,9 +51,10 @@ export const driverController = new Hono()
|
||||
}
|
||||
|
||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
return c.json({
|
||||
Mountpoint: getVolumePath(volume),
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
});
|
||||
})
|
||||
.post("/VolumeDriver.Get", async (c) => {
|
||||
@@ -62,11 +65,12 @@ export const driverController = new Hono()
|
||||
}
|
||||
|
||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
return c.json({
|
||||
Volume: {
|
||||
Name: `im-${volume.name}`,
|
||||
Mountpoint: getVolumePath(volume),
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
Status: {},
|
||||
},
|
||||
Err: "",
|
||||
@@ -75,11 +79,16 @@ export const driverController = new Hono()
|
||||
.post("/VolumeDriver.List", async (c) => {
|
||||
const volumes = await volumeService.listVolumes();
|
||||
|
||||
const res = volumes.map((volume) => ({
|
||||
let res = [];
|
||||
for (const volume of volumes) {
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
res.push({
|
||||
Name: `im-${volume.name}`,
|
||||
Mountpoint: getVolumePath(volume),
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
Status: {},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
Volumes: res,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||
import type { Volume } from "../../db/schema";
|
||||
|
||||
export const getVolumePath = (volume: Volume) => {
|
||||
if (volume.config.backend === "directory") {
|
||||
return volume.config.path;
|
||||
}
|
||||
|
||||
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
|
||||
};
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type BrowseFilesystemDto,
|
||||
} from "./volume.dto";
|
||||
import { volumeService } from "./volume.service";
|
||||
import { getVolumePath } from "./helpers";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
|
||||
export const volumeController = new Hono()
|
||||
.get("/", listVolumesDto, async (c) => {
|
||||
@@ -37,9 +37,10 @@ export const volumeController = new Hono()
|
||||
const body = c.req.valid("json");
|
||||
const res = await volumeService.createVolume(body.name, body.config);
|
||||
|
||||
const backend = createVolumeBackend(res.volume);
|
||||
const response = {
|
||||
...res.volume,
|
||||
path: getVolumePath(res.volume),
|
||||
path: backend.getVolumePath(),
|
||||
};
|
||||
|
||||
return c.json<CreateVolumeDto>(response, 201);
|
||||
@@ -60,10 +61,11 @@ export const volumeController = new Hono()
|
||||
const { name } = c.req.param();
|
||||
const res = await volumeService.getVolume(name);
|
||||
|
||||
const backend = createVolumeBackend(res.volume);
|
||||
const response = {
|
||||
volume: {
|
||||
...res.volume,
|
||||
path: getVolumePath(res.volume),
|
||||
path: backend.getVolumePath(),
|
||||
},
|
||||
statfs: {
|
||||
total: res.statfs.total ?? 0,
|
||||
@@ -85,9 +87,10 @@ export const volumeController = new Hono()
|
||||
const body = c.req.valid("json");
|
||||
const res = await volumeService.updateVolume(name, body);
|
||||
|
||||
const backend = createVolumeBackend(res.volume);
|
||||
const response = {
|
||||
...res.volume,
|
||||
path: getVolumePath(res.volume),
|
||||
path: backend.getVolumePath(),
|
||||
};
|
||||
|
||||
return c.json<UpdateVolumeDto>(response, 200);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||
import { withTimeout } from "../../utils/timeout";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
import type { UpdateVolumeBody } from "./volume.dto";
|
||||
import { getVolumePath } from "./helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { serverEvents } from "../../core/events";
|
||||
import type { BackendConfig } from "~/schemas/volumes";
|
||||
@@ -129,7 +128,9 @@ const getVolume = async (name: string) => {
|
||||
|
||||
let statfs: Partial<StatFs> = {};
|
||||
if (volume.status === "mounted") {
|
||||
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||
const backend = createVolumeBackend(volume);
|
||||
const volumePath = backend.getVolumePath();
|
||||
statfs = await withTimeout(getStatFs(volumePath), 1000, "getStatFs").catch((error) => {
|
||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||
return {};
|
||||
});
|
||||
@@ -203,7 +204,16 @@ const testConnection = async (backendConfig: BackendConfig) => {
|
||||
};
|
||||
|
||||
const backend = createVolumeBackend(mockVolume);
|
||||
const { error } = await backend.mount();
|
||||
let error: string | null = null;
|
||||
const mount = await backend.mount();
|
||||
if (mount.error) {
|
||||
error = mount.error;
|
||||
} else {
|
||||
const health = await backend.checkHealth();
|
||||
if (health.error) {
|
||||
error = health.error;
|
||||
}
|
||||
}
|
||||
|
||||
await backend.unmount();
|
||||
|
||||
@@ -295,8 +305,8 @@ const listFiles = async (name: string, subPath?: string) => {
|
||||
throw new InternalServerError("Volume is not mounted");
|
||||
}
|
||||
|
||||
// For directory volumes, use the configured path directly
|
||||
const volumePath = getVolumePath(volume);
|
||||
const backend = createVolumeBackend(volume);
|
||||
const volumePath = backend.getVolumePath();
|
||||
|
||||
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* This removes passwords and credentials from logs and error messages
|
||||
*/
|
||||
export const sanitizeSensitiveData = (text: string): string => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return text;
|
||||
}
|
||||
|
||||
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
||||
|
||||
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
||||
|
||||
@@ -5,6 +5,8 @@ interface Params {
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
stdin?: string;
|
||||
timeout?: number;
|
||||
onStdout?: (data: string) => void;
|
||||
onStderr?: (error: string) => void;
|
||||
onError?: (error: Error) => Promise<void> | void;
|
||||
@@ -19,17 +21,26 @@ type SpawnResult = {
|
||||
};
|
||||
|
||||
export const safeSpawn = (params: Params) => {
|
||||
const { command, args, env = {}, signal, ...callbacks } = params;
|
||||
const { command, args, env = {}, signal, stdin, timeout, ...callbacks } = params;
|
||||
|
||||
return new Promise<SpawnResult>((resolve) => {
|
||||
return new Promise<SpawnResult>((resolve, reject) => {
|
||||
let stdoutData = "";
|
||||
let stderrData = "";
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env: { ...process.env, ...env },
|
||||
signal: signal,
|
||||
});
|
||||
|
||||
// Handle timeout if specified
|
||||
if (timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error(`Command timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
if (callbacks.onStdout) {
|
||||
callbacks.onStdout(data.toString());
|
||||
@@ -47,6 +58,7 @@ export const safeSpawn = (params: Params) => {
|
||||
});
|
||||
|
||||
child.on("error", async (error) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (callbacks.onError) {
|
||||
await callbacks.onError(error);
|
||||
}
|
||||
@@ -62,6 +74,7 @@ export const safeSpawn = (params: Params) => {
|
||||
});
|
||||
|
||||
child.on("close", async (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (callbacks.onClose) {
|
||||
await callbacks.onClose(code);
|
||||
}
|
||||
@@ -69,11 +82,15 @@ export const safeSpawn = (params: Params) => {
|
||||
await callbacks.finally();
|
||||
}
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
reject(new Error(`Command failed with exit code ${code}: ${stderrData || stdoutData}`));
|
||||
} else {
|
||||
resolve({
|
||||
exitCode: code === null ? -1 : code,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user