diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java index d6aed97..da8f22b 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -123,6 +123,12 @@ public class TenantPortalController { return ResponseEntity.noContent().build(); } + @PostMapping("/server/upgrade") + public ResponseEntity upgradeServer() { + portalService.upgradeServer(); + return ResponseEntity.noContent().build(); + } + @GetMapping("/settings") public ResponseEntity getSettings() { return ResponseEntity.ok(portalService.getSettings()); diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index f1885f1..935e6d3 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -245,4 +245,16 @@ public class TenantPortalService { throw e; } } + + public void upgradeServer() { + TenantEntity tenant = resolveTenant(); + if (!tenantProvisioner.isAvailable()) return; + + tenantProvisioner.upgrade(tenant.getSlug()); + + var license = licenseService.getActiveLicense(tenant.getId()).orElse(null); + String token = license != null ? license.getToken() : ""; + vendorTenantService.provisionAsync( + tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null); + } } diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledTenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledTenantProvisioner.java index a4f88dc..b772f5c 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledTenantProvisioner.java @@ -14,6 +14,7 @@ public class DisabledTenantProvisioner implements TenantProvisioner { @Override public void start(String slug) { log.warn("Cannot start: provisioning disabled"); } @Override public void stop(String slug) { log.warn("Cannot stop: provisioning disabled"); } @Override public void remove(String slug) { log.warn("Cannot remove: provisioning disabled"); } + @Override public void upgrade(String slug) { log.warn("Cannot upgrade: provisioning disabled"); } @Override public ServerStatus getStatus(String slug) { return ServerStatus.notFound(); } @Override public String getServerEndpoint(String slug) { return null; } } diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java index b0a7cad..b530ae0 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -140,6 +140,19 @@ public class DockerTenantProvisioner implements TenantProvisioner { } } + @Override + public void upgrade(String slug) { + // 1. Stop and remove server + UI containers (preserve app containers, volumes, networks) + stopIfRunning(serverContainerName(slug)); + stopIfRunning(uiContainerName(slug)); + removeContainer(serverContainerName(slug)); + removeContainer(uiContainerName(slug)); + + // 2. Force pull latest images + forcePull(props.serverImage()); + forcePull(props.serverUiImage()); + } + @Override public ServerStatus getStatus(String slug) { try { @@ -309,6 +322,15 @@ public class DockerTenantProvisioner implements TenantProvisioner { return false; } + private void forcePull(String image) { + log.info("Force pulling image: {}", image); + try { + docker.pullImageCmd(image).start().awaitCompletion(); + } catch (Exception e) { + throw new RuntimeException("Failed to pull image " + image + ": " + e.getMessage(), e); + } + } + private void pullIfMissing(String image) { try { docker.inspectImageCmd(image).exec(); diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisioner.java index 72dca4d..e6de330 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisioner.java @@ -6,6 +6,7 @@ public interface TenantProvisioner { void start(String slug); void stop(String slug); void remove(String slug); + void upgrade(String slug); ServerStatus getStatus(String slug); String getServerEndpoint(String slug); } diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java index 795c545..0e9d452 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java @@ -142,6 +142,16 @@ public class VendorTenantController { } } + @PostMapping("/{id}/upgrade") + public ResponseEntity upgrade(@PathVariable UUID id) { + try { + vendorTenantService.upgradeServer(id); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + @PostMapping("/{id}/suspend") public ResponseEntity suspend(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java index fd39639..3a49b30 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -242,6 +242,19 @@ public class VendorTenantService { } } + public void upgradeServer(UUID tenantId) { + TenantEntity tenant = tenantService.getById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); + if (!tenantProvisioner.isAvailable()) return; + + tenantProvisioner.upgrade(tenant.getSlug()); + + // Re-provision with freshly pulled images + var license = licenseService.getActiveLicense(tenantId).orElse(null); + String token = license != null ? license.getToken() : ""; + provisionAsync(tenantId, tenant.getSlug(), tenant.getTier().name(), token, null); + } + @Transactional public TenantEntity suspend(UUID tenantId, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId) diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index 67c0c51..79988d7 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -18,6 +18,14 @@ export function useRestartServer() { }); } +export function useUpgradeServer() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/tenant/server/upgrade'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'dashboard'] }), + }); +} + export function useTenantLicense() { return useQuery({ queryKey: ['tenant', 'license'], diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts index 4c2588a..9c8d1a9 100644 --- a/ui/src/api/vendor-hooks.ts +++ b/ui/src/api/vendor-hooks.ts @@ -62,6 +62,14 @@ export function useRestartServer() { }); } +export function useUpgradeServer() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.post(`/vendor/tenants/${id}/upgrade`), + onSuccess: (_, id) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', id] }), + }); +} + export function useRenewLicense() { const qc = useQueryClient(); return useMutation({ diff --git a/ui/src/pages/tenant/TenantDashboardPage.tsx b/ui/src/pages/tenant/TenantDashboardPage.tsx index f829013..33da9d4 100644 --- a/ui/src/pages/tenant/TenantDashboardPage.tsx +++ b/ui/src/pages/tenant/TenantDashboardPage.tsx @@ -8,8 +8,8 @@ import { Spinner, useToast, } from '@cameleer/design-system'; -import { ExternalLink, Key, RefreshCw, Settings } from 'lucide-react'; -import { useTenantDashboard, useRestartServer } from '../../api/tenant-hooks'; +import { ArrowUpCircle, ExternalLink, Key, RefreshCw, Settings } from 'lucide-react'; +import { useTenantDashboard, useRestartServer, useUpgradeServer } from '../../api/tenant-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; @@ -30,6 +30,7 @@ export function TenantDashboardPage() { const { toast } = useToast(); const { data, isLoading, isError } = useTenantDashboard(); const restartServer = useRestartServer(); + const upgradeServer = useUpgradeServer(); if (isLoading) { return ( @@ -98,7 +99,7 @@ export function TenantDashboardPage() { {data.serverEndpoint} )} -
+
+
diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index c50ab85..49566c1 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -9,7 +9,7 @@ import { Spinner, useToast, } from '@cameleer/design-system'; -import { ArrowLeft, RefreshCw, Trash2 } from 'lucide-react'; +import { ArrowLeft, ArrowUpCircle, RefreshCw, Trash2 } from 'lucide-react'; import { useVendorTenant, useSuspendTenant, @@ -17,6 +17,7 @@ import { useDeleteTenant, useRenewLicense, useRestartServer, + useUpgradeServer, } from '../../api/vendor-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { tierColor } from '../../utils/tier'; @@ -69,6 +70,7 @@ export function TenantDetailPage() { const deleteTenant = useDeleteTenant(); const renewLicense = useRenewLicense(); const restartServer = useRestartServer(); + const upgradeServer = useUpgradeServer(); const [deleteOpen, setDeleteOpen] = useState(false); @@ -117,6 +119,16 @@ export function TenantDetailPage() { } } + async function handleUpgrade() { + if (!id) return; + try { + await upgradeServer.mutateAsync(id); + toast({ title: 'Server upgrade started — pulling latest images', variant: 'success' }); + } catch (err) { + toast({ title: 'Upgrade failed', description: String(err), variant: 'error' }); + } + } + async function handleDelete() { if (!id) return; try { @@ -267,6 +279,14 @@ export function TenantDetailPage() { Restart Server +