Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion contract/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ public PageResponse<AdminRouteView> 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<AdminDomainView> listDomains(AuthenticatedUser actor, Long orgId,
DomainKind kind, DomainStatus status, int page, int size) {
Expand All @@ -128,7 +135,8 @@ public PageResponse<AdminDomainView> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>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.</p>
*/
private Instant reservedUntil(Domain domain) {
Instant reservedUntil(Domain domain) {
if (domain.getReleasedAt() == null) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
/**
* Contract schema {@code AdminDomainView} (= DomainSummary + VM/group/org context
* + route/cert status).
*
* <p>{@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.</p>
*/
public record AdminDomainView(
Long id,
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long, tools.jackson.databind.JsonNode> 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) ───────────

/**
Expand Down Expand Up @@ -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<Long, tools.jackson.databind.JsonNode> 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<Long, tools.jackson.databind.JsonNode> 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<Long, tools.jackson.databind.JsonNode> listDomains(String token, String extraQuery)
throws Exception {
Expand Down
Loading