feat: add server upgrade action — force-pull latest images and re-provision
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 48s

Restart only stops/starts existing containers with the same image. The new
upgrade action removes server + UI containers, force-pulls the latest
Docker images, then re-provisions (preserving app containers, volumes, and
networks). Available to both vendor (tenant detail) and tenant admin
(dashboard).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-11 10:45:45 +02:00
parent d5eead888d
commit e2e5c794a2
11 changed files with 122 additions and 5 deletions

View File

@@ -123,6 +123,12 @@ public class TenantPortalController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PostMapping("/server/upgrade")
public ResponseEntity<Void> upgradeServer() {
portalService.upgradeServer();
return ResponseEntity.noContent().build();
}
@GetMapping("/settings") @GetMapping("/settings")
public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() { public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() {
return ResponseEntity.ok(portalService.getSettings()); return ResponseEntity.ok(portalService.getSettings());

View File

@@ -245,4 +245,16 @@ public class TenantPortalService {
throw e; 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);
}
} }

View File

@@ -14,6 +14,7 @@ public class DisabledTenantProvisioner implements TenantProvisioner {
@Override public void start(String slug) { log.warn("Cannot start: provisioning disabled"); } @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 stop(String slug) { log.warn("Cannot stop: provisioning disabled"); }
@Override public void remove(String slug) { log.warn("Cannot remove: 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 ServerStatus getStatus(String slug) { return ServerStatus.notFound(); }
@Override public String getServerEndpoint(String slug) { return null; } @Override public String getServerEndpoint(String slug) { return null; }
} }

View File

@@ -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 @Override
public ServerStatus getStatus(String slug) { public ServerStatus getStatus(String slug) {
try { try {
@@ -309,6 +322,15 @@ public class DockerTenantProvisioner implements TenantProvisioner {
return false; 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) { private void pullIfMissing(String image) {
try { try {
docker.inspectImageCmd(image).exec(); docker.inspectImageCmd(image).exec();

View File

@@ -6,6 +6,7 @@ public interface TenantProvisioner {
void start(String slug); void start(String slug);
void stop(String slug); void stop(String slug);
void remove(String slug); void remove(String slug);
void upgrade(String slug);
ServerStatus getStatus(String slug); ServerStatus getStatus(String slug);
String getServerEndpoint(String slug); String getServerEndpoint(String slug);
} }

View File

@@ -142,6 +142,16 @@ public class VendorTenantController {
} }
} }
@PostMapping("/{id}/upgrade")
public ResponseEntity<Void> upgrade(@PathVariable UUID id) {
try {
vendorTenantService.upgradeServer(id);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/suspend") @PostMapping("/{id}/suspend")
public ResponseEntity<TenantResponse> suspend(@PathVariable UUID id, public ResponseEntity<TenantResponse> suspend(@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) { @AuthenticationPrincipal Jwt jwt) {

View File

@@ -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 @Transactional
public TenantEntity suspend(UUID tenantId, UUID actorId) { public TenantEntity suspend(UUID tenantId, UUID actorId) {
TenantEntity tenant = tenantService.getById(tenantId) TenantEntity tenant = tenantService.getById(tenantId)

View File

@@ -18,6 +18,14 @@ export function useRestartServer() {
}); });
} }
export function useUpgradeServer() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.post('/tenant/server/upgrade'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'dashboard'] }),
});
}
export function useTenantLicense() { export function useTenantLicense() {
return useQuery<TenantLicenseData>({ return useQuery<TenantLicenseData>({
queryKey: ['tenant', 'license'], queryKey: ['tenant', 'license'],

View File

@@ -62,6 +62,14 @@ export function useRestartServer() {
}); });
} }
export function useUpgradeServer() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.post(`/vendor/tenants/${id}/upgrade`),
onSuccess: (_, id) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', id] }),
});
}
export function useRenewLicense() { export function useRenewLicense() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<LicenseResponse, Error, string>({ return useMutation<LicenseResponse, Error, string>({

View File

@@ -8,8 +8,8 @@ import {
Spinner, Spinner,
useToast, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { ExternalLink, Key, RefreshCw, Settings } from 'lucide-react'; import { ArrowUpCircle, ExternalLink, Key, RefreshCw, Settings } from 'lucide-react';
import { useTenantDashboard, useRestartServer } from '../../api/tenant-hooks'; import { useTenantDashboard, useRestartServer, useUpgradeServer } from '../../api/tenant-hooks';
import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { ServerStatusBadge } from '../../components/ServerStatusBadge';
import { UsageIndicator } from '../../components/UsageIndicator'; import { UsageIndicator } from '../../components/UsageIndicator';
import { tierColor } from '../../utils/tier'; import { tierColor } from '../../utils/tier';
@@ -30,6 +30,7 @@ export function TenantDashboardPage() {
const { toast } = useToast(); const { toast } = useToast();
const { data, isLoading, isError } = useTenantDashboard(); const { data, isLoading, isError } = useTenantDashboard();
const restartServer = useRestartServer(); const restartServer = useRestartServer();
const upgradeServer = useUpgradeServer();
if (isLoading) { if (isLoading) {
return ( return (
@@ -98,7 +99,7 @@ export function TenantDashboardPage() {
<span className={styles.kvValueMono}>{data.serverEndpoint}</span> <span className={styles.kvValueMono}>{data.serverEndpoint}</span>
</div> </div>
)} )}
<div style={{ paddingTop: 8 }}> <div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>
<Button <Button
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
@@ -112,7 +113,22 @@ export function TenantDashboardPage() {
loading={restartServer.isPending} loading={restartServer.isPending}
> >
<RefreshCw size={14} style={{ marginRight: 6 }} /> <RefreshCw size={14} style={{ marginRight: 6 }} />
Restart Server Restart
</Button>
<Button
variant="secondary"
onClick={async () => {
try {
await upgradeServer.mutateAsync();
toast({ title: 'Server upgrade started — pulling latest images', variant: 'success' });
} catch (err) {
toast({ title: 'Upgrade failed', description: String(err), variant: 'error' });
}
}}
loading={upgradeServer.isPending}
>
<ArrowUpCircle size={14} style={{ marginRight: 6 }} />
Upgrade
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@ import {
Spinner, Spinner,
useToast, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { ArrowLeft, RefreshCw, Trash2 } from 'lucide-react'; import { ArrowLeft, ArrowUpCircle, RefreshCw, Trash2 } from 'lucide-react';
import { import {
useVendorTenant, useVendorTenant,
useSuspendTenant, useSuspendTenant,
@@ -17,6 +17,7 @@ import {
useDeleteTenant, useDeleteTenant,
useRenewLicense, useRenewLicense,
useRestartServer, useRestartServer,
useUpgradeServer,
} from '../../api/vendor-hooks'; } from '../../api/vendor-hooks';
import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { ServerStatusBadge } from '../../components/ServerStatusBadge';
import { tierColor } from '../../utils/tier'; import { tierColor } from '../../utils/tier';
@@ -69,6 +70,7 @@ export function TenantDetailPage() {
const deleteTenant = useDeleteTenant(); const deleteTenant = useDeleteTenant();
const renewLicense = useRenewLicense(); const renewLicense = useRenewLicense();
const restartServer = useRestartServer(); const restartServer = useRestartServer();
const upgradeServer = useUpgradeServer();
const [deleteOpen, setDeleteOpen] = useState(false); 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() { async function handleDelete() {
if (!id) return; if (!id) return;
try { try {
@@ -267,6 +279,14 @@ export function TenantDetailPage() {
<RefreshCw size={14} style={{ marginRight: 6 }} /> <RefreshCw size={14} style={{ marginRight: 6 }} />
Restart Server Restart Server
</Button> </Button>
<Button
variant="secondary"
onClick={handleUpgrade}
loading={upgradeServer.isPending}
>
<ArrowUpCircle size={14} style={{ marginRight: 6 }} />
Upgrade Server
</Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleSuspendToggle} onClick={handleSuspendToggle}