feat: config portability with generic entity resolver framework
Full configuration export/import system for Homarr with excellent DX: - Export/import boards, apps, integrations (with re-encrypted secrets), users (with bcrypt hashes), groups (with permissions and memberships), search engines, and server settings - Homepage services.yaml import with widget-type mapping - Generic resolveEntities() utility eliminates per-entity boilerplate - Declarative entity-fields.ts: adding a new user preference requires only one line change (no export/import/schema edits needed) - User schema uses .catchall(z.unknown()) for forward-compatible prefs - groupByKey() utility replaces manual Map-building - CONFIG_ENTITY_KEYS as single source of truth for entity metadata - Reuse existing entities (apps by name+icon+href, integrations by kind+name+url, users by email, groups by name, boards by name) - Skip duplicates with warnings rather than failing - Onboarding flow skips user creation step when config includes users - Reuse existing admin group during onboarding after config import
This commit is contained in:
48 files changed
+1869
-1435
No files matched your search
@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
assessBundleCompatibility,
|
||||
exportFullConfigAsync,
|
||||
importFullConfigAsync,
|
||||
parseAndValidateBundle,
|
||||
previewExportFullConfigAsync,
|
||||
previewImportFullConfigAsync,
|
||||
} from "@homarr/board-portability";
|
||||
@@ -19,11 +19,7 @@ export const configRouter = createTRPCRouter({
|
||||
|
||||
previewImport: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
@@ -34,27 +30,13 @@ export const configRouter = createTRPCRouter({
|
||||
|
||||
importFull: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.content);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, packageJson.version);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: compatibility.issues.join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
}),
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
assessBundleCompatibility,
|
||||
importFullConfigAsync,
|
||||
previewImportFullConfigAsync,
|
||||
} from "@homarr/board-portability";
|
||||
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
@@ -26,12 +23,7 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ checksum: z.string(), token: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
@@ -49,38 +41,26 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
previewInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
importInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.content);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
}
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, packageJson.version);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: compatibility.issues.join(" "),
|
||||
});
|
||||
const hasUsers = (bundle.users ?? []).length > 0;
|
||||
if (hasUsers) {
|
||||
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
|
||||
} else {
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -50,14 +50,13 @@ vi.mock("@homarr/docker/env", () => ({
|
||||
ENABLE_DOCKER: true,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/docker", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@homarr/docker")>();
|
||||
return {
|
||||
...original,
|
||||
listDiscoveredContainersAsync: async () => [],
|
||||
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
|
||||
};
|
||||
});
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
DockerSingleton: {
|
||||
getInstances: () => [],
|
||||
},
|
||||
listDiscoveredContainersAsync: async () => [],
|
||||
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
|
||||
}));
|
||||
vi.mock("@homarr/db/queries", () => ({
|
||||
getServerSettingsAsync: async () => ({
|
||||
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
|
||||
|
||||
@@ -52,23 +52,45 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, input.email),
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
|
||||
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
|
||||
|
||||
const existingGroup = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
|
||||
let groupId: string;
|
||||
if (existingGroup) {
|
||||
groupId = existingGroup.id;
|
||||
} else {
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await ctx.db.query.groupMembers.findFirst({
|
||||
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
|
||||
Reference in new issue
Block a user