feat: add server upgrade action — force-pull latest images and re-provision
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:
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
22
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
22
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user