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:
Thomas Camlong committed 2026-06-06 16:22:59 +02:00
1 parent 1f7b7f0516
commit f6a598b774
48 files changed
+1869 -1435

No files matched your search

+7 -25
View File
@@ -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);
}),
});
+15 -35
View File
@@ -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 },
+36 -14
View File
@@ -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