Compare commits

...

5 Commits

Author SHA1 Message Date
Nicolas Meienberger
bf33b15b3e fix: cleanup volumes on shutdown 2025-11-10 07:08:51 +01:00
Nicolas Meienberger
2b0fea9645 fix(mounts): use bun shell instead of execFile 2025-11-10 06:52:14 +01:00
Nicolas Meienberger
e9eeda304b chore: update readme with new version 2025-11-09 15:37:16 +01:00
Nicolas Meienberger
4ddc45a74f style(create-schedule): fix explorer width on mobile 2025-11-09 14:19:34 +01:00
Nicolas Meienberger
2aa90ec44d feat: exclude patterns 2025-11-09 14:09:49 +01:00
12 changed files with 220 additions and 93 deletions

View File

@@ -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.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
privileged: true
@@ -67,7 +67,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.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -124,24 +124,21 @@ Ironmount allows you to easily restore your data from backups. To restore data,
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
In order to enable this feature, you need to run Ironmount with privileged mode and mount /proc from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
- cap_add:
- - SYS_ADMIN
+ privileged: true
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /var/lib/ironmount:/var/lib/ironmount
+ - /proc:/host/proc
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
```
Restart the Ironmount container to apply the changes:
@@ -155,24 +152,23 @@ docker compose up -d
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
In order to enable this feature, you need to run Ironmount with privileged mode and mount several items from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
- cap_add:
- - SYS_ADMIN
+ privileged: true
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /var/lib/ironmount:/var/lib/ironmount
+ - /proc:/host/proc
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins
+ - /var/run/docker.sock:/var/run/docker.sock
```

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "~/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -9,13 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/com
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { VolumeFileBrowser } from "~/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/lib/types";
import { deepClean } from "~/utils/object";
const formSchema = type({
const internalFormSchema = type({
repositoryId: "string",
excludePatterns: "string[]?",
excludePatternsText: "string?",
includePatterns: "string[]?",
frequency: "string",
dailyTime: "string?",
@@ -27,7 +28,7 @@ const formSchema = type({
keepMonthly: "number?",
keepYearly: "number?",
});
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [
{ label: "Monday", value: "1" },
@@ -39,7 +40,11 @@ export const weeklyDays = [
{ label: "Sunday", value: "0" },
];
export type BackupScheduleFormValues = typeof formSchema.infer;
type InternalFormValues = typeof internalFormSchema.infer;
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
excludePatterns?: string[];
};
type Props = {
volume: Volume;
@@ -50,7 +55,7 @@ type Props = {
formId: string;
};
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) {
return undefined;
}
@@ -72,16 +77,36 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
dailyTime,
weeklyDay,
includePatterns: schedule.includePatterns || undefined,
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
...schedule.retentionPolicy,
};
};
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<BackupScheduleFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});
const handleSubmit = useCallback(
(data: InternalFormValues) => {
// Convert excludePatternsText string to excludePatterns array
const { excludePatternsText, ...rest } = data;
const excludePatterns = excludePatternsText
? excludePatternsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: undefined;
onSubmit({
...rest,
excludePatterns,
});
},
[onSubmit],
);
const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(),
});
@@ -102,7 +127,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(handleSubmit)}
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
id={formId}
>
@@ -232,7 +257,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={true}
className="max-w-2xs xs:max-w-screen flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
/>
{selectedPaths.size > 0 && (
<div className="mt-4">
@@ -249,6 +274,47 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Exclude patterns</CardTitle>
<CardDescription>
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
node_modules/**, .cache/).
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="excludePatternsText"
render={({ field }) => (
<FormItem>
<FormLabel>Exclusion patterns</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="*.tmp&#10;node_modules/**&#10;.cache/&#10;*.log"
className="font-mono text-sm min-h-[120px]"
/>
</FormControl>
<FormDescription>
Patterns support glob syntax. See&nbsp;
<a
href="https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Restic documentation
</a>
&nbsp;for more details.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retention policy</CardTitle>
@@ -408,6 +474,33 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p>
</div>
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
<div>
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
<div className="flex flex-col gap-1">
{formValues.includePatterns.map((path) => (
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{path}
</span>
))}
</div>
</div>
)}
{formValues.excludePatternsText && (
<div>
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
<div className="flex flex-col gap-1">
{formValues.excludePatternsText
.split("\n")
.filter(Boolean)
.map((pattern) => (
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{pattern.trim()}
</span>
))}
</div>
</div>
)}
<div>
<p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium">

View File

@@ -6,9 +6,8 @@ await Bun.build({
sourcemap: true,
minify: {
whitespace: true,
identifiers: true,
identifiers: false,
syntax: true,
keepNames: true,
},
external: ["ssh2"],
});

View File

@@ -4,7 +4,6 @@ import { logger } from "../utils/logger";
export type SystemCapabilities = {
docker: boolean;
hostProc: boolean;
};
let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
@@ -29,7 +28,6 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
async function detectCapabilities(): Promise<SystemCapabilities> {
return {
docker: await detectDocker(),
hostProc: await detectHostProc(),
};
}
@@ -55,23 +53,3 @@ async function detectDocker(): Promise<boolean> {
return false;
}
}
/**
* Checks if host proc is available by attempting to access /host/proc/1/ns/mnt
* This allows using nsenter to execute mount commands in the host namespace
*/
async function detectHostProc(): Promise<boolean> {
try {
await fs.access("/host/proc/1/ns/mnt");
logger.info("Host proc capability: enabled");
return true;
} catch (_) {
logger.warn(
"Host proc capability: disabled. " +
"To enable: mount /proc:/host/proc:ro in docker-compose.yml. " +
"Mounts will be executed in container namespace instead of host namespace.",
);
return false;
}
}

View File

@@ -3,3 +3,4 @@ export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
export const SOCKET_PATH = "/run/docker/plugins/ironmount.sock";

View File

@@ -17,6 +17,8 @@ import { backupScheduleController } from "./modules/backups/backups.controller";
import { eventsController } from "./modules/events/events.controller";
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown";
import { SOCKET_PATH } from "./core/constants";
export const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, {
@@ -70,17 +72,15 @@ runDbMigrations();
const { docker } = await getCapabilities();
if (docker) {
const socketPath = "/run/docker/plugins/ironmount.sock";
try {
await fs.mkdir("/run/docker/plugins", { recursive: true });
Bun.serve({
unix: socketPath,
unix: SOCKET_PATH,
fetch: driver.fetch,
});
logger.info(`Docker volume plugin server running at ${socketPath}`);
logger.info(`Docker volume plugin server running at ${SOCKET_PATH}`);
} catch (error) {
logger.error(`Failed to start Docker volume plugin server: ${error}`);
}
@@ -96,3 +96,16 @@ startup();
logger.info(`Server is running at http://localhost:4096`);
export type AppType = typeof app;
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, starting graceful shutdown...");
await shutdown();
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("SIGINT received, starting graceful shutdown...");
await shutdown();
process.exit(0);
});

View File

@@ -1,31 +1,14 @@
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as npath from "node:path";
import { promisify } from "node:util";
import { getCapabilities } from "../../../core/capabilities";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
const execFile = promisify(execFileCb);
import { $ } from "bun";
export const executeMount = async (args: string[]): Promise<void> => {
const capabilities = await getCapabilities();
let stderr: string | undefined;
if (capabilities.hostProc) {
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} else {
const result = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
const result = await $`mount ${args}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
logger.warn(stderr.trim());
@@ -33,22 +16,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
};
export const executeUnmount = async (path: string): Promise<void> => {
const capabilities = await getCapabilities();
let stderr: string | undefined;
if (capabilities.hostProc) {
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} else {
const result = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
const result = await $`umount -l -f ${path}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
logger.warn(stderr.trim());

View File

@@ -0,0 +1,28 @@
import { Scheduler } from "../../core/scheduler";
import { eq, or } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { SOCKET_PATH } from "../../core/constants";
import { createVolumeBackend } from "../backends/backend";
export const shutdown = async () => {
await Scheduler.stop();
await Bun.file(SOCKET_PATH)
.delete()
.catch(() => {
// Ignore errors if the socket file does not exist
});
const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted")),
});
for (const volume of volumes) {
const backend = createVolumeBackend(volume);
const { status, error } = await backend.unmount();
logger.info(`Volume ${volume.name} unmount status: ${status}${error ? `, error: ${error}` : ""}`);
}
};

View File

@@ -82,7 +82,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = {
RESTIC_CACHE_DIR: "/tmp/restic-cache",
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
};
@@ -189,7 +189,7 @@ const backup = async (
let stdout = "";
await safeSpawn({
const res = await safeSpawn({
command: "restic",
args,
env,
@@ -210,6 +210,11 @@ const backup = async (
},
});
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
}
const lastLine = stdout.trim();
const resSummary = JSON.parse(lastLine ?? "{}");

View File

@@ -12,10 +12,19 @@ interface Params {
finally?: () => Promise<void> | void;
}
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export const safeSpawn = (params: Params) => {
const { command, args, env = {}, signal, ...callbacks } = params;
return new Promise((resolve, reject) => {
return new Promise<SpawnResult>((resolve) => {
let stdoutData = "";
let stderrData = "";
const child = spawn(command, args, {
env: { ...process.env, ...env },
signal: signal,
@@ -24,12 +33,16 @@ export const safeSpawn = (params: Params) => {
child.stdout.on("data", (data) => {
if (callbacks.onStdout) {
callbacks.onStdout(data.toString());
} else {
stdoutData += data.toString();
}
});
child.stderr.on("data", (data) => {
if (callbacks.onStderr) {
callbacks.onStderr(data.toString());
} else {
stderrData += data.toString();
}
});
@@ -40,7 +53,12 @@ export const safeSpawn = (params: Params) => {
if (callbacks.finally) {
await callbacks.finally();
}
reject(error);
resolve({
exitCode: -1,
stdout: stdoutData,
stderr: stderrData,
});
});
child.on("close", async (code) => {
@@ -50,7 +68,12 @@ export const safeSpawn = (params: Params) => {
if (callbacks.finally) {
await callbacks.finally();
}
resolve(code);
resolve({
exitCode: code === null ? -1 : code,
stdout: stdoutData,
stderr: stderrData,
});
});
});
};

View File

@@ -34,4 +34,6 @@ services:
ports:
- "4096:4096"
volumes:
- /var/lib/ironmount/:/var/lib/ironmount/
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock