From 389d89958602b2e98e416462b6e598b9dc87a120 Mon Sep 17 00:00:00 2001 From: yessjun Date: Sat, 8 Aug 2026 22:47:21 +0900 Subject: [PATCH] feat: carry the reservation axis on the admin domain list (v0.31.0) A released platform subdomain keeps its row with releasedAt stamped and its status ACTIVE, and the admin query hides REMOVED only, so a reserved name was already listed and read as an ordinary domain with no route yet. Without the two timestamps the listing cannot answer why a name is taken. The reservation end stays computed in one place: the grace arithmetic lives on the assembler, so the admin and user views cannot disagree about when a name comes free while the sweeper follows only one of them. --- contract/openapi.yaml | 12 +++++- .../pickle/common/openapi/OpenApiConfig.java | 2 +- .../publishing/AdminPublishingService.java | 10 ++++- .../publishing/PublicationAssembler.java | 7 +++- .../publishing/dto/AdminDomainView.java | 8 ++++ .../pickle/publishing/PublishingTest.java | 38 +++++++++++++++++++ 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/contract/openapi.yaml b/contract/openapi.yaml index 416f7dec..f303dbc8 100644 --- a/contract/openapi.yaml +++ b/contract/openapi.yaml @@ -181,6 +181,16 @@ components: type: "integer" orgName: type: "string" + releasedAt: + format: "date-time" + type: + - "string" + - "null" + reservedUntil: + format: "date-time" + type: + - "string" + - "null" rootDomain: type: - "string" @@ -4391,7 +4401,7 @@ info: description: "부산대학교 클라우드 플랫폼 Pickle의 REST API. 인증은 JWT Bearer, 오류 응답은 RFC 9457 problem+json(Problem\ \ 스키마)을 따릅니다." title: "Pickle API" - version: "0.30.1" + version: "0.31.0" openapi: "3.1.0" paths: /admin/announcements: diff --git a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java index 8d6cb5e4..bc75b433 100644 --- a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java +++ b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java @@ -41,7 +41,7 @@ public class OpenApiConfig { /** Contract version served in {@code info.version}; bump on any contract change. */ - public static final String CONTRACT_VERSION = "0.30.1"; + public static final String CONTRACT_VERSION = "0.31.0"; /** Name of the bearer-JWT security scheme in the published spec. */ private static final String BEARER_SCHEME = "bearerAuth"; diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java index 39b8c725..3e8dc7dc 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java @@ -113,6 +113,13 @@ public PageResponse listRoutes(AuthenticatedUser actor, Long org return PageResponse.of(content, routes); } + /** + * The admin domain listing. Names held through their release grace are in + * it — the query hides REMOVED only, and a release leaves the row ACTIVE — + * so {@code releasedAt}/{@code reservedUntil} are what separate them from + * a domain that simply has no route yet. Without that pair the two read + * identically, and "why is this subdomain taken" has no answer here. + */ @Transactional(readOnly = true) public PageResponse listDomains(AuthenticatedUser actor, Long orgId, DomainKind kind, DomainStatus status, int page, int size) { @@ -128,7 +135,8 @@ public PageResponse listDomains(AuthenticatedUser actor, Long o var certStatus = assembler.certificateFor(domain).map(Certificate::getStatus).orElse(null); return new AdminDomainView(domain.getId(), domain.getVmId(), domain.getKind(), domain.getFqdn(), domain.getRootDomain(), domain.getStatus(), - domain.getVerifiedAt(), domain.getCreatedAt(), name(vm), + domain.getVerifiedAt(), domain.getReleasedAt(), + assembler.reservedUntil(domain), domain.getCreatedAt(), name(vm), vm != null ? vm.getGroupId() : null, ctx.groupName(vm), vm != null ? vm.getOrgId() : null, ctx.orgName(vm), routeStatus, certStatus, domain.getUpdatedAt()); diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java index 0fbfda73..0b98647f 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java @@ -74,8 +74,13 @@ public DomainDetailView toDomainDetail(Domain domain) { * a setting it cannot read. A released custom row carries no grace under * the reservation policy: its {@code reservedUntil} equals its release * time (due immediately). + * + *

Package-private because the admin domain listing carries the same + * axis: two copies of this arithmetic would let the two views disagree + * about when a name comes free, and only one of them would be the one the + * sweeper actually follows.

*/ - private Instant reservedUntil(Domain domain) { + Instant reservedUntil(Domain domain) { if (domain.getReleasedAt() == null) { return null; } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java index 256b2290..7231514f 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java @@ -10,6 +10,12 @@ /** * Contract schema {@code AdminDomainView} (= DomainSummary + VM/group/org context * + route/cert status). + * + *

{@code releasedAt}/{@code reservedUntil} carry the same meaning and the + * same server-side computation as on the user summary: a released platform + * subdomain keeps {@link DomainStatus#ACTIVE} while it holds its name through + * the grace, so this pair is the only axis that tells an admin why a name is + * occupied.

*/ public record AdminDomainView( Long id, @@ -19,6 +25,8 @@ public record AdminDomainView( @Nullable String rootDomain, DomainStatus status, @Nullable Instant verifiedAt, + @Nullable Instant releasedAt, + @Nullable Instant reservedUntil, Instant createdAt, String vmName, Long groupId, diff --git a/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java b/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java index 7f0a9705..c7734e65 100644 --- a/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java +++ b/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java @@ -1156,6 +1156,32 @@ void adminDomainRemovedFilterAndFailedCertHideExpiry() throws Exception { assertThat(cert.get("daysUntilExpiry").isNull()).isTrue(); } + @Test + void adminDomainListingSeparatesAReservedNameFromAServingOne() throws Exception { + long vmId = publishableVm("team-admres", "pusan.dev", VmStatus.RUNNING); + publish(vmId, "{\"port\":80,\"subdomain\":\"team-admres-kept\"}") + .andExpect(status().isAccepted()); + long servingId = domainIdForVm(vmId); + publish(vmId, "{\"port\":80,\"subdomain\":\"team-admres-gone\"}") + .andExpect(status().isAccepted()); + long reservedId = domainIdForVm(vmId); + mockMvc.perform(delete("/api/v1/domains/" + reservedId) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isAccepted()); // released → reserved + + // Both rows are listed and both read ACTIVE — the reservation stamp is + // the only thing telling an admin why the second name is still taken. + Map byId = listAdminDomains(); + assertThat(byId.get(servingId).get("status").asString()).isEqualTo("ACTIVE"); + assertThat(byId.get(servingId).get("releasedAt").isNull()).isTrue(); + assertThat(byId.get(servingId).get("reservedUntil").isNull()).isTrue(); + assertThat(byId.get(reservedId).get("status").asString()).isEqualTo("ACTIVE"); + assertThat(byId.get(reservedId).get("releasedAt").isNull()).isFalse(); + // The reservation end is the server's own arithmetic (grace setting), + // not something the console could derive from releasedAt. + assertThat(byId.get(reservedId).get("reservedUntil").isNull()).isFalse(); + } + // ── transport-failure retry + recurring reconcile (hardening) ─────────── /** @@ -1956,6 +1982,18 @@ private long domainIdForVm(long vmId) { "select id from domains where vm_id = ? order by id desc limit 1", Long.class, vmId); } + /** GET /admin/domains as SYS_ADMIN; rows of the page keyed by domain id. */ + private Map listAdminDomains() throws Exception { + String body = mockMvc.perform(get("/api/v1/admin/domains?size=100") + .header("Authorization", "Bearer " + sysAdminToken)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Map byId = new java.util.HashMap<>(); + objectMapper.readTree(body).get("content") + .forEach(node -> byId.put(node.get("id").asLong(), node)); + return byId; + } + /** GET /domains as the given caller; rows of the page keyed by domain id. */ private Map listDomains(String token, String extraQuery) throws Exception {