Multi-tenant build system with K8s namespace separation

Build-time tenant config via VITE_TENANT env var (--mode loop42/dev).
Content pages moved to tenants/{name}/pages/ with dynamic imports and
loop42 fallback. Feature-gated routing (viewer/devTools per tenant).
Dockerfile parameterized with TENANT build arg. Deployed to separate
K8s namespaces: loop42.de → ns/loop42, loop42.dev → ns/dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-01 17:09:22 +02:00
parent db10ab93fd
commit 42c6079755
15 changed files with 178 additions and 18 deletions

1
.env.dev Normal file
View File

@ -0,0 +1 @@
VITE_TENANT=dev

1
.env.loop42 Normal file
View File

@ -0,0 +1 @@
VITE_TENANT=loop42

View File

@ -1 +1,2 @@
VITE_WS_URL=wss://assay.loop42.de/ws # Default production tenant (overridden by --mode loop42 or --mode dev)
VITE_TENANT=loop42

View File

@ -1,9 +1,10 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
ARG TENANT=loop42
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npx vite build --mode $TENANT
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html

View File

@ -14,7 +14,7 @@
<TtsPlayerBar /> <TtsPlayerBar />
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) --> <!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
<AgentsView v-if="visited.nyx" :class="{ 'view-hidden': route.name !== 'nyx' }" /> <AgentsView v-if="visited.nyx" :class="{ 'view-hidden': route.name !== 'nyx' }" />
<ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" /> <ViewerView v-if="ViewerView && visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
<!-- Non-socket views: scrollable --> <!-- Non-socket views: scrollable -->
<div v-if="routerReady && !isSocketRoute" class="page-scroll"> <div v-if="routerReady && !isSocketRoute" class="page-scroll">
<RouterView /> <RouterView />
@ -33,6 +33,7 @@ import { useRoute } from 'vue-router';
import { ws, auth, agents } from './store'; import { ws, auth, agents } from './store';
import { THEME_LOGOS, useTheme } from './composables/useTheme'; import { THEME_LOGOS, useTheme } from './composables/useTheme';
import { useChatStore } from './store/chat'; import { useChatStore } from './store/chat';
import tenant from './tenant';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
@ -44,11 +45,14 @@ import router from './router';
import TtsPlayerBar from './components/TtsPlayerBar.vue'; import TtsPlayerBar from './components/TtsPlayerBar.vue';
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue')); const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue'));
const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue')); const ViewerView = tenant.features.viewer
? defineAsyncComponent(() => import('./views/ViewerView.vue'))
: null;
const chatStore = useChatStore(); const chatStore = useChatStore();
const visited = reactive({ nyx: false, viewer: false }); const visited = reactive({ nyx: false, viewer: false });
const isSocketRoute = computed(() => ['nyx', 'viewer'].includes(route.name as string)); const socketRoutes = ['nyx', ...(tenant.features.viewer ? ['viewer'] : [])];
const isSocketRoute = computed(() => socketRoutes.includes(route.name as string));
const routerReady = ref(false); const routerReady = ref(false);
router.isReady().then(() => { routerReady.value = true; }); router.isReady().then(() => { routerReady.value = true; });

View File

@ -1,5 +1,6 @@
import { ref, watch, type Component } from 'vue'; import { ref, watch, type Component } from 'vue';
import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline'; import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline';
import tenant from '../tenant';
export type Theme = 'titan' | 'eras' | 'loop42'; export type Theme = 'titan' | 'eras' | 'loop42';
@ -37,9 +38,10 @@ export function agentLogo(agentId: string): string | null {
} }
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
const defaultTheme = (tenant.defaultTheme || 'loop42') as Theme;
const theme = ref<Theme>( const theme = ref<Theme>(
stored === 'workhorse' ? 'loop42' : // migrate legacy name stored === 'workhorse' ? 'loop42' : // migrate legacy name
(stored as Theme) || 'loop42' (stored as Theme) || defaultTheme
); );
const THEME_FAVICONS: Record<Theme, string> = { const THEME_FAVICONS: Record<Theme, string> = {

View File

@ -1,20 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import LoginView from './views/LoginView.vue'; import LoginView from './views/LoginView.vue';
import { THEME_NAMES, useTheme } from './composables/useTheme'; import { THEME_NAMES, useTheme } from './composables/useTheme';
import tenant from './tenant';
import { HomePage, ImpressumPage, DatenschutzPage } from './tenantPages';
const router = createRouter({ const routes: RouteRecordRaw[] = [
history: createWebHistory(), { path: '/', name: 'home', component: HomePage, meta: { suffix: '' } },
routes: [ { path: '/impressum', name: 'impressum', component: ImpressumPage, meta: { suffix: 'Impressum' } },
{ path: '/', name: 'home', component: () => import('./views/CompanyView.vue'), meta: { suffix: '' } }, { path: '/datenschutz', name: 'datenschutz', component: DatenschutzPage, meta: { suffix: 'Datenschutz' } },
{ path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } },
{ path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } },
{ path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } }, { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
{ path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } }, { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
{ path: '/agents', redirect: '/nyx' }, { path: '/agents', redirect: '/nyx' },
{ path: '/chat', redirect: '/nyx' }, { path: '/chat', redirect: '/nyx' },
{ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } }, ];
{ path: '/:pathMatch(.*)*', redirect: '/' },
], if (tenant.features.viewer) {
routes.push({ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } });
}
routes.push({ path: '/:pathMatch(.*)*', redirect: '/' });
const router = createRouter({
history: createWebHistory(),
routes,
}); });
// Auth guard — redirect to /login for protected routes // Auth guard — redirect to /login for protected routes

