feat: add option to enable namespaces

This commit is contained in:
Oliver Geneser
2025-09-13 13:45:19 +02:00
parent 4b1f359cb6
commit 803577a403
10 changed files with 300 additions and 110 deletions

View File

@@ -37,14 +37,7 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props {
id: string;
type:
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
}
const AddRedirectchema = z.object({
@@ -58,7 +51,6 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
@@ -114,7 +106,6 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",

View File

@@ -37,15 +37,16 @@ interface Props {
export const ShowEnvironment = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -53,12 +54,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
compose: () => api.compose.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
@@ -85,12 +87,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
composeId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: formData.environment,
})
.then(async () => {

View File

@@ -51,13 +51,29 @@ const createDockerProviderSchema = (sqldNode?: string) =>
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
externalAdminPort: z.preprocess((a) => {
if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
})
.superRefine((data, ctx) => {
if (data.externalPort === null && data.externalGRPCPort === null) {
if (
data.externalPort === null &&
data.externalGRPCPort === null &&
data.externalAdminPort === null
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either externalPort or externalGRPCPort must be provided.",
path: ["externalPort", "externalGRPCPort"],
message:
"Either externalPort, externalGRPCPort or externalAdminPort must be provided.",
path: ["externalPort", "externalGRPCPort", "externalAdminPort"],
});
}
if (sqldNode === "replica" && data.externalGRPCPort !== null) {
@@ -91,12 +107,16 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
useEffect(() => {
const fieldsToUpdate: Partial<DockerProvider> = {};
if (data?.externalPort !== undefined) {
fieldsToUpdate.externalPort = data.externalPort;
}
if (data?.externalGRPCPort !== undefined) {
fieldsToUpdate.externalGRPCPort = data.externalGRPCPort;
}
if (data?.externalPort !== undefined) {
fieldsToUpdate.externalPort = data.externalPort;
if (data?.externalAdminPort !== undefined) {
fieldsToUpdate.externalAdminPort = data.externalAdminPort;
}
if (Object.keys(fieldsToUpdate).length > 0) {
@@ -108,6 +128,7 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
await mutateAsync({
externalPort: values.externalPort,
externalGRPCPort: values.externalGRPCPort,
externalAdminPort: values.externalAdminPort,
libsqlId,
})
.then(async () => {
@@ -205,6 +226,29 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
)}
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="externalAdminPort"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
External Admin Port (Internet)
</FormLabel>
<FormControl>
<Input
placeholder="5000"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
{data?.sqldNode !== "replica" && (
<>
<div className="md:col-span-2 space-y-4">

View File

@@ -1,5 +1,7 @@
import { SelectGroup } from "@radix-ui/react-select";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -63,11 +65,39 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
<Label>Internal GRPC Port (Container)</Label>
<Input disabled value="5001" />
</div>
<div className="w-full flex flex-col gap-2">
<Label>Internal Admin Port (Container)</Label>
<Input disabled value="5000" />
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label>
<Select
disabled
defaultValue={
data?.enableNamespaces
? String(data?.enableNamespaces)
: "False"
}
>
<SelectTrigger>
<SelectValue placeholder={"False"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["False", "True"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>

View File

@@ -131,7 +131,14 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input
placeholder={
type === "libsql"
? "sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000"
: "Custom command"
}
{...field}
/>
</FormControl>
<FormMessage />

View File

@@ -13,6 +13,7 @@ import {
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -97,73 +98,88 @@ const baseDatabaseSchema = z.object({
serverId: z.string().nullable(),
});
const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("libsql"),
dockerImage: z
.string()
.default("ghcr.io/tursodatabase/libsql-server:latest"),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
const mySchema = z
.discriminatedUnion("type", [
z
.object({
type: z.literal("libsql"),
dockerImage: z
.string()
.default("ghcr.io/tursodatabase/libsql-server:latest"),
databaseUser: z.string().default("libsql"),
sqldNode: z.enum(["primary", "replica"]).default("primary"),
sqldPrimaryUrl: z.string().optional(),
enableNamespaces: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mariadb"),
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mongo"),
databaseUser: z.string().default("mongo"),
replicaSets: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mysql"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("postgres"),
databaseName: z.string().default("postgres"),
databaseUser: z.string().default("postgres"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("redis"),
})
.merge(baseDatabaseSchema),
])
.superRefine((data, ctx) => {
if (data.type === "libsql") {
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
});
}
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databaseUser: z.string().default("libsql"),
sqldNode: z.enum(["primary", "replica"]).default("primary"),
sqldPrimaryUrl: z.string().optional(),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mariadb"),
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mongo"),
databaseUser: z.string().default("mongo"),
replicaSets: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mysql"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("postgres"),
databaseName: z.string().default("postgres"),
databaseUser: z.string().default("postgres"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("redis"),
})
.merge(baseDatabaseSchema),
]);
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
});
}
}
});
const databasesMap = {
postgres: {
@@ -264,7 +280,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
promise = libsqlMutation.mutateAsync({
...commonParams,
sqldNode: data.sqldNode,
sqldPrimaryUrl: data.sqldPrimaryUrl,
sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
enableNamespaces: data.enableNamespaces,
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
@@ -574,11 +591,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
defaultValue={field.value || "primary"}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
<SelectValue placeholder={"primary"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -615,7 +628,46 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
)}
/>
)}
{type === "libsql" && (
<FormField
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
field.onChange(Boolean(value))
}
defaultValue={
field.value ? String(field.value) : "False"
}
>
<SelectTrigger>
<SelectValue placeholder={"False"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["False", "True"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() +
node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{(type === "libsql" ||
type === "mariadb" ||
type === "mongo" ||

View File

@@ -13,7 +13,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
return (
<div className="flex w-full flex-col gap-5">
<ShowCustomCommand id={id} type={type} />
<ShowClusterSettings id={id} type={type} />
{type === "mariadb" ||
type === "mongo" ||
type === "mysql" ||
type === "postgres" ||
type === "redis" ? (
<ShowClusterSettings id={id} type={type} />
) : null}
<ShowVolumes id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />

View File

@@ -179,6 +179,7 @@ export const libsqlRouter = createTRPCRouter({
await updateLibsqlById(input.libsqlId, {
externalPort: input.externalPort,
externalGRPCPort: input.externalGRPCPort,
externalAdminPort: input.externalAdminPort,
});
await deployLibsql(input.libsqlId);
return libsql;

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { boolean, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -41,6 +41,7 @@ export const libsql = pgTable("libsql", {
databasePassword: text("databasePassword").notNull(),
sqldNode: sqldNode("sqldNode").notNull().default("primary"),
sqldPrimaryUrl: text("sqldPrimaryUrl"),
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
env: text("env"),
@@ -52,6 +53,7 @@ export const libsql = pgTable("libsql", {
//
externalPort: integer("externalPort"),
externalGRPCPort: integer("externalGRPCPort"),
externalAdminPort: integer("externalAdminPort"),
applicationStatus: applicationStatus("applicationStatus")
.notNull()
.default("idle"),
@@ -102,7 +104,8 @@ const createSchema = createInsertSchema(libsql, {
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
sqldNode: z.enum(sqldNode.enumValues),
sqldPrimaryUrl: z.string().optional(),
sqldPrimaryUrl: z.string().nullable(),
enableNamespaces: z.boolean().default(false),
dockerImage: z.string().default("ghcr.io/tursodatabase/libsql-server:latest"),
command: z.string().optional(),
env: z.string().optional(),
@@ -114,6 +117,7 @@ const createSchema = createInsertSchema(libsql, {
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalGRPCPort: z.number(),
externalAdminPort: z.number(),
description: z.string().optional(),
serverId: z.string().optional(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
@@ -137,6 +141,7 @@ export const apiCreateLibsql = createSchema
databasePassword: true,
sqldNode: true,
sqldPrimaryUrl: true,
enableNamespaces: true,
serverId: true,
})
.required()
@@ -183,14 +188,20 @@ export const apiSaveExternalPortsLibsql = createSchema
libsqlId: true,
externalPort: true,
externalGRPCPort: true,
externalAdminPort: true,
})
.required({ libsqlId: true })
.superRefine((data, ctx) => {
if (data.externalPort === null && data.externalGRPCPort === null) {
if (
data.externalPort === null &&
data.externalGRPCPort === null &&
data.externalAdminPort === null
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either externalPort or externalGRPCPort must be provided.",
path: ["externalPort", "externalGRPCPort"],
message:
"Either externalPort, externalGRPCPort or externalAdminPort must be provided.",
path: ["externalPort", "externalGRPCPort", "externalAdminPort"],
});
}
});

View File

@@ -8,8 +8,27 @@ import {
generateVolumeMounts,
prepareEnvironmentVariables,
} from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getRemoteDocker } from "../servers/remote-docker";
const getServerArchitecture = async (
serverId?: string | null,
): Promise<string> => {
if (!serverId) {
const { stdout } = await execAsync("uname -m");
return stdout.trim();
}
const { stdout } = await execAsyncRemote(serverId, "uname -m");
return stdout.trim();
};
const getLibsqlImage = (arch: string): string => {
if (arch === "aarch64" || arch === "arm64") {
return "ghcr.io/tursodatabase/libsql-server:latest-arm";
}
return "ghcr.io/tursodatabase/libsql-server:latest";
};
export type LibsqlNested = InferResultType<
"libsql",
{ mounts: true; environment: { with: { project: true } } }
@@ -20,6 +39,7 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
env,
externalPort,
externalGRPCPort,
externalAdminPort,
dockerImage,
memoryLimit,
memoryReservation,
@@ -31,8 +51,16 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
cpuReservation,
command,
mounts,
serverId,
enableNamespaces,
} = libsql;
let finalDockerImage = dockerImage;
if (dockerImage === "ghcr.io/tursodatabase/libsql-server:latest") {
const arch = await getServerArchitecture(serverId);
finalDockerImage = getLibsqlImage(arch);
}
const basicAuth = Buffer.from(
`${databaseUser}:${databasePassword}`,
"utf-8",
@@ -69,18 +97,25 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
const docker = await getRemoteDocker(libsql.serverId);
let finalCommand =
command ??
"sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000";
if (enableNamespaces) {
finalCommand += " --enable-namespaces";
}
const settings: CreateServiceOptions = {
Name: appName,
TaskTemplate: {
ContainerSpec: {
HealthCheck,
Image: dockerImage,
Image: finalDockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
...(finalCommand
? {
Command: ["/bin/sh"],
Args: ["-c", command],
Args: ["-c", finalCommand],
}
: {}),
Labels,
@@ -117,6 +152,16 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
} as PortConfig,
]
: []),
...(externalAdminPort
? [
{
Protocol: "tcp",
TargetPort: 5000,
PublishedPort: externalAdminPort,
PublishMode: "host",
} as PortConfig,
]
: []),
],
},
UpdateConfig,