feat: async tenant provisioning with polling UX
Some checks failed
CI / build (push) Failing after 39s
CI / docker (push) Has been skipped

Backend: extract Docker provisioning into @Async method so the API
returns immediately with status=PROVISIONING. The tenant record, Logto
org, admin user, and license are created synchronously; container
provisioning, health check, license push, and OIDC config happen in a
background thread.

Frontend: navigate to tenant detail page immediately after creation.
Detail page polls every 3s while status=PROVISIONING and shows a
spinner indicator. Toast notification when provisioning completes.
Fixes #52.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 17:14:26 +02:00
parent 269c679e9c
commit 252c18bcff
4 changed files with 72 additions and 22 deletions

View File

@@ -20,6 +20,7 @@ import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -110,23 +111,41 @@ public class VendorTenantService {
// 3. Generate license
LicenseEntity license = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId);
// 4. Provision server if provisioner is available
auditService.log(actorId, null, tenant.getId(),
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
null, null, "SUCCESS", null);
// 4. Provision server asynchronously (Docker containers, health check, config push)
if (tenantProvisioner.isAvailable()) {
var provisionRequest = new TenantProvisionRequest(
tenant.getId(), tenant.getSlug(),
tenant.getTier().name(), license.getToken());
provisionAsync(tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken(), actorId);
}
return tenant;
}
@Async
public void provisionAsync(UUID tenantId, String slug, String tier, String licenseToken, UUID actorId) {
try {
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
TenantEntity tenant = tenantRepository.findById(tenantId).orElse(null);
if (tenant == null) {
log.error("Tenant {} disappeared during provisioning", slug);
return;
}
if (result.success()) {
tenant.setServerEndpoint(result.serverEndpoint());
tenant.setProvisionError(null);
tenant.setStatus(TenantStatus.ACTIVE);
tenant = tenantRepository.save(tenant);
tenantRepository.save(tenant);
// Push license to newly provisioned server
try {
serverApiClient.pushLicense(result.serverEndpoint(), license.getToken());
serverApiClient.pushLicense(result.serverEndpoint(), licenseToken);
} catch (Exception e) {
log.warn("License push failed for tenant {}: {}", tenant.getSlug(), e.getMessage());
log.warn("License push failed for tenant {}: {}", slug, e.getMessage());
}
// Configure OIDC on the provisioned server (SSO via Logto)
@@ -144,24 +163,25 @@ public class VendorTenantService {
"rolesClaim", "roles",
"audience", "https://api.cameleer.local"
));
log.info("Pushed OIDC config to server for tenant {}", tenant.getSlug());
log.info("Pushed OIDC config to server for tenant {}", slug);
} catch (Exception e) {
log.warn("OIDC config push failed for tenant {}: {}", tenant.getSlug(), e.getMessage());
log.warn("OIDC config push failed for tenant {}: {}", slug, e.getMessage());
}
}
log.info("Tenant {} provisioned successfully", slug);
} else {
tenant.setProvisionError(result.error());
tenant.setStatus(TenantStatus.PROVISIONING);
tenant = tenantRepository.save(tenant);
log.error("Provisioning failed for tenant {}: {}", tenant.getSlug(), result.error());
tenantRepository.save(tenant);
log.error("Provisioning failed for tenant {}: {}", slug, result.error());
}
} catch (Exception e) {
log.error("Unexpected error during async provisioning of tenant {}: {}", slug, e.getMessage(), e);
tenantRepository.findById(tenantId).ifPresent(t -> {
t.setProvisionError(e.getMessage());
tenantRepository.save(t);
});
}
auditService.log(actorId, null, tenant.getId(),
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
null, null, "SUCCESS", null);
return tenant;
}
public List<TenantEntity> listAll() {