fix conversion

This commit is contained in:
d34dscene
2025-02-03 16:32:01 +01:00
parent 4c2c624849
commit 4dc07fa950
9 changed files with 268 additions and 172 deletions

View File

@@ -81,10 +81,8 @@ func UpsertMiddleware(a *config.App) http.HandlerFunc {
existingConfig.Config.TCPMiddlewares = make(map[string]*runtime.TCPMiddlewareInfo)
}
// Ensure name has @http suffix
if !strings.HasSuffix(params.Name, "@http") {
params.Name = fmt.Sprintf("%s@http", strings.Split(params.Name, "@")[0])
}
// Ensure name has no @
params.Name = strings.Split(params.Name, "@")[0]
// Update configuration based on type
switch params.Protocol {
@@ -141,11 +139,6 @@ func DeleteMiddleware(a *config.App) http.HandlerFunc {
return
}
// Ensure name has @http suffix for consistency
if !strings.HasSuffix(mwName, "@http") {
mwName = fmt.Sprintf("%s@http", strings.Split(mwName, "@")[0])
}
existingConfig, err := q.GetLocalTraefikConfig(r.Context(), profileID)
if err != nil {
http.Error(

View File

@@ -81,20 +81,36 @@ func UpsertRouter(a *config.App) http.HandlerFunc {
existingConfig.Config.UDPServices = make(map[string]*runtime.UDPServiceInfo)
}
// Ensure name has @http suffix
if !strings.HasSuffix(params.Name, "@http") {
params.Name = fmt.Sprintf("%s@http", strings.Split(params.Name, "@")[0])
}
// Ensure name has no @
params.Name = strings.Split(params.Name, "@")[0]
// Update configuration based on type
switch params.Protocol {
case "http":
if !strings.HasSuffix(params.Router.Service, "@http") {
params.Router.Service = fmt.Sprintf(
"%s@http",
strings.Split(params.Router.Service, "@")[0],
)
}
existingConfig.Config.Routers[params.Name] = params.Router
existingConfig.Config.Services[params.Name] = params.Service
case "tcp":
if !strings.HasSuffix(params.TCPRouter.Service, "@http") {
params.TCPRouter.Service = fmt.Sprintf(
"%s@http",
strings.Split(params.TCPRouter.Service, "@")[0],
)
}
existingConfig.Config.TCPRouters[params.Name] = params.TCPRouter
existingConfig.Config.TCPServices[params.Name] = params.TCPService
case "udp":
if !strings.HasSuffix(params.UDPRouter.Service, "@http") {
params.UDPRouter.Service = fmt.Sprintf(
"%s@http",
strings.Split(params.UDPRouter.Service, "@")[0],
)
}
existingConfig.Config.UDPRouters[params.Name] = params.UDPRouter
existingConfig.Config.UDPServices[params.Name] = params.UDPService
default:
@@ -142,12 +158,6 @@ func DeleteRouter(a *config.App) http.HandlerFunc {
return
}
// Ensure name has @http suffix
if !strings.HasSuffix(routerName, "@http") {
http.Error(w, "Invalid router provider", http.StatusBadRequest)
return
}
existingConfig, err := q.GetLocalTraefikConfig(r.Context(), profileID)
if err != nil {
http.Error(

View File

@@ -8,6 +8,7 @@ import (
"github.com/MizuchiLabs/mantrae/internal/config"
"github.com/MizuchiLabs/mantrae/internal/db"
"github.com/MizuchiLabs/mantrae/internal/source"
"github.com/MizuchiLabs/mantrae/internal/traefik"
"github.com/traefik/traefik/v3/pkg/config/runtime"
)
@@ -57,81 +58,31 @@ func PublishTraefikConfig(a *config.App) http.HandlerFunc {
UDPServices: make(map[string]*runtime.UDPServiceInfo),
}
localT, err := q.GetLocalTraefikConfig(r.Context(), profile.ID)
local, err := q.GetLocalTraefikConfig(r.Context(), profile.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
agentT, err := q.GetAgentTraefikConfigs(r.Context(), profile.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Merge configurations from each agent
for _, a := range agentT {
if a.Config == nil {
continue
}
if a.Config.Routers != nil {
for k, v := range a.Config.Routers {
mergedConfig.Routers[k] = v
}
}
if a.Config.Services != nil {
for k, v := range a.Config.Services {
mergedConfig.Services[k] = v
}
}
}
if localT.Config == nil {
if local.Config == nil {
w.WriteHeader(http.StatusNoContent)
return
}
// Overlay local config to ensure it takes precedence
if localT.Config.Routers != nil {
for k, v := range localT.Config.Routers {
mergedConfig.Routers[k] = v
}
agents, err := q.GetAgentTraefikConfigs(r.Context(), profile.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if localT.Config.Middlewares != nil {
for k, v := range localT.Config.Middlewares {
mergedConfig.Middlewares[k] = v
}
}
if localT.Config.Services != nil {
for k, v := range localT.Config.Services {
mergedConfig.Services[k] = v
}
}
if localT.Config.TCPRouters != nil {
for k, v := range localT.Config.TCPRouters {
mergedConfig.TCPRouters[k] = v
}
}
if localT.Config.TCPMiddlewares != nil {
for k, v := range localT.Config.TCPMiddlewares {
mergedConfig.TCPMiddlewares[k] = v
}
}
if localT.Config.TCPServices != nil {
for k, v := range localT.Config.TCPServices {
mergedConfig.TCPServices[k] = v
}
}
if localT.Config.UDPRouters != nil {
for k, v := range localT.Config.UDPRouters {
mergedConfig.UDPRouters[k] = v
}
}
if localT.Config.UDPServices != nil {
for k, v := range localT.Config.UDPServices {
mergedConfig.UDPServices[k] = v
}
// Merge configurations (prefer local)
for _, agent := range agents {
mergedConfig = traefik.MergeConfigs(mergedConfig, agent.Config)
}
mergedConfig = traefik.MergeConfigs(mergedConfig, local.Config)
// Convert to dynamic
dynamic := traefik.ConvertToDynamicConfig(mergedConfig)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mergedConfig)
json.NewEncoder(w).Encode(dynamic)
}
}

161
internal/traefik/convert.go Normal file
View File

@@ -0,0 +1,161 @@
package traefik
import (
"github.com/MizuchiLabs/mantrae/internal/db"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/config/runtime"
)
func ConvertToDynamicConfig(rc *db.TraefikConfiguration) *dynamic.Configuration {
dc := &dynamic.Configuration{}
// Only create HTTP config if there are HTTP components
if len(rc.Routers) > 0 || len(rc.Middlewares) > 0 || len(rc.Services) > 0 {
dc.HTTP = &dynamic.HTTPConfiguration{
Routers: make(map[string]*dynamic.Router),
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
}
for name, router := range rc.Routers {
dc.HTTP.Routers[name] = router.Router
}
for name, service := range rc.Services {
dc.HTTP.Services[name] = service.Service
}
for name, middleware := range rc.Middlewares {
dc.HTTP.Middlewares[name] = middleware.Middleware
}
}
// Only create TCP config if there are TCP components
if len(rc.TCPRouters) > 0 || len(rc.TCPMiddlewares) > 0 || len(rc.TCPServices) > 0 {
dc.TCP = &dynamic.TCPConfiguration{
Routers: make(map[string]*dynamic.TCPRouter),
Middlewares: make(map[string]*dynamic.TCPMiddleware),
Services: make(map[string]*dynamic.TCPService),
}
for name, router := range rc.TCPRouters {
dc.TCP.Routers[name] = router.TCPRouter
}
for name, service := range rc.TCPServices {
dc.TCP.Services[name] = service.TCPService
}
for name, middleware := range rc.TCPMiddlewares {
dc.TCP.Middlewares[name] = middleware.TCPMiddleware
}
}
// Only create UDP config if there are UDP components
if len(rc.UDPRouters) > 0 || len(rc.UDPServices) > 0 {
dc.UDP = &dynamic.UDPConfiguration{
Routers: make(map[string]*dynamic.UDPRouter),
Services: make(map[string]*dynamic.UDPService),
}
for name, router := range rc.UDPRouters {
dc.UDP.Routers[name] = router.UDPRouter
}
for name, service := range rc.UDPServices {
dc.UDP.Services[name] = service.UDPService
}
}
return dc
}
func MergeConfigs(base, overlay *db.TraefikConfiguration) *db.TraefikConfiguration {
if overlay == nil {
return base
}
// Merge HTTP components
mergeRouters(base.Routers, overlay.Routers)
mergeMiddlewares(base.Middlewares, overlay.Middlewares)
mergeServices(base.Services, overlay.Services)
// Merge TCP components
mergeTCPRouters(base.TCPRouters, overlay.TCPRouters)
mergeTCPMiddlewares(base.TCPMiddlewares, overlay.TCPMiddlewares)
mergeTCPServices(base.TCPServices, overlay.TCPServices)
// Merge UDP components
mergeUDPRouters(base.UDPRouters, overlay.UDPRouters)
mergeUDPServices(base.UDPServices, overlay.UDPServices)
return base
}
// Merge helper functions for each type
func mergeRouters(base, overlay map[string]*runtime.RouterInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeMiddlewares(base, overlay map[string]*runtime.MiddlewareInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeServices(base, overlay map[string]*db.ServiceInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeTCPRouters(base, overlay map[string]*runtime.TCPRouterInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeTCPMiddlewares(base, overlay map[string]*runtime.TCPMiddlewareInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeTCPServices(base, overlay map[string]*runtime.TCPServiceInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeUDPRouters(base, overlay map[string]*runtime.UDPRouterInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}
func mergeUDPServices(base, overlay map[string]*runtime.UDPServiceInfo) {
if overlay == nil {
return
}
for k, v := range overlay {
base[k] = v
}
}

View File

@@ -634,15 +634,10 @@ async function fetchTraefikConfig(src: TraefikSource) {
const newServices = flattenServiceData(res);
const newMiddlewares = flattenMiddlewareData(res);
const newMerge = newRouters.map((router) => {
const routerProvider = router.name.split('@')[1];
let serviceName = router.service; // api@internal
// Most of time the service name doesn't include the provider
if (!router.service?.includes('@')) {
serviceName = router.service + '@' + routerProvider;
let service = newServices.find((service) => service.name === router.service);
if (!service) {
service = newServices.find((service) => service.name === router.name);
}
const service = newServices.find((service) => service.name === serviceName);
return { router, service: service || ({} as Service) };
});

View File

@@ -13,6 +13,7 @@
import * as Select from '$lib/components/ui/select';
import { toast } from 'svelte-sonner';
import type { RouterDNSProvider } from '$lib/types';
import { source } from '$lib/stores/source';
interface Props {
router: Router;
@@ -27,9 +28,7 @@
);
let rdpName = $derived(routerDNS ? routerDNS.providerName : '');
let routerProvider = $derived(router.name ? router.name?.split('@')[1] : 'http');
let isHttpProvider = $derived(routerProvider === 'http' || !routerProvider);
let isHttpType = $derived(router.protocol === 'http');
let disabled = $derived(routerProvider !== 'http' && mode === 'edit');
let certResolvers = $derived([
...new Set(
$routers.filter((item) => item.tls?.certResolver).map((item) => item.tls?.certResolver)
@@ -69,13 +68,12 @@
</Card.Header>
<Card.Content class="flex flex-col gap-2">
<!-- Provider Type Toggles -->
{#if isHttpProvider}
{#if source.isLocal()}
<div class="flex items-center justify-end gap-1 font-mono text-sm">
<Toggle
size="sm"
pressed={router.protocol === 'http'}
onPressedChange={() => (router.protocol = 'http')}
{disabled}
class="font-bold data-[state=on]:bg-green-300 dark:data-[state=on]:text-black"
>
HTTP
@@ -84,7 +82,6 @@
size="sm"
pressed={router.protocol === 'tcp'}
onPressedChange={() => (router.protocol = 'tcp')}
{disabled}
class="font-bold data-[state=on]:bg-blue-300 dark:data-[state=on]:text-black"
>
TCP
@@ -93,7 +90,6 @@
size="sm"
pressed={router.protocol === 'udp'}
onPressedChange={() => (router.protocol = 'udp')}
{disabled}
class="font-bold data-[state=on]:bg-red-300 dark:data-[state=on]:text-black"
>
UDP
@@ -112,68 +108,64 @@
oninput={() => (router.name = router.name?.split('@')[0])}
class="col-span-3"
required
disabled={disabled || mode === 'edit'}
disabled={!source.isLocal()}
/>
<!-- Icon based on provider -->
{#if routerProvider !== ''}
<span
class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-400"
>
{#if isHttpProvider}
<img src={logo} alt="HTTP" width="20" />
{/if}
{#if routerProvider === 'internal' || routerProvider === 'file'}
<iconify-icon icon="devicon:traefikproxy" height="20"></iconify-icon>
{/if}
{#if routerProvider?.includes('docker')}
<iconify-icon icon="logos:docker-icon" height="20"></iconify-icon>
{/if}
{#if routerProvider?.includes('kubernetes')}
<iconify-icon icon="logos:kubernetes" height="20"></iconify-icon>
{/if}
{#if routerProvider === 'consul'}
<iconify-icon icon="logos:consul" height="20"></iconify-icon>
{/if}
{#if routerProvider === 'nomad'}
<iconify-icon icon="logos:nomad-icon" height="20"></iconify-icon>
{/if}
{#if routerProvider === 'kv'}
<iconify-icon icon="logos:redis" height="20"></iconify-icon>
{/if}
</span>
{/if}
<span
class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-400"
>
{#if routerProvider === ''}
<img src={logo} alt="HTTP" width="20" />
{/if}
{#if routerProvider === 'internal' || routerProvider === 'file'}
<iconify-icon icon="devicon:traefikproxy" height="20"></iconify-icon>
{/if}
{#if routerProvider?.includes('docker')}
<iconify-icon icon="logos:docker-icon" height="20"></iconify-icon>
{/if}
{#if routerProvider?.includes('kubernetes')}
<iconify-icon icon="logos:kubernetes" height="20"></iconify-icon>
{/if}
{#if routerProvider === 'consul'}
<iconify-icon icon="logos:consul" height="20"></iconify-icon>
{/if}
{#if routerProvider === 'nomad'}
<iconify-icon icon="logos:nomad-icon" height="20"></iconify-icon>
{/if}
{#if routerProvider === 'kv'}
<iconify-icon icon="logos:redis" height="20"></iconify-icon>
{/if}
</span>
</div>
</div>
<!-- Entrypoints -->
{#if isHttpProvider}
<div class="grid grid-cols-4 items-center gap-1">
<Label class="mr-2 text-right">Entrypoints</Label>
<Select.Root type="multiple" bind:value={router.entryPoints} {disabled}>
<Select.Trigger class="col-span-3">
{router.entryPoints?.length ? router.entryPoints.join(', ') : 'Select entrypoints'}
</Select.Trigger>
<Select.Content>
{#each $entrypoints as ep}
<Select.Item value={ep.name}>
<div class="flex items-center gap-2">
{ep.name}
{#if ep.http?.tls}
<Lock size="1rem" class="text-green-400" />
{/if}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="grid grid-cols-4 items-center gap-1">
<Label class="mr-2 text-right">Entrypoints</Label>
<Select.Root type="multiple" bind:value={router.entryPoints} disabled={!source.isLocal()}>
<Select.Trigger class="col-span-3">
{router.entryPoints?.length ? router.entryPoints.join(', ') : 'Select entrypoints'}
</Select.Trigger>
<Select.Content>
{#each $entrypoints as ep}
<Select.Item value={ep.name}>
<div class="flex items-center gap-2">
{ep.name}
{#if ep.http?.tls}
<Lock size="1rem" class="text-green-400" />
{/if}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Middlewares -->
{#if router.protocol !== 'udp'}
<div class="grid grid-cols-4 items-center gap-1">
<Label class="mr-2 text-right">Middlewares</Label>
<Select.Root type="multiple" bind:value={router.middlewares} {disabled}>
<Select.Root type="multiple" bind:value={router.middlewares} disabled={!source.isLocal()}>
<Select.Trigger class="col-span-3">
{router.middlewares?.length ? router.middlewares.join(', ') : 'Select middlewares'}
</Select.Trigger>
@@ -196,14 +188,15 @@
<Input
bind:value={router.tls.certResolver}
placeholder="Certificate resolver"
{disabled}
disabled={!source.isLocal()}
/>
<div class="flex flex-wrap gap-1">
{#each certResolvers as resolver}
{#if resolver !== router.tls.certResolver}
<Badge
onclick={() => !disabled && router.tls && (router.tls.certResolver = resolver)}
class={disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
onclick={() =>
!source.isLocal() && router.tls && (router.tls.certResolver = resolver)}
class={!source.isLocal() ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
>
{resolver}
</Badge>
@@ -239,7 +232,11 @@
<!-- Rule -->
{#if router.protocol === 'http' || router.protocol === 'tcp'}
<RuleEditor bind:rule={router.rule} bind:type={router.protocol} {disabled} />
<RuleEditor
bind:rule={router.rule}
bind:type={router.protocol}
disabled={!source.isLocal()}
/>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -6,17 +6,13 @@
import { Button } from '$lib/components/ui/button/index.js';
import { type Router, type Service } from '$lib/types/router';
import { Plus, Trash } from 'lucide-svelte';
import { source } from '$lib/stores/source';
interface Props {
service: Service;
router: Router;
mode: 'create' | 'edit';
}
let { service = $bindable(), router = $bindable(), mode }: Props = $props();
let routerProvider = $derived(router.name ? router.name?.split('@')[1] : 'http');
let disabled = $derived(routerProvider !== 'http' && mode === 'edit');
let { service = $bindable(), router = $bindable() }: Props = $props();
let servers = $state(getServers());
let passHostHeader = $state(service.loadBalancer?.passHostHeader ?? true);
@@ -79,7 +75,7 @@
class="col-span-3"
bind:checked={passHostHeader}
onCheckedChange={update}
{disabled}
disabled={!source.isLocal()}
/>
</div>
{/if}
@@ -94,9 +90,9 @@
bind:value={servers[i]}
placeholder={router.protocol === 'http' ? 'http://127.0.0.1:8080' : '127.0.0.1:8080'}
oninput={update}
{disabled}
disabled={!source.isLocal()}
/>
{#if !disabled}
{#if !source.isLocal()}
<Button
variant="ghost"
size="icon"
@@ -110,7 +106,7 @@
</div>
{/each}
</div>
{#if !disabled}
{#if !source.isLocal()}
<Button type="button" variant="outline" onclick={addItem} class="w-full">
<Plus />
Add Server

View File

@@ -26,9 +26,6 @@
const update = async () => {
try {
// Ensure proper name formatting and synchronization
if (!router.name.includes('@')) {
router.name = `${router.name}@http`;
}
if (service.loadBalancer?.servers?.length === 0) {
toast.error('At least one server is required');
return;
@@ -81,7 +78,7 @@
<RouterForm bind:router {mode} />
</Tabs.Content>
<Tabs.Content value="service">
<ServiceForm bind:service bind:router {mode} />
<ServiceForm bind:service bind:router />
</Tabs.Content>
</Tabs.Root>

View File

@@ -64,13 +64,9 @@
}
const deleteRouter = async (router: Router) => {
try {
let routerProvider = router.name.split('@')[1];
if (routerProvider !== 'http') {
toast.error('Router not managed by Mantrae!');
return;
}
if (!source.isLocal()) return;
try {
await api.deleteRouter(router);
toast.success('Router deleted');
} catch (err: unknown) {