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
27 changes: 25 additions & 2 deletions contract/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,25 @@ components:
- "id"
- "name"
type: "object"
LiveCoverage:
properties:
memoryMeasuredNodeCount:
description: "메모리 측정값이 있는 노드 수 — nodeCount보다 작으면 메모리 합계는 부분 측정"
format: "int32"
type: "integer"
nodeCount:
description: "nodesLive 행 수 (플랫폼 전체 노드 수)"
format: "int32"
type: "integer"
storageMeasuredNodeCount:
description: "스토리지 측정값이 있는 노드 수 — nodeCount보다 작으면 스토리지 합계는 부분 측정"
format: "int32"
type: "integer"
required:
- "memoryMeasuredNodeCount"
- "nodeCount"
- "storageMeasuredNodeCount"
type: "object"
LoginRequest:
properties:
email:
Expand Down Expand Up @@ -1934,7 +1953,8 @@ components:
format: "int64"
type: "integer"
reachable:
description: "false = 이 노드의 Proxmox API가 응답하지 않음 — 나머지 필드는 null"
description: "true = 이 노드가 상태 조회에 응답함. false = 응답하지 않음이며 나머지 필드는 모두 null.\
\ true여도 스토리지 조회는 별도 권한이라 storage* 필드는 null일 수 있음"
type: "boolean"
storageTotalBytes:
format: "int64"
Expand Down Expand Up @@ -3618,6 +3638,8 @@ components:
items:
$ref: "#/components/schemas/IpPoolUsage"
type: "array"
liveCoverage:
$ref: "#/components/schemas/LiveCoverage"
nodes:
items:
$ref: "#/components/schemas/NodeRatio"
Expand Down Expand Up @@ -3645,6 +3667,7 @@ components:
required:
- "certExpiring30dCount"
- "ipPools"
- "liveCoverage"
- "nodes"
- "nodesLive"
- "notificationFailureCount"
Expand Down Expand Up @@ -4955,7 +4978,7 @@ info:
description: "부산대학교 클라우드 플랫폼 Pickle의 REST API. 인증은 JWT Bearer, 오류 응답은 RFC 9457 problem+json(Problem\
\ 스키마)을 따릅니다."
title: "Pickle API"
version: "0.36.0"
version: "0.37.0"
openapi: "3.1.0"
paths:
/admin/announcements:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,25 @@ public NodeSummaryResponse getNode(long nodeId) {

@Transactional(readOnly = true)
public List<NodeSummaryResponse> listNodes() {
return listNodes(nodeRepository.findAll(Sort.by("id")));
}

/**
* The same summaries over node rows the caller already read. The system
* dashboard builds two halves from one node list — these ratios and the
* live hypervisor probe — and reads the rows once so a status change
* landing mid-request cannot leave the two halves describing different
* rows. Only basic columns are touched, so rows read outside this
* transaction are fine.
*/
@Transactional(readOnly = true)
public List<NodeSummaryResponse> listNodes(List<Node> nodes) {
double cpuWarn = settingsService.decimal(SettingsService.VCPU_OVERCOMMIT_WARN,
DEFAULT_VCPU_OVERCOMMIT_WARN);
double memoryWarn = settingsService.decimal(SettingsService.MEMORY_USAGE_WARN,
DEFAULT_MEMORY_USAGE_WARN);
Map<Long, NodeAllocation> allocations = loadAllocations();
return nodeRepository.findAll(Sort.by("id")).stream()
return nodes.stream()
.map(node -> toSummary(node, allocations.getOrDefault(node.getId(),
NodeAllocation.EMPTY), cpuWarn, memoryWarn))
.toList();
Expand Down
59 changes: 42 additions & 17 deletions src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
import kr.ac.pusan.pickle.admin.dto.OrgDashboardSummaryResponse.TopWorkspace;
import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse;
import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse.IpPoolUsage;
import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse.LiveCoverage;
import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse.NodeRatio;
import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse.Tasks;
import kr.ac.pusan.pickle.common.error.ApiException;
import kr.ac.pusan.pickle.common.error.ErrorCodes;
import kr.ac.pusan.pickle.config.ClockConfig;
import kr.ac.pusan.pickle.inventory.Node;
import kr.ac.pusan.pickle.inventory.NodeRepository;
import kr.ac.pusan.pickle.inventory.NodeStatus;
import kr.ac.pusan.pickle.ipam.IpPoolRepository;
import kr.ac.pusan.pickle.ipam.IpamService;
import kr.ac.pusan.pickle.orgs.OrgRepository;
Expand Down Expand Up @@ -168,9 +168,17 @@ and status not in ('DELETED', 'DELETING')
* every tile is an independent counter, each query and each collaborator
* carries its own read transaction, and the entities read afterwards are
* touched on basic columns only.
*
* <p>What one transaction did give the two node halves was one reading of
* the node table. Without it, a status change landing mid-request would let
* the ratio list and the live list describe different rows — the panel
* would show a node as ACTIVE and unreachable at the same time, which reads
* as an outage rather than as the parking the operator just did. So the
* rows are read once here and both halves are built from that one list.
*/
public SystemDashboardSummaryResponse systemSummary() {
List<NodeRatio> nodes = adminNodeQueryService.listNodes().stream()
List<Node> nodeRows = nodeRepository.findAll(org.springframework.data.domain.Sort.by("id"));
List<NodeRatio> nodes = adminNodeQueryService.listNodes(nodeRows).stream()
.map(AdminSummaryService::toNodeRatio)
.toList();
Tasks tasks = jdbcTemplate.queryForObject("""
Expand Down Expand Up @@ -204,10 +212,30 @@ select count(*) from vms v
where s.value = 'true'::jsonb and v.status <> 'DELETED'
""");

List<NodeLiveResponse> live = nodesLive(nodeRows);
return new SystemDashboardSummaryResponse(nodes, vmCountsByStatus(null), tasks,
notificationFailureCount(), certExpiring30d,
driftFindingRepository.countByStatus(DriftFindingStatus.OPEN),
sshPasswordEnabledVms, pools, nodesLive());
sshPasswordEnabledVms, pools, live, liveCoverage(live));
}

/**
* How much of the platform the live numbers actually cover. Each live
* measurement is a per-node sum, and a sum over the nodes that answered is
* not the platform total: one node whose storage read is refused leaves a
* figure smaller than the truth, which an operator reads as free capacity
* that is not there. The counts say how many nodes are behind each sum so a
* client cannot present a subset as the whole — the same reason the org
* headroom figures null a disk number no node measured.
*/
private static LiveCoverage liveCoverage(List<NodeLiveResponse> live) {
long memory = live.stream()
.filter(node -> node.memTotalBytes() != null && node.memUsedBytes() != null)
.count();
long storage = live.stream()
.filter(node -> node.storageTotalBytes() != null && node.storageUsedBytes() != null)
.count();
return new LiveCoverage(live.size(), (int) memory, (int) storage);
}

/**
Expand All @@ -228,21 +256,17 @@ select count(*) from vms v
* so losing it leaves the two storage fields null on a node that is still
* reachable rather than blanking a tile that did answer.
*
* <p>An OFFLINE node is not probed at all — the operator has already said
* the host is down, and asking it only buys a timeout per dashboard load
* (the status poller skips OFFLINE for the same reason). It still gets a
* row, so the node count stays whole, and that row is
* {@code reachable: false}: no live numbers are known either way, and the
* node's own status is already on the panel beside it in the ratio list,
* which is where "the operator parked it" is said.
* <p>Every node is asked, an OFFLINE one included. OFFLINE excludes a node
* from new placements and leaves its existing guests running, so its RAM is
* still spoken for; skipping the probe would drop that usage out of the
* platform memory tile and show headroom the platform does not have. The
* cost of asking a host that really is dead — one read timeout per
* dashboard load — is accepted deliberately here and belongs to the
* scale-out round, which will probe nodes in parallel under a time budget.
*/
private List<NodeLiveResponse> nodesLive() {
private List<NodeLiveResponse> nodesLive(List<Node> nodes) {
List<NodeLiveResponse> live = new ArrayList<>();
for (Node node : nodeRepository.findAll(org.springframework.data.domain.Sort.by("id"))) {
if (node.getStatus() == NodeStatus.OFFLINE) {
live.add(NodeLiveResponse.unreachable(node.getId(), node.getName()));
continue;
}
for (Node node : nodes) {
NodeStatusInfo status;
try {
status = proxmoxClient.nodeStatus(node.getApiHost(), node.getName());
Expand Down Expand Up @@ -285,7 +309,8 @@ private NodeStorageStatus guestStorage(Node node) {
List<NodeStorageStatus> storages = proxmoxClient.nodeStorage(node.getApiHost(),
node.getName());
return storages.stream()
.filter(storage -> storage.storage().equals(node.getStorage()))
// node.storage is NOT NULL; the PVE-supplied name may be absent.
.filter(storage -> node.getStorage().equals(storage.storage()))
.findFirst()
.orElseGet(() -> storages.stream()
.filter(storage -> "lvmthin".equals(storage.type()) && storage.isActive())
Expand Down
14 changes: 8 additions & 6 deletions src/main/java/kr/ac/pusan/pickle/admin/dto/NodeLiveResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@
* measurement is nullable because the answer to "the host is not answering" is
* this row with {@code reachable} false — not a missing row and not an error.
*
* <p>{@code reachable} false also covers a node the platform chose not to ask:
* an OFFLINE node is skipped rather than waited on, and the answer is the same
* one either way — no live measurement is known. Why it is unknown is on the
* panel already, in the node's own status beside these numbers.
* <p>Every node is asked, whatever status the operator gave it: an OFFLINE
* node still runs the guests it had, so its live numbers are still part of the
* platform's. {@code reachable} is therefore exactly "this node answered its
* status probe just now".
*
* <p>The measurements come from two independent hypervisor calls, so a
* reachable node can still carry null storage: the storage half needs a right
* the status half does not.
* the status half does not. How many nodes are behind each platform sum is on
* the summary itself, in {@code liveCoverage}.
*/
public record NodeLiveResponse(
long nodeId,
String name,
@Schema(description = "false = 이 노드의 Proxmox API가 응답하지 않음 — 나머지 필드는 null")
@Schema(description = "true = 이 노드가 상태 조회에 응답함. false = 응답하지 않음이며 나머지 필드는 모두 null."
+ " true여도 스토리지 조회는 별도 권한이라 storage* 필드는 null일 수 있음")
boolean reachable,
@Nullable Long memTotalBytes,
@Nullable Long memUsedBytes,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.pusan.pickle.admin.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Map;
import kr.ac.pusan.pickle.inventory.NodeStatus;
Expand All @@ -14,7 +15,26 @@ public record SystemDashboardSummaryResponse(
long openDriftFindingCount,
long sshPasswordEnabledVmCount,
List<IpPoolUsage> ipPools,
List<NodeLiveResponse> nodesLive) {
List<NodeLiveResponse> nodesLive,
LiveCoverage liveCoverage) {

/**
* How many nodes each live measurement in {@code nodesLive} actually
* covers. A platform total summed over {@code nodesLive} is only the
* platform total when every node answered: a node whose storage read was
* refused, or that did not answer at all, leaves a sum smaller than the
* truth, which reads as free capacity that is not there. A client that
* sums must compare these counts with {@code nodeCount} and say so when
* they differ, instead of presenting a subset as the whole.
*/
public record LiveCoverage(
@Schema(description = "nodesLive 행 수 (플랫폼 전체 노드 수)")
int nodeCount,
@Schema(description = "메모리 측정값이 있는 노드 수 — nodeCount보다 작으면 메모리 합계는 부분 측정")
int memoryMeasuredNodeCount,
@Schema(description = "스토리지 측정값이 있는 노드 수 — nodeCount보다 작으면 스토리지 합계는 부분 측정")
int storageMeasuredNodeCount) {
}

/** Per-node allocation ratios (same aggregates as {@code GET /admin/nodes}). */
public record NodeRatio(
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.36.0";
public static final String CONTRACT_VERSION = "0.37.0";

/** Name of the bearer-JWT security scheme in the published spec. */
private static final String BEARER_SCHEME = "bearerAuth";
Expand Down
16 changes: 13 additions & 3 deletions src/main/java/kr/ac/pusan/pickle/proxmox/ProxmoxClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,13 @@ private String power(String apiHost, String node, int vmid, String action, Map<S

/**
* Executes the request and unwraps the PVE {@code {"data": ...}} envelope.
* Error responses ({@code {"message": ..., "data": null}}) and transport
* failures both surface as {@link ProxmoxApiException}.
* Error responses ({@code {"message": ..., "data": null}}), transport
* failures and a 200 whose body is not the envelope the caller asked for
* all surface as {@link ProxmoxApiException} — an HTML error page from
* something standing in front of pveproxy, or a changed envelope during a
* PVE upgrade, must not reach a caller as a parser exception it would have
* to enumerate on top of this one. An unreadable body counts as transient:
* the same request against a settled host parses.
*/
private <T> T call(HttpMethod method, URI uri, Map<String, String> form,
TypeReference<Envelope<T>> responseType) {
Expand All @@ -396,7 +401,12 @@ private <T> T call(HttpMethod method, URI uri, Map<String, String> form,
throw new ProxmoxApiException(response.getStatusCode().value(),
extractErrorMessage(body), description);
}
return JSON.readValue(body, responseType).data();
try {
return JSON.readValue(body, responseType).data();
} catch (RuntimeException e) {
throw new ProxmoxApiException("Proxmox API returned an unreadable body on "
+ description + ": " + e.getMessage(), e);
}
});
} catch (ResourceAccessException e) {
// connect/read timeout, connection refused/reset … — no HTTP response.
Expand Down
Loading