34
src/tenant.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* tenant.ts Build-time tenant configuration
*
* VITE_TENANT selects which tenant config to load.
* Vite's static analysis + tree-shaking ensures only the
* active tenant's code ends up in the bundle.
*/
export interface TenantFeatures {
graph: boolean;
trace: boolean;
viewer: boolean;
devTools: boolean;
}
export interface TenantConfig {
id: string;
name: string;
domain: string;
defaultTheme: string;
features: TenantFeatures;
}
const modules = import.meta.glob('../tenants/*/config.ts', { eager: true, import: 'default' });
const tenantId = import.meta.env.VITE_TENANT || 'loop42';
const key = `../tenants/${tenantId}/config.ts`;
const config = modules[key] as TenantConfig;
if (!config) {
throw new Error(`Unknown tenant: ${tenantId}. Available: ${Object.keys(modules).join(', ')}`);
}
export default config;

21
src/tenantPages.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* tenantPages.ts Dynamic page imports per tenant
*
* Uses Vite's glob import to resolve tenant-specific pages.
* Pages not found in the active tenant fall back to loop42's pages.
*/
const tenantId = import.meta.env.VITE_TENANT || 'loop42';
// All tenant pages as lazy loaders
const allPages = import.meta.glob('../tenants/*/pages/*.vue');
function resolve(page: string) {
const tenantKey = `../tenants/${tenantId}/pages/${page}.vue`;
const fallbackKey = `../tenants/loop42/pages/${page}.vue`;
return allPages[tenantKey] || allPages[fallbackKey];
}
export const HomePage = resolve('HomePage');
export const ImpressumPage = resolve('ImpressumPage');
export const DatenschutzPage = resolve('DatenschutzPage');

16
tenants/dev/config.ts Normal file
View File

@ -0,0 +1,16 @@
import type { TenantConfig } from '../../src/tenant';
const config: TenantConfig = {
id: 'dev',
name: 'loop42 Dev',
domain: 'loop42.dev',
defaultTheme: 'titan',
features: {
graph: true,
trace: true,
viewer: true,
devTools: true,
},
};
export default config;

View File

@ -0,0 +1,55 @@
<template>
<div class="dev-home">
<div class="hero">
<h1>loop42 <em>Dev</em></h1>
<p>Development environment with full debug tools.</p>
<router-link to="/nyx" class="btn">Open nyx</router-link>
</div>
</div>
</template>
<style scoped>
.dev-home { color: var(--text); }
.hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 6rem 2rem 4rem;
min-height: 60vh;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 300;
letter-spacing: 0.05em;
margin: 0 0 1.5rem;
}
.hero h1 em {
font-style: normal;
color: var(--accent);
}
.hero p {
font-size: 1.1rem;
color: var(--text-dim);
max-width: 480px;
margin: 0 auto 2.5rem;
}
.btn {
display: inline-block;
padding: 0.75rem 2rem;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 0.9rem;
letter-spacing: 0.05em;
transition: opacity 0.2s;
text-decoration: none;
}
.btn:hover { opacity: 0.85; }
</style>

16
tenants/loop42/config.ts Normal file
View File

@ -0,0 +1,16 @@
import type { TenantConfig } from '../../src/tenant';
const config: TenantConfig = {
id: 'loop42',
name: 'loop42',
domain: 'loop42.de',
defaultTheme: 'loop42',
features: {
graph: false,
trace: false,
viewer: false,
devTools: false,
},
};
export default config;