feat(libsql): add support for libsql database backups and restores

- Updated backup and restore functionalities to include support for the 'libsql' database type.
- Enhanced the backup process with new methods for running and restoring libsql backups.
- Modified existing components and schemas to accommodate libsql, including updates to the database type enumerations and backup schemas.
- Removed obsolete bottomless replication features from the libsql schema.
- Updated related UI components to reflect changes in backup handling for libsql.
This commit is contained in:
Mauricio Siu
2026-03-19 16:00:39 -06:00
parent a03ec76b6f
commit bb56a0bae8
25 changed files with 8420 additions and 291 deletions
@@ -65,7 +65,7 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
type CacheType = "cache" | "fetch"; type CacheType = "cache" | "fetch";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server" | "libsql";
const Schema = z const Schema = z
.object({ .object({
@@ -77,7 +77,7 @@ const Schema = z
keepLatestCount: z.coerce.number().optional(), keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(), serviceName: z.string().nullable(),
databaseType: z databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(), .optional(),
backupType: z.enum(["database", "compose"]), backupType: z.enum(["database", "compose"]),
metadata: z metadata: z
@@ -209,7 +209,7 @@ export const HandleBackup = ({
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "", database: databaseType === "web-server" ? "dokploy" : databaseType === "libsql" ? "iku.db" : "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
prefix: "/", prefix: "/",
@@ -246,7 +246,9 @@ export const HandleBackup = ({
? backup?.database ? backup?.database
: databaseType === "web-server" : databaseType === "web-server"
? "dokploy" ? "dokploy"
: "", : databaseType === "libsql"
? "iku.db"
: "",
destinationId: backup?.destinationId ?? "", destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true, enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/", prefix: backup?.prefix ?? "/",
@@ -281,6 +283,10 @@ export const HandleBackup = ({
? { ? {
mongoId: id, mongoId: id,
} }
: databaseType === "libsql"
? {
libsqlId: id,
}
: databaseType === "web-server" : databaseType === "web-server"
? { ? {
userId: id, userId: id,
@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
message: "Database name is required", message: "Database name is required",
}), }),
databaseType: z databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(), .optional(),
backupType: z.enum(["database", "compose"]).default("database"), backupType: z.enum(["database", "compose"]).default("database"),
metadata: z metadata: z
@@ -40,7 +40,7 @@ import { RestoreBackup } from "./restore-backup";
interface Props { interface Props {
id: string; id: string;
databaseType?: databaseType?:
| Exclude<ServiceType, "application" | "redis" | "libsql"> | Exclude<ServiceType, "application" | "redis">
| "web-server"; | "web-server";
backupType?: "database" | "compose"; backupType?: "database" | "compose";
} }
@@ -63,6 +63,8 @@ export const ShowBackups = ({
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
libsql: () =>
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(), "web-server": () => api.user.getBackups.useQuery(),
} }
: { : {
@@ -83,6 +85,7 @@ export const ShowBackups = ({
mongo: api.backup.manualBackupMongo.useMutation(), mongo: api.backup.manualBackupMongo.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(), mysql: api.backup.manualBackupMySql.useMutation(),
postgres: api.backup.manualBackupPostgres.useMutation(), postgres: api.backup.manualBackupPostgres.useMutation(),
libsql: api.backup.manualBackupLibsql.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(), "web-server": api.backup.manualBackupWebServer.useMutation(),
} }
: { : {
@@ -1,202 +0,0 @@
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
libsqlId: string;
enableBottomlessReplication: boolean;
bottomlessReplicationDestinationId?: string | null;
}
export const ShowBottomlessReplication = ({
libsqlId,
enableBottomlessReplication,
bottomlessReplicationDestinationId,
}: Props) => {
const utils = api.useUtils();
const switchId = useId();
const commandId = useId();
const { mutateAsync, isLoading } = api.libsql.update.useMutation();
const { data: destinations, isLoading: isLoadingDestinations } =
api.destination.all.useQuery();
const [isDestinationOpen, setIsDestinationOpen] = useState(false);
const handleToggle = async (checked: boolean) => {
try {
await mutateAsync({
libsqlId,
enableBottomlessReplication: checked,
});
toast.success("Bottomless replication updated successfully");
utils.libsql.one.invalidate({ libsqlId });
} catch (error) {
toast.error("Error updating bottomless replication");
}
};
const handleDestinationSelect = async (destinationId: string | null) => {
try {
await mutateAsync({
libsqlId,
enableBottomlessReplication:
destinationId === null ? false : enableBottomlessReplication,
bottomlessReplicationDestinationId: destinationId,
});
toast.success("Bottomless replication destination updated successfully");
utils.libsql.one.invalidate({ libsqlId });
setIsDestinationOpen(false);
} catch (error) {
toast.error("Error updating bottomless replication destination");
}
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Bottomless Replication</CardTitle>
<CardDescription>
Bottomless replication allows automatically backing up your database
to an S3-compatible storage.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<AlertBlock type="warning">
The service needs to be restarted for bottomless replication changes
to take effect. Please redeploy the service after enabling or
disabling this feature.
</AlertBlock>
<div className="flex items-center justify-between">
<div className="space-y-1">
<label htmlFor={switchId} className="text-sm font-medium">
Enable Bottomless Replication
</label>
<p className="text-sm text-muted-foreground">
Automatically replicate database changes to S3-compatible storage
</p>
{!bottomlessReplicationDestinationId && (
<p className="text-sm text-orange-600">
Select a destination above to enable bottomless replication
</p>
)}
</div>
<Switch
id={switchId}
checked={enableBottomlessReplication}
onCheckedChange={handleToggle}
disabled={isLoading || !bottomlessReplicationDestinationId}
/>
</div>
<div className="space-y-2">
<label htmlFor={commandId} className="text-sm font-medium">
Destination
</label>
<p className="text-sm text-muted-foreground">
Select the S3-compatible destination for bottomless replication
</p>
<Popover open={isDestinationOpen} onOpenChange={setIsDestinationOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!bottomlessReplicationDestinationId &&
"text-muted-foreground",
)}
>
{isLoadingDestinations
? "Loading...."
: bottomlessReplicationDestinationId
? destinations?.find(
(destination) =>
destination.destinationId ===
bottomlessReplicationDestinationId,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command id={commandId}>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoadingDestinations && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{destinations?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() =>
handleDestinationSelect(destination.destinationId)
}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId ===
bottomlessReplicationDestinationId
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
<CommandItem
value="none"
onSelect={() => handleDestinationSelect(null)}
>
None
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
!bottomlessReplicationDestinationId
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
</div>
</CardContent>
</Card>
);
};
@@ -56,7 +56,7 @@ import { api } from "@/utils/api";
type DbType = z.infer<typeof mySchema>["type"]; type DbType = z.infer<typeof mySchema>["type"];
const dockerImageDefaultPlaceholder: Record<DbType, string> = { const dockerImageDefaultPlaceholder: Record<DbType, string> = {
libsql: "ghcr.io/tursodatabase/libsql-server:latest", libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mongo: "mongo:7", mongo: "mongo:7",
mariadb: "mariadb:11", mariadb: "mariadb:11",
mysql: "mysql:8", mysql: "mysql:8",
@@ -104,7 +104,7 @@ const mySchema = z
type: z.literal("libsql"), type: z.literal("libsql"),
dockerImage: z dockerImage: z
.string() .string()
.default("ghcr.io/tursodatabase/libsql-server:latest"), .default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
databaseUser: z.string().default("libsql"), databaseUser: z.string().default("libsql"),
sqldNode: z.enum(["primary", "replica"]).default("primary"), sqldNode: z.enum(["primary", "replica"]).default("primary"),
sqldPrimaryUrl: z.string().optional(), sqldPrimaryUrl: z.string().optional(),
+1 -1
View File
@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-inset", "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -0,0 +1,7 @@
ALTER TYPE "public"."databaseType" ADD VALUE 'libsql';--> statement-breakpoint
ALTER TABLE "libsql" DROP CONSTRAINT "libsql_bottomlessReplicationDestinationId_destination_destinationId_fk";
--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "libsqlId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_libsqlId_libsql_libsqlId_fk" FOREIGN KEY ("libsqlId") REFERENCES "public"."libsql"("libsqlId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "libsql" DROP COLUMN "enableBottomlessReplication";--> statement-breakpoint
ALTER TABLE "libsql" DROP COLUMN "bottomlessReplicationDestinationId";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1079,6 +1079,13 @@
"when": 1773940853496, "when": 1773940853496,
"tag": "0153_even_morlocks", "tag": "0153_even_morlocks",
"breakpoints": true "breakpoints": true
},
{
"idx": 154,
"version": "7",
"when": 1773942481550,
"tag": "0154_tan_living_mummy",
"breakpoints": true
} }
] ]
} }
@@ -13,7 +13,7 @@ import superjson from "superjson";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment"; import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show"; import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowBottomlessReplication } from "@/components/dashboard/libsql/general/show-bottomless-replication"; import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ShowExternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-external-libsql-credentials"; import { ShowExternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-external-libsql-credentials";
import { ShowGeneralLibsql } from "@/components/dashboard/libsql/general/show-general-libsql"; import { ShowGeneralLibsql } from "@/components/dashboard/libsql/general/show-general-libsql";
import { ShowInternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-internal-libsql-credentials"; import { ShowInternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-internal-libsql-credentials";
@@ -276,14 +276,10 @@ const Libsql = (
</TabsContent> </TabsContent>
<TabsContent value="backups"> <TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowBottomlessReplication <ShowBackups
libsqlId={libsqlId} id={libsqlId}
enableBottomlessReplication={ databaseType="libsql"
data?.enableBottomlessReplication || false backupType="database"
}
bottomlessReplicationDestinationId={
data?.bottomlessReplicationDestinationId
}
/> />
</div> </div>
</TabsContent> </TabsContent>
+42
View File
@@ -3,6 +3,8 @@ import {
findBackupById, findBackupById,
findComposeByBackupId, findComposeByBackupId,
findComposeById, findComposeById,
findLibsqlByBackupId,
findLibsqlById,
findMariadbByBackupId, findMariadbByBackupId,
findMariadbById, findMariadbById,
findMongoByBackupId, findMongoByBackupId,
@@ -16,6 +18,7 @@ import {
keepLatestNBackups, keepLatestNBackups,
removeBackupById, removeBackupById,
removeScheduleBackup, removeScheduleBackup,
runLibsqlBackup,
runMariadbBackup, runMariadbBackup,
runMongoBackup, runMongoBackup,
runMySqlBackup, runMySqlBackup,
@@ -36,6 +39,7 @@ import {
} from "@dokploy/server/utils/process/execAsync"; } from "@dokploy/server/utils/process/execAsync";
import { import {
restoreComposeBackup, restoreComposeBackup,
restoreLibsqlBackup,
restoreMariadbBackup, restoreMariadbBackup,
restoreMongoBackup, restoreMongoBackup,
restoreMySqlBackup, restoreMySqlBackup,
@@ -82,6 +86,7 @@ export const backupRouter = createTRPCRouter({
input.mysqlId || input.mysqlId ||
input.mariadbId || input.mariadbId ||
input.mongoId || input.mongoId ||
input.libsqlId ||
input.composeId; input.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -103,6 +108,8 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId; serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) { } else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId; serverId = backup.mariadb.serverId;
} else if (databaseType === "libsql" && backup.libsql?.serverId) {
serverId = backup.libsql.serverId;
} else if ( } else if (
backup.backupType === "compose" && backup.backupType === "compose" &&
backup.compose?.serverId backup.compose?.serverId
@@ -154,6 +161,7 @@ export const backupRouter = createTRPCRouter({
backup.mysqlId || backup.mysqlId ||
backup.mariadbId || backup.mariadbId ||
backup.mongoId || backup.mongoId ||
backup.libsqlId ||
backup.composeId; backup.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -173,6 +181,7 @@ export const backupRouter = createTRPCRouter({
existing.mysqlId || existing.mysqlId ||
existing.mariadbId || existing.mariadbId ||
existing.mongoId || existing.mongoId ||
existing.libsqlId ||
existing.composeId; existing.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -400,6 +409,33 @@ export const backupRouter = createTRPCRouter({
}); });
} }
}), }),
manualBackupLibsql: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.libsqlId) {
await checkServicePermissionAndAccess(ctx, backup.libsqlId, {
backup: ["create"],
});
}
const libsql = await findLibsqlByBackupId(backup.backupId);
await runLibsqlBackup(libsql, backup);
await keepLatestNBackups(backup, libsql?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error running manual Libsql backup ",
cause: error,
});
}
}),
manualBackupWebServer: withPermission("backup", "create") manualBackupWebServer: withPermission("backup", "create")
.input(apiFindOneBackup) .input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -536,6 +572,12 @@ export const backupRouter = createTRPCRouter({
queue.push(log); queue.push(log);
}); });
} }
if (input.databaseType === "libsql") {
const libsql = await findLibsqlById(input.databaseId);
restoreLibsqlBackup(libsql, destination, input, (log) => {
queue.push(log);
});
}
if (input.databaseType === "web-server") { if (input.databaseType === "web-server") {
restoreWebServerBackup(destination, input.backupFile, (log) => { restoreWebServerBackup(destination, input.backupFile, (log) => {
queue.push(log); queue.push(log);
+16 -2
View File
@@ -15,6 +15,7 @@ import { generateAppName } from ".";
import { compose } from "./compose"; import { compose } from "./compose";
import { deployments } from "./deployment"; import { deployments } from "./deployment";
import { destinations } from "./destination"; import { destinations } from "./destination";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb"; import { mariadb } from "./mariadb";
import { mongo } from "./mongo"; import { mongo } from "./mongo";
import { mysql } from "./mysql"; import { mysql } from "./mysql";
@@ -26,6 +27,7 @@ export const databaseType = pgEnum("databaseType", [
"mysql", "mysql",
"mongo", "mongo",
"web-server", "web-server",
"libsql",
]); ]);
export const backupType = pgEnum("backupType", ["database", "compose"]); export const backupType = pgEnum("backupType", ["database", "compose"]);
@@ -74,6 +76,12 @@ export const backups = pgTable("backup", {
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, { mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
libsqlId: text("libsqlId").references(
(): AnyPgColumn => libsql.libsqlId,
{
onDelete: "cascade",
},
),
userId: text("userId").references(() => user.id), userId: text("userId").references(() => user.id),
// Only for compose backups // Only for compose backups
metadata: jsonb("metadata").$type< metadata: jsonb("metadata").$type<
@@ -118,6 +126,10 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
fields: [backups.mongoId], fields: [backups.mongoId],
references: [mongo.mongoId], references: [mongo.mongoId],
}), }),
libsql: one(libsql, {
fields: [backups.libsqlId],
references: [libsql.libsqlId],
}),
user: one(user, { user: one(user, {
fields: [backups.userId], fields: [backups.userId],
references: [user.id], references: [user.id],
@@ -137,11 +149,12 @@ const createSchema = createInsertSchema(backups, {
database: z.string().min(1), database: z.string().min(1),
schedule: z.string(), schedule: z.string(),
keepLatestCount: z.number().optional(), keepLatestCount: z.number().optional(),
databaseType: z.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]), databaseType: z.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]),
postgresId: z.string().optional(), postgresId: z.string().optional(),
mariadbId: z.string().optional(), mariadbId: z.string().optional(),
mysqlId: z.string().optional(), mysqlId: z.string().optional(),
mongoId: z.string().optional(), mongoId: z.string().optional(),
libsqlId: z.string().optional(),
userId: z.string().optional(), userId: z.string().optional(),
metadata: z.any().optional(), metadata: z.any().optional(),
}); });
@@ -157,6 +170,7 @@ export const apiCreateBackup = createSchema.pick({
mysqlId: true, mysqlId: true,
postgresId: true, postgresId: true,
mongoId: true, mongoId: true,
libsqlId: true,
databaseType: true, databaseType: true,
userId: true, userId: true,
backupType: true, backupType: true,
@@ -192,7 +206,7 @@ export const apiUpdateBackup = createSchema
export const apiRestoreBackup = z.object({ export const apiRestoreBackup = z.object({
databaseId: z.string(), databaseId: z.string(),
databaseType: z.enum(["postgres", "mysql", "mariadb", "mongo", "web-server"]), databaseType: z.enum(["postgres", "mysql", "mariadb", "mongo", "web-server", "libsql"]),
backupType: z.enum(["database", "compose"]), backupType: z.enum(["database", "compose"]),
databaseName: z.string().min(1), databaseName: z.string().min(1),
backupFile: z.string().min(1), backupFile: z.string().min(1),
+3 -17
View File
@@ -3,7 +3,7 @@ import { boolean, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { destinations } from "./destination"; import { backups } from "./backups";
import { environments } from "./environment"; import { environments } from "./environment";
import { mounts } from "./mount"; import { mounts } from "./mount";
import { server } from "./server"; import { server } from "./server";
@@ -43,14 +43,6 @@ export const libsql = pgTable("libsql", {
sqldNode: sqldNode("sqldNode").notNull().default("primary"), sqldNode: sqldNode("sqldNode").notNull().default("primary"),
sqldPrimaryUrl: text("sqldPrimaryUrl"), sqldPrimaryUrl: text("sqldPrimaryUrl"),
enableNamespaces: boolean("enableNamespaces").notNull().default(false), enableNamespaces: boolean("enableNamespaces").notNull().default(false),
enableBottomlessReplication: boolean("enableBottomlessReplication")
.notNull()
.default(false),
bottomlessReplicationDestinationId: text(
"bottomlessReplicationDestinationId",
).references(() => destinations.destinationId, {
onDelete: "set null",
}),
dockerImage: text("dockerImage").notNull(), dockerImage: text("dockerImage").notNull(),
command: text("command"), command: text("command"),
env: text("env"), env: text("env"),
@@ -92,16 +84,12 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
fields: [libsql.environmentId], fields: [libsql.environmentId],
references: [environments.environmentId], references: [environments.environmentId],
}), }),
//backups: many(backups), backups: many(backups),
mounts: many(mounts), mounts: many(mounts),
server: one(server, { server: one(server, {
fields: [libsql.serverId], fields: [libsql.serverId],
references: [server.serverId], references: [server.serverId],
}), }),
bottomlessReplicationDestination: one(destinations, {
fields: [libsql.bottomlessReplicationDestinationId],
references: [destinations.destinationId],
}),
})); }));
const createSchema = createInsertSchema(libsql, { const createSchema = createInsertSchema(libsql, {
@@ -119,9 +107,7 @@ const createSchema = createInsertSchema(libsql, {
sqldNode: z.enum(sqldNode.enumValues), sqldNode: z.enum(sqldNode.enumValues),
sqldPrimaryUrl: z.string().nullable(), sqldPrimaryUrl: z.string().nullable(),
enableNamespaces: z.boolean().default(false), enableNamespaces: z.boolean().default(false),
enableBottomlessReplication: z.boolean().default(false), dockerImage: z.string().default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
bottomlessReplicationDestinationId: z.string().nullable(),
dockerImage: z.string().default("ghcr.io/tursodatabase/libsql-server:latest"),
command: z.string().optional(), command: z.string().optional(),
env: z.string().optional(), env: z.string().optional(),
memoryReservation: z.string().optional(), memoryReservation: z.string().optional(),
@@ -14,7 +14,7 @@ import {
export type TemplateProps = { export type TemplateProps = {
projectName: string; projectName: string;
applicationName: string; applicationName: string;
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb"; databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
type: "error" | "success"; type: "error" | "success";
errorMessage?: string; errorMessage?: string;
date: string; date: string;
+1
View File
@@ -68,6 +68,7 @@ export * from "./utils/access-log/types";
export * from "./utils/access-log/utils"; export * from "./utils/access-log/utils";
export * from "./utils/backups/compose"; export * from "./utils/backups/compose";
export * from "./utils/backups/index"; export * from "./utils/backups/index";
export * from "./utils/backups/libsql";
export * from "./utils/backups/mariadb"; export * from "./utils/backups/mariadb";
export * from "./utils/backups/mongo"; export * from "./utils/backups/mongo";
export * from "./utils/backups/mysql"; export * from "./utils/backups/mysql";
+3 -1
View File
@@ -33,6 +33,7 @@ export const findBackupById = async (backupId: string) => {
mysql: true, mysql: true,
mariadb: true, mariadb: true,
mongo: true, mongo: true,
libsql: true,
destination: true, destination: true,
compose: true, compose: true,
}, },
@@ -72,7 +73,7 @@ export const removeBackupById = async (backupId: string) => {
export const findBackupsByDbId = async ( export const findBackupsByDbId = async (
id: string, id: string,
type: "postgres" | "mysql" | "mariadb" | "mongo", type: "postgres" | "mysql" | "mariadb" | "mongo" | "libsql",
) => { ) => {
const result = await db.query.backups.findMany({ const result = await db.query.backups.findMany({
where: eq(backups[`${type}Id`], id), where: eq(backups[`${type}Id`], id),
@@ -81,6 +82,7 @@ export const findBackupsByDbId = async (
mysql: true, mysql: true,
mariadb: true, mariadb: true,
mongo: true, mongo: true,
libsql: true,
destination: true, destination: true,
}, },
}); });
+28 -27
View File
@@ -1,6 +1,7 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
type apiCreateLibsql, type apiCreateLibsql,
backups,
buildAppName, buildAppName,
libsql, libsql,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
@@ -9,12 +10,13 @@ import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
export type Libsql = typeof libsql.$inferSelect; export type Libsql = typeof libsql.$inferSelect;
export const createLibsql = async (input: typeof apiCreateLibsql._type) => { export const createLibsql = async (input: z.infer<typeof apiCreateLibsql>) => {
const appName = buildAppName("libsql", input.appName); const appName = buildAppName("libsql", input.appName);
const valid = await validUniqueServerAppName(input.appName); const valid = await validUniqueServerAppName(input.appName);
@@ -59,13 +61,12 @@ export const findLibsqlById = async (libsqlId: string) => {
}, },
mounts: true, mounts: true,
server: true, server: true,
bottomlessReplicationDestination: true, backups: {
// backups: { with: {
// with: { destination: true,
// destination: true, deployments: true,
// deployments: true, },
// }, },
// },
}, },
}); });
if (!result) { if (!result) {
@@ -102,24 +103,24 @@ export const removeLibsqlById = async (libsqlId: string) => {
return result[0]; return result[0];
}; };
// export const findLibsqlByBackupId = async (backupId: string) => { export const findLibsqlByBackupId = async (backupId: string) => {
// const result = await db const result = await db
// .select({ .select({
// ...getTableColumns(libsql), ...getTableColumns(libsql),
// }) })
// .from(libsql) .from(libsql)
// .innerJoin(backups, eq(libsql.libsqlId, backups.libsqlId)) .innerJoin(backups, eq(libsql.libsqlId, backups.libsqlId))
// .where(eq(backups.backupId, backupId)) .where(eq(backups.backupId, backupId))
// .limit(1); .limit(1);
//
// if (!result || !result[0]) { if (!result || !result[0]) {
// throw new TRPCError({ throw new TRPCError({
// code: "NOT_FOUND", code: "NOT_FOUND",
// message: "Libsql not found", message: "Libsql not found",
// }); });
// } }
// return result[0]; return result[0];
// }; };
export const deployLibsql = async ( export const deployLibsql = async (
libsqlId: string, libsqlId: string,
+3 -1
View File
@@ -75,6 +75,7 @@ export const initCronJobs = async () => {
mariadb: true, mariadb: true,
mysql: true, mysql: true,
mongo: true, mongo: true,
libsql: true,
user: true, user: true,
compose: true, compose: true,
}, },
@@ -116,7 +117,8 @@ const getServiceAppName = (backup: BackupSchedule): string => {
backup.postgres?.appName || backup.postgres?.appName ||
backup.mysql?.appName || backup.mysql?.appName ||
backup.mariadb?.appName || backup.mariadb?.appName ||
backup.mongo?.appName; backup.mongo?.appName ||
backup.libsql?.appName;
return serviceAppName || backup.appName; return serviceAppName || backup.appName;
}; };
@@ -0,0 +1,75 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
export const runLibsqlBackup = async (
libsql: Libsql,
backup: BackupSchedule,
) => {
const { name, environmentId, appName } = libsql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Initializing Backup",
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (libsql.serverId) {
await execAsyncRemote(libsql.serverId, backupCommand);
} else {
await execAsync(backupCommand, {
shell: "/bin/bash",
});
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "success",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};
+17 -2
View File
@@ -4,6 +4,7 @@ import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule"; import { scheduledJobs, scheduleJob } from "node-schedule";
import { keepLatestNBackups } from "."; import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose"; import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql";
import { runMariadbBackup } from "./mariadb"; import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo"; import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql"; import { runMySqlBackup } from "./mysql";
@@ -19,6 +20,7 @@ export const scheduleBackup = (backup: BackupSchedule) => {
mysql, mysql,
mongo, mongo,
mariadb, mariadb,
libsql,
compose, compose,
} = backup; } = backup;
scheduleJob(backupId, schedule, async () => { scheduleJob(backupId, schedule, async () => {
@@ -35,6 +37,9 @@ export const scheduleBackup = (backup: BackupSchedule) => {
} else if (databaseType === "mariadb" && mariadb) { } else if (databaseType === "mariadb" && mariadb) {
await runMariadbBackup(mariadb, backup); await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb.serverId); await keepLatestNBackups(backup, mariadb.serverId);
} else if (databaseType === "libsql" && libsql) {
await runLibsqlBackup(libsql, backup);
await keepLatestNBackups(backup, libsql.serverId);
} else if (databaseType === "web-server") { } else if (databaseType === "web-server") {
await runWebServerBackup(backup); await runWebServerBackup(backup);
await keepLatestNBackups(backup); await keepLatestNBackups(backup);
@@ -107,6 +112,10 @@ export const getMongoBackupCommand = (
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`; return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
}; };
export const getLibsqlBackupCommand = (database: string) => {
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
};
export const getServiceContainerCommand = (appName: string) => { export const getServiceContainerCommand = (appName: string) => {
return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`; return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;
}; };
@@ -123,12 +132,12 @@ export const getComposeContainerCommand = (
}; };
const getContainerSearchCommand = (backup: BackupSchedule) => { const getContainerSearchCommand = (backup: BackupSchedule) => {
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } = const { backupType, postgres, mysql, mariadb, mongo, libsql, compose, serviceName } =
backup; backup;
if (backupType === "database") { if (backupType === "database") {
const appName = const appName =
postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName; postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName || libsql?.appName;
return getServiceContainerCommand(appName || ""); return getServiceContainerCommand(appName || "");
} }
if (backupType === "compose") { if (backupType === "compose") {
@@ -209,6 +218,12 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
} }
break; break;
} }
case "libsql": {
if (backupType === "database") {
return getLibsqlBackupCommand(backup.database);
}
break;
}
default: default:
throw new Error(`Database type not supported: ${databaseType}`); throw new Error(`Database type not supported: ${databaseType}`);
} }
+2 -18
View File
@@ -15,7 +15,6 @@ export type LibsqlNested = InferResultType<
{ {
mounts: true; mounts: true;
environment: { with: { project: true } }; environment: { with: { project: true } };
bottomlessReplicationDestination: true;
} }
>; >;
export const buildLibsql = async (libsql: LibsqlNested) => { export const buildLibsql = async (libsql: LibsqlNested) => {
@@ -36,8 +35,6 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
command, command,
mounts, mounts,
enableNamespaces, enableNamespaces,
enableBottomlessReplication,
bottomlessReplicationDestination,
} = libsql; } = libsql;
const basicAuth = Buffer.from( const basicAuth = Buffer.from(
@@ -45,20 +42,10 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
"utf-8", "utf-8",
).toString("base64"); ).toString("base64");
let defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${ const defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
env ? `\n${env}` : "" env ? `\n${env}` : ""
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`; }${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
// Add bottomless replication environment variables if destination is configured
if (enableBottomlessReplication && bottomlessReplicationDestination) {
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_DATABASE_ID="${appName}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_BUCKET="${bottomlessReplicationDestination.bucket}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_ENDPOINT="${bottomlessReplicationDestination.endpoint}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_SECRET_ACCESS_KEY="${bottomlessReplicationDestination.secretAccessKey}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_ACCESS_KEY_ID="${bottomlessReplicationDestination.accessKey}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_DEFAULT_REGION="${bottomlessReplicationDestination.region}"`;
}
const { const {
HealthCheck, HealthCheck,
RestartPolicy, RestartPolicy,
@@ -92,16 +79,13 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
if (enableNamespaces) { if (enableNamespaces) {
finalCommand += " --enable-namespaces"; finalCommand += " --enable-namespaces";
} }
if (enableBottomlessReplication) {
finalCommand += " --enable-bottomless-replication";
}
const settings: CreateServiceOptions = { const settings: CreateServiceOptions = {
Name: appName, Name: appName,
TaskTemplate: { TaskTemplate: {
ContainerSpec: { ContainerSpec: {
HealthCheck, HealthCheck,
Image: "ghcr.io/tursodatabase/libsql-server:latest", Image: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
Env: envVariables, Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount], Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(finalCommand ...(finalCommand
@@ -29,7 +29,7 @@ export const sendDatabaseBackupNotifications = async ({
}: { }: {
projectName: string; projectName: string;
applicationName: string; applicationName: string;
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb"; databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
type: "error" | "success"; type: "error" | "success";
organizationId: string; organizationId: string;
errorMessage?: string; errorMessage?: string;
+1 -1
View File
@@ -62,7 +62,7 @@ export const restoreComposeBackup = async (
const restoreCommand = getRestoreCommand({ const restoreCommand = getRestoreCommand({
appName: appName, appName: appName,
serviceName: backupInput.metadata?.serviceName, serviceName: backupInput.metadata?.serviceName,
type: backupInput.databaseType, type: backupInput.databaseType as "postgres" | "mariadb" | "mysql" | "mongo",
credentials: { credentials: {
database: backupInput.databaseName, database: backupInput.databaseName,
...credentials, ...credentials,
@@ -1,4 +1,5 @@
export { restoreComposeBackup } from "./compose"; export { restoreComposeBackup } from "./compose";
export { restoreLibsqlBackup } from "./libsql";
export { restoreMariadbBackup } from "./mariadb"; export { restoreMariadbBackup } from "./mariadb";
export { restoreMongoBackup } from "./mongo"; export { restoreMongoBackup } from "./mongo";
export { restoreMySqlBackup } from "./mysql"; export { restoreMySqlBackup } from "./mysql";
@@ -0,0 +1,51 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Libsql } from "@dokploy/server/services/libsql";
import type { z } from "zod";
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
export const restoreLibsqlBackup = async (
libsql: Libsql,
destination: Destination,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
const { appName, serverId } = libsql;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
const containerSearch = getServiceContainerCommand(appName);
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
emit(`Executing command: ${command}`);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
emit("Restore completed successfully!");
} catch (error) {
emit(
`Error: ${
error instanceof Error
? error.message
: "Error restoring libsql backup"
}`,
);
throw error;
}
};