diff --git a/README.md b/README.md index a8c05069..82668deb 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,45 @@ fat jar 하나로 동작합니다. 상태는 데이터베이스 한 곳에서 지나갑니다. ``` -guard → 노드 배치 → IP 할당 → VMID 채번 → OS 이미지 clone +guard → 승인 배치 확인 → IP 할당 → VMID 채번 → OS 이미지 clone → 설정(사양·cloud-init·고정 IP·protection=1) → 디스크 리사이즈 → 기동 → qemu-agent 검증 → 호스트키 수집 → 완료(RUNNING, 알림) ``` 각 단계는 멱등이라 중간에서 다시 시작해도 안전합니다. 백오프 재시도로도 통과하지 -못하면 보상 로직이 만들어 둔 것을 정리하거나, VM을 NEEDS_ADMIN 상태로 두고 관리자 -콘솔에 띄웁니다. 어느 경로도 리소스를 자동으로 파괴하지 않습니다. +못한 일반 생성 실패는 단계에 따라 반쯤 만들어진 VM과 IP를 정리하거나, VM을 +NEEDS_ADMIN 상태로 두고 관리자 콘솔에 띄웁니다. clone pin 누락·변경, 현재 노드 불일치, +prepared NIC 조건 불일치처럼 자동 정리가 안전하지 않은 경우에는 VMID와 IP, 기존 guest와 +pin을 그대로 보존하고 NEEDS_ADMIN에서 멈춥니다. + +승인 트랜잭션은 이미지 revision과 후보 노드를 잠근 뒤 CPU, 메모리, 디스크 여유를 함께 +확인합니다. 선택한 노드와 clone 원본 행, template VMID, revision metadata hash는 VM에 +고정됩니다. 워커 재시도도 이 좌표만 사용하며 이미지 metadata나 위치가 바뀌면 관리자 +확인이 필요한 상태로 멈춥니다. 복구로 VM의 현재 노드가 바뀌어도 최초 clone 좌표는 +이력으로 남고, 다른 노드에서 clone을 다시 실행할 권한으로 사용되지 않습니다. pin이 없는 +기존 VM은 조회와 일반 lifecycle을 계속 지원하지만 CREATING 상태에서 새 clone을 시작하지 +않습니다. REINSTALL 파이프라인은 제공하지 않습니다. + +OS 이미지 행은 노드별 inventory이며 `(node_id, name, version)`이 유일합니다. 공개 목록은 +같은 `(name, version)`의 호환 replica를 하나로 묶고 가장 먼저 등록된 행의 UUID를 계속 +노출합니다. 이 원본 행이 비활성 상태여도 활성 노드에 metadata가 일치하는 활성 replica가 +있으면 선택할 수 있습니다. 신청과 승인, 실제 clone이 같은 revision 판정을 사용하므로 +카탈로그 표시와 생성 가능 여부가 어긋나지 않습니다. + +새로 준비한 노드는 `labels`에 `placement_capacity`와 `vm_nic_requirements`를 함께 등록합니다. +첫 문서는 `schema_version`, 측정 시각, `physical`/`reserved`/`allocatable`의 `cpu_threads`, +`memory_mb`, `disk_gb`를 담습니다. 세 축 모두 승인 시 hard limit으로 적용됩니다. NIC 문서는 +`schema_version: 1`, `mtu: 1370`, `firewall: true`만 허용합니다. 이미지 활성화와 clone 직전에 +template의 `net0`가 이 값과 일치하는지 확인하고, VM 설정에서는 MAC과 기존 NIC 속성을 +보존한 채 대상 bridge만 바꿉니다. 이 두 label이 모두 없는 기존 노드는 종전의 메모리 hard +limit과 CPU·aggregate disk advisory 동작을 유지합니다. label 하나라도 등록한 노드는 두 +문서가 모두 유효해야 ACTIVE 전환을 통과합니다. + +CIDR 정책 렌더러와 PVE 방화벽 호출, 수동 VM 복구 guard는 내부 구현 준비 단계입니다. +정책 CRUD와 agent 전달, VM 위치 변경에는 아직 연결되지 않았습니다. +`pickle.network-policy.enabled`는 기본 false이며 이 값을 켜는 것만으로 방화벽이 적용되지 +않습니다. 교내 CIDR 설정 `pickle.network-policy.campus-source-cidrs`에는 기본 허용 대역이 +없습니다. 30초 상태 폴러와 10분 드리프트 리컨실러, 5분 삭제 스위퍼, 10분 고아 태스크 복구가 데이터베이스와 Proxmox를 계속 맞춥니다. 드리프트 리컨실러는 어긋난 지점을 보고만 하고 @@ -200,6 +231,24 @@ dev와 test 프로파일에서는 시더가 그 자리를 채웁니다. 런타 - 신청 화면의 OS 목록은 활성 상태인 카탈로그 행만 보여줍니다. 상태 전환은 관리자 API가 담당합니다. +### V127 순차 배포 + +V127은 기존 OS 이미지의 전역 `(name, version)` unique를 노드 범위로 바꾸고 VM에 nullable +clone pin 네 열과 제약을 추가합니다. 환경 행이나 replica는 삽입하지 않습니다. 배포 전에 +대상 데이터베이스의 전체 Flyway 이력이 성공 상태인지 확인하며, 특히 V125와 V126이 모두 +성공한 뒤에만 V127을 순서대로 적용합니다. out-of-order 적용은 지원하지 않습니다. + +V127 스키마만 적용하고 노드별 replica를 아직 등록하지 않은 단계에서는 이전 jar가 기존 +행을 계속 읽을 수 있습니다. 같은 `(name, version)` replica를 둘 이상의 노드에 등록한 뒤에는 +이 README의 카탈로그 중복 제거와 clone pin 규칙을 구현한 jar만 사용합니다. 기존 VM 행의 pin은 +backfill하지 않으며 null인 채 유지됩니다. + +jar를 이전 버전으로 교체해도 V127의 unique 범위와 clone pin 열은 되돌아가지 않습니다. +노드별 replica 등록 뒤에는 기존 global catalog jar나 legacy writer를 rollback 경로로 사용하지 +않습니다. 등록과 신규 생성을 중지한 상태에서 호환 버전으로 복구하거나 별도로 검증한 DB +복구 지점으로 돌아갑니다. 기존 이미지 행이나 UUID를 임의로 삭제해 전역 unique를 복원하지 +않습니다. + 그래서 운영 환경은 부트스트랩 절차를 마친 뒤에야 쓸 수 있습니다. `staging`과 `prod`는 이 절차를 그대로 공유합니다. diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java index 9bf6ba08..55746b88 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java @@ -1,5 +1,6 @@ package kr.ac.pusan.pickle.admin; +import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -24,6 +25,8 @@ import kr.ac.pusan.pickle.inventory.OsImage; import kr.ac.pusan.pickle.inventory.OsImageRepository; import kr.ac.pusan.pickle.inventory.dto.VmFlavorResponse; +import kr.ac.pusan.pickle.provisioning.VmNicConfiguration; +import kr.ac.pusan.pickle.proxmox.ProxmoxClient; import kr.ac.pusan.pickle.security.AuthenticatedUser; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; @@ -49,16 +52,18 @@ public class AdminInventoryService { private final NodeRepository nodeRepository; private final AdminNodeQueryService adminNodeQueryService; private final AuditService auditService; + private final ProxmoxClient proxmox; public AdminInventoryService(OsImageRepository osImageRepository, VmFlavorRepository vmFlavorRepository, NodeRepository nodeRepository, AdminNodeQueryService adminNodeQueryService, - AuditService auditService) { + AuditService auditService, ProxmoxClient proxmox) { this.osImageRepository = osImageRepository; this.vmFlavorRepository = vmFlavorRepository; this.nodeRepository = nodeRepository; this.adminNodeQueryService = adminNodeQueryService; this.auditService = auditService; + this.proxmox = proxmox; } /** Contract {@code listAdminOsImages}: every OS image, retired revisions included. */ @@ -85,6 +90,13 @@ public AdminOsImageResponse updateCatalogStatus(AuthenticatedUser actor, UUID im UpdateOsImageStatusRequest request, String ip) { OsImage image = osImageRepository.findByPublicId(imageId) .orElseThrow(() -> notFound("해당 OS 이미지가 존재하지 않습니다.")); + if (image.getStatus() != request.status() && request.status() == CatalogStatus.ACTIVE) { + Node node = nodeRepository.findById(image.getNodeId()) + .orElseThrow(() -> notFound("OS 이미지가 속한 노드가 존재하지 않습니다.")); + node.vmNicRequirements().ifPresent(requirements -> VmNicConfiguration.requirePrepared( + nicConfig(proxmox.currentVmConfig(node.getApiHost(), node.getName(), + image.getProxmoxVmid())), requirements)); + } if (image.getStatus() != request.status()) { String fromStatus = image.getStatus().name(); image.setStatus(request.status()); @@ -102,6 +114,10 @@ public NodeSummaryResponse updateNodeStatus(AuthenticatedUser actor, UUID public Node node = nodeRepository.findByPublicId(publicNodeId) .orElseThrow(() -> notFound("해당 노드가 존재하지 않습니다.")); long nodeId = node.getId(); + if (node.getStatus() != request.status() + && request.status() == kr.ac.pusan.pickle.inventory.NodeStatus.ACTIVE) { + node.requireProvisioningReadiness(Instant.now()); + } if (node.getStatus() != request.status()) { String fromStatus = node.getStatus().name(); node.setStatus(request.status()); @@ -212,4 +228,9 @@ private static ApiException notFound(String detail) { return new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", detail); } + + private static String nicConfig(Map config) { + Object value = config == null ? null : config.get("net0"); + return value == null ? null : value.toString(); + } } diff --git a/src/main/java/kr/ac/pusan/pickle/config/NetworkPolicyProperties.java b/src/main/java/kr/ac/pusan/pickle/config/NetworkPolicyProperties.java new file mode 100644 index 00000000..0f87e99a --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/config/NetworkPolicyProperties.java @@ -0,0 +1,22 @@ +package kr.ac.pusan.pickle.config; + +import java.util.List; +import kr.ac.pusan.pickle.networkpolicy.SourcePolicy; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Opt-in network policy settings; an unconfigured deployment keeps its existing behavior. */ +@ConfigurationProperties(prefix = "pickle.network-policy") +public record NetworkPolicyProperties(boolean enabled, List campusSourceCidrs) { + + public NetworkPolicyProperties { + campusSourceCidrs = campusSourceCidrs == null ? List.of() + : SourcePolicy.parse(campusSourceCidrs, true).cidrValues(); + } + + public SourcePolicy campusPreset(boolean ipv6Supported) { + if (campusSourceCidrs.isEmpty()) { + throw new IllegalStateException("교내 출발지 IP 범위가 설정되지 않았습니다."); + } + return SourcePolicy.parse(campusSourceCidrs, ipv6Supported); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/CloneImagePin.java b/src/main/java/kr/ac/pusan/pickle/inventory/CloneImagePin.java new file mode 100644 index 00000000..564e70cb --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/inventory/CloneImagePin.java @@ -0,0 +1,66 @@ +package kr.ac.pusan.pickle.inventory; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +/** Immutable clone coordinates, independent of the request's public image identity. */ +public record CloneImagePin(long imageId, long nodeId, int templateVmid, String revisionSha256) { + + public CloneImagePin { + if (imageId <= 0 || nodeId <= 0 || templateVmid <= 0 || revisionSha256 == null + || !revisionSha256.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("복제할 OS 이미지의 위치가 올바르지 않습니다."); + } + } + + public static CloneImagePin from(OsImage replica) { + if (replica.getId() == null || replica.getNodeId() == null) { + throw new IllegalArgumentException("등록된 OS 이미지만 복제 대상으로 고정할 수 있습니다."); + } + return new CloneImagePin(replica.getId(), replica.getNodeId(), replica.getProxmoxVmid(), + revisionSha256(replica)); + } + + /** A retry never silently follows an overwritten or retired catalog row. */ + public void requireUnchanged(OsImage granted, OsImage replica) { + if (!Objects.equals(replica.getId(), imageId) + || !Objects.equals(replica.getNodeId(), nodeId) + || replica.getProxmoxVmid() != templateVmid + || replica.getStatus() != CatalogStatus.ACTIVE + || !revisionSha256.equals(revisionSha256(granted)) + || !revisionSha256.equals(revisionSha256(replica)) + || !OsImageReplicaResolver.compatible(granted, replica)) { + throw new IllegalStateException("고정된 OS 이미지의 상태나 위치가 변경되었습니다. 관리자 확인이 필요합니다."); + } + } + + /** Creation provenance survives recovery; it must never authorize a clone on another current node. */ + public void requireCurrentNode(long currentNodeId) { + if (currentNodeId != nodeId) { + throw new IllegalStateException("현재 VM 위치가 최초 복제 위치와 다릅니다. 다시 복제할 수 없습니다."); + } + } + + /** Identifies registered revision metadata, not the mutable contents of a hypervisor disk. */ + public static String revisionSha256(OsImage image) { + List fields = List.of("pickle-clone-revision-v1", image.getName(), + Integer.toString(image.getVersion()), image.getOsFamily(), image.getOsVersion(), + image.getSshUsername(), Integer.toString(image.getMinDiskGb())); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (String field : fields) { + byte[] value = field.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array()); + digest.update(value); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("이미지 식별 정보를 계산할 수 없습니다.", impossible); + } + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/Node.java b/src/main/java/kr/ac/pusan/pickle/inventory/Node.java index 30d55fc1..62867042 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/Node.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/Node.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.UUID; import java.util.Map; +import java.util.Optional; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -81,6 +82,33 @@ public boolean isGpuNode() { return labels != null && Boolean.TRUE.equals(labels.get("gpu")); } + /** Existing numeric columns retain their monitoring meanings; placement reads the explicit budget. */ + public Optional placementCapacity(Instant observedAt) { + return PlacementCapacity.read(labels, cpuThreads, memoryMb, diskCapacityGb, observedAt); + } + + public Optional vmNicRequirements() { + return NodeVmNicRequirements.read(labels); + } + + /** New prepared nodes publish both placement and NIC facts before activation. */ + public void requireProvisioningReadiness(Instant observedAt) { + boolean prepared = labels != null && (labels.containsKey("placement_capacity") + || labels.containsKey("vm_nic_requirements")); + if (!prepared) { + return; + } + placementCapacity(observedAt).orElseThrow(Node::invalidPreparation); + NodeVmNicRequirements nic = vmNicRequirements().orElseThrow(Node::invalidPreparation); + if (nic.mtu() != 1370 || !nic.firewall()) { + throw invalidPreparation(); + } + } + + private static IllegalStateException invalidPreparation() { + return new IllegalStateException("노드의 프로비저닝 준비 정보가 완전하지 않습니다."); + } + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java index 3d06996f..6e429265 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java @@ -1,9 +1,13 @@ package kr.ac.pusan.pickle.inventory; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface NodeRepository extends JpaRepository { @@ -11,4 +15,9 @@ public interface NodeRepository extends JpaRepository { Optional findByPublicId(UUID publicId); List findByStatusOrderByIdAsc(NodeStatus status); + + /** Serializes approval reservations on every eligible node in deterministic order. */ + @Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select n from Node n where n.id in :ids order by n.id") + List findAllByIdForUpdate(@Param("ids") Collection ids); } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/NodeVmNicRequirements.java b/src/main/java/kr/ac/pusan/pickle/inventory/NodeVmNicRequirements.java new file mode 100644 index 00000000..9a6f0b8d --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/inventory/NodeVmNicRequirements.java @@ -0,0 +1,44 @@ +package kr.ac.pusan.pickle.inventory; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** Explicit requirements registered for a prepared node; absence retains legacy behavior. */ +public record NodeVmNicRequirements(int mtu, boolean firewall) { + + public NodeVmNicRequirements { + if (mtu < 1280 || mtu > 1500 || !firewall) { + throw invalid(); + } + } + + public static Optional read(Map labels) { + if (labels == null || !labels.containsKey("vm_nic_requirements")) { + return Optional.empty(); + } + Object value = labels.get("vm_nic_requirements"); + if (!(value instanceof Map document) + || !document.keySet().equals(Set.of("schema_version", "mtu", "firewall")) + || integer(document.get("schema_version")) != 1 + || !Boolean.TRUE.equals(document.get("firewall"))) { + throw invalid(); + } + return Optional.of(new NodeVmNicRequirements(integer(document.get("mtu")), true)); + } + + private static int integer(Object value) { + if (!(value instanceof Integer) && !(value instanceof Long)) { + throw invalid(); + } + long number = ((Number) value).longValue(); + if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) { + throw invalid(); + } + return (int) number; + } + + private static IllegalStateException invalid() { + return new IllegalStateException("노드의 VM 네트워크 준비 정보를 확인할 수 없습니다."); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageCatalogService.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageCatalogService.java new file mode 100644 index 00000000..40f7a37e --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageCatalogService.java @@ -0,0 +1,51 @@ +package kr.ac.pusan.pickle.inventory; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Public logical catalog over node-specific image inventory rows. */ +@Service +public class OsImageCatalogService { + + private final OsImageRepository images; + private final NodeRepository nodes; + + public OsImageCatalogService(OsImageRepository images, NodeRepository nodes) { + this.images = images; + this.nodes = nodes; + } + + @Transactional(readOnly = true) + public List selectable() { + List rows = images.findAllInDisplayOrder(); + return OsImageSelection.selectable(rows, activeNodeIds()); + } + + @Transactional(readOnly = true) + public OsImage requireSelectable(UUID publicId) { + OsImage requested = images.findByPublicId(publicId) + .orElseThrow(() -> new UnknownImageException()); + List rows = images.findAllInDisplayOrder(); + OsImage canonical = OsImageSelection.canonicalOf(requested, rows) + .orElseThrow(UnknownImageException::new); + if (!OsImageSelection.isSelectable(requested, rows, activeNodeIds())) { + throw new UnavailableImageException(); + } + return canonical; + } + + private Set activeNodeIds() { + return nodes.findByStatusOrderByIdAsc(NodeStatus.ACTIVE).stream() + .map(Node::getId).collect(Collectors.toSet()); + } + + public static final class UnknownImageException extends RuntimeException { + } + + public static final class UnavailableImageException extends RuntimeException { + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageController.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageController.java index e097fa30..1cdf48ea 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageController.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageController.java @@ -19,17 +19,17 @@ @Tag(name = "os-image-controller") public class OsImageController { - private final OsImageRepository osImageRepository; + private final OsImageCatalogService catalog; - public OsImageController(OsImageRepository osImageRepository) { - this.osImageRepository = osImageRepository; + public OsImageController(OsImageCatalogService catalog) { + this.catalog = catalog; } @GetMapping @Transactional(readOnly = true) public List listOsImages() { - return osImageRepository.findByStatusInDisplayOrder(CatalogStatus.ACTIVE).stream() - .map(OsImageResponse::from) + return catalog.selectable().stream() + .map(OsImageResponse::available) .toList(); } } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageReplicaResolver.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageReplicaResolver.java new file mode 100644 index 00000000..db6b17d1 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageReplicaResolver.java @@ -0,0 +1,36 @@ +package kr.ac.pusan.pickle.inventory; + +import java.util.Objects; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Resolves the exact granted image revision on the selected node. */ +@Service +public class OsImageReplicaResolver { + + private final OsImageRepository images; + + public OsImageReplicaResolver(OsImageRepository images) { + this.images = images; + } + + @Transactional(readOnly = true) + public OsImage resolve(OsImage granted, Long nodeId) { + return images.findByNameAndVersionAndNodeIdAndStatus( + granted.getName(), granted.getVersion(), nodeId, CatalogStatus.ACTIVE) + .filter(candidate -> compatible(granted, candidate)) + .orElseThrow(() -> new IllegalStateException( + "선택한 노드에 승인된 OS 이미지와 동일한 버전이 없습니다 (" + + granted.getName() + ", 버전 " + granted.getVersion() + ")")); + } + + /** A revision may have different template VMIDs, but never different guest semantics. */ + public static boolean compatible(OsImage granted, OsImage candidate) { + return Objects.equals(granted.getName(), candidate.getName()) + && granted.getVersion() == candidate.getVersion() + && Objects.equals(granted.getOsFamily(), candidate.getOsFamily()) + && Objects.equals(granted.getOsVersion(), candidate.getOsVersion()) + && Objects.equals(granted.getSshUsername(), candidate.getSshUsername()) + && granted.getMinDiskGb() == candidate.getMinDiskGb(); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java index 62e1f8e4..52ff4e4f 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java @@ -5,6 +5,7 @@ import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.repository.query.Param; public interface OsImageRepository extends JpaRepository { @@ -57,4 +58,13 @@ default List findByStatusInDisplayOrder(CatalogStatus status) { * placement uses. */ boolean existsByNameAndNodeIdAndStatus(String name, Long nodeId, CatalogStatus status); + + /** The selected node must host the granted revision, not merely the same OS name. */ + Optional findByNameAndVersionAndNodeIdAndStatus( + String name, int version, Long nodeId, CatalogStatus status); + + /** Locks one logical revision and all node replicas before approval picks a clone source. */ + @Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select i from OsImage i where i.name = :name and i.version = :version order by i.id") + List findRevisionForUpdate(@Param("name") String name, @Param("version") int version); } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageSelection.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageSelection.java new file mode 100644 index 00000000..1064bb76 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageSelection.java @@ -0,0 +1,64 @@ +package kr.ac.pusan.pickle.inventory; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** Selects logical catalog identities while retaining every node-specific inventory row. */ +public final class OsImageSelection { + + private OsImageSelection() { + } + + /** The input contains all rows in display order, including disabled canonical identities. */ + public static List selectable(List allCatalogRows, Set activeNodeIds) { + Set canonicalIds = groups(allCatalogRows).values().stream() + .filter(rows -> hasAvailableReplica(canonical(rows), rows, activeNodeIds)) + .map(rows -> canonical(rows).getId()) + .collect(Collectors.toSet()); + return allCatalogRows.stream().filter(image -> canonicalIds.contains(image.getId())).toList(); + } + + public static boolean isSelectable(OsImage requested, List allCatalogRows, Set activeNodeIds) { + List rows = groups(allCatalogRows).get(identity(requested)); + if (rows == null) { + return false; + } + OsImage canonical = canonical(rows); + return OsImageReplicaResolver.compatible(canonical, requested) + && hasAvailableReplica(canonical, rows, activeNodeIds); + } + + public static Optional canonicalOf(OsImage requested, List allCatalogRows) { + List rows = groups(allCatalogRows).get(identity(requested)); + return rows == null ? Optional.empty() : Optional.of(canonical(rows)); + } + + private static boolean hasAvailableReplica(OsImage canonical, List rows, Set activeNodeIds) { + return rows.stream().anyMatch(image -> image.getStatus() == CatalogStatus.ACTIVE + && activeNodeIds.contains(image.getNodeId()) && OsImageReplicaResolver.compatible(canonical, image)); + } + + private static OsImage canonical(List rows) { + return rows.stream().min(Comparator.comparing(OsImage::getId)).orElseThrow(); + } + + private static Map> groups(List rows) { + if (rows.stream().anyMatch(image -> image.getId() == null)) { + throw new IllegalArgumentException("등록되지 않은 이미지는 카탈로그에서 선택할 수 없습니다."); + } + return rows.stream().collect(Collectors.groupingBy(OsImageSelection::identity, + LinkedHashMap::new, Collectors.toList())); + } + + private static OsImageRevisionIdentity identity(OsImage image) { + return new OsImageRevisionIdentity(image.getName(), image.getVersion()); + } + + private record OsImageRevisionIdentity(String name, int version) { + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/PlacementCapacity.java b/src/main/java/kr/ac/pusan/pickle/inventory/PlacementCapacity.java new file mode 100644 index 00000000..27ffeded --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/inventory/PlacementCapacity.java @@ -0,0 +1,107 @@ +package kr.ac.pusan.pickle.inventory; + +import java.math.BigInteger; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Validated capacity reserved by an external node registration tool. This pure + * parser does not enable placement or change the legacy monitoring denominators. + */ +public record PlacementCapacity(CapacityAmounts physical, CapacityAmounts reserved, + CapacityAmounts allocatable, Instant measuredAt) { + + private static final Set DOCUMENT_FIELDS = Set.of( + "schema_version", "physical", "reserved", "allocatable", "measured_at"); + private static final Set RESOURCE_FIELDS = Set.of("cpu_threads", "memory_mb", "disk_gb"); + + public PlacementCapacity { + if (physical == null || reserved == null || allocatable == null || measuredAt == null + || physical.cpuThreads() > Integer.MAX_VALUE || physical.memoryMb() > Integer.MAX_VALUE + || !consistent(physical.cpuThreads(), reserved.cpuThreads(), allocatable.cpuThreads()) + || !consistent(physical.memoryMb(), reserved.memoryMb(), allocatable.memoryMb()) + || !consistent(physical.diskGb(), reserved.diskGb(), allocatable.diskGb())) { + throw invalid(); + } + } + + public record CapacityAmounts(long cpuThreads, long memoryMb, long diskGb) { + public CapacityAmounts { + if (cpuThreads < 0 || memoryMb < 0 || diskGb < 0) { + throw invalid(); + } + } + } + + /** + * Absence is distinct from malformed data: a feature-disabled legacy caller + * may retain its existing behavior, but must never treat invalid labels as absent. + * The memory column already excludes the reserve; use allocatable directly. + */ + public static Optional read(Map labels, + int nodeCpuThreads, int nodeMemoryMb, Long nodeDiskCapacityGb, Instant observedAt) { + if (labels == null || !labels.containsKey("placement_capacity")) { + return Optional.empty(); + } + Map document = exactMap(labels.get("placement_capacity"), DOCUMENT_FIELDS); + if (integer(document.get("schema_version")) != 1 || observedAt == null) { + throw invalid(); + } + Instant measuredAt; + try { + if (!(document.get("measured_at") instanceof String value)) { + throw invalid(); + } + measuredAt = Instant.parse(value); + } catch (DateTimeParseException e) { + throw invalid(); + } + PlacementCapacity result = new PlacementCapacity(resources(document.get("physical")), + resources(document.get("reserved")), resources(document.get("allocatable")), measuredAt); + if (measuredAt.isAfter(observedAt) + || result.physical().cpuThreads() != nodeCpuThreads + || result.allocatable().memoryMb() != nodeMemoryMb + || nodeDiskCapacityGb == null || result.physical().diskGb() != nodeDiskCapacityGb) { + throw invalid(); + } + return Optional.of(result); + } + + private static CapacityAmounts resources(Object value) { + Map values = exactMap(value, RESOURCE_FIELDS); + return new CapacityAmounts(integer(values.get("cpu_threads")), integer(values.get("memory_mb")), + integer(values.get("disk_gb"))); + } + + private static Map exactMap(Object value, Set fields) { + if (!(value instanceof Map map) || !map.keySet().equals(fields)) { + throw invalid(); + } + return map; + } + + private static long integer(Object value) { + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) { + return ((Number) value).longValue(); + } + if (value instanceof BigInteger number) { + try { + return number.longValueExact(); + } catch (ArithmeticException e) { + throw invalid(); + } + } + throw invalid(); + } + + private static boolean consistent(long physical, long reserved, long allocatable) { + return physical > 0 && reserved < physical && allocatable == physical - reserved; + } + + private static IllegalStateException invalid() { + return new IllegalStateException("노드의 예약 용량 정보를 확인할 수 없습니다"); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java b/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java index 945d65e8..0b4cd680 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java @@ -24,4 +24,12 @@ public static OsImageResponse from(OsImage image) { image.getVersion(), image.getMinDiskGb(), image.getStatus(), image.getNotes()); } + + /** A disabled canonical row is exposed as available while an ACTIVE compatible replica exists. */ + public static OsImageResponse available(OsImage image) { + OsImageResponse response = from(image); + return new OsImageResponse(response.id(), response.name(), response.displayName(), + response.osFamily(), response.osVersion(), response.sshUsername(), response.version(), + response.minDiskGb(), CatalogStatus.ACTIVE, response.notes()); + } } diff --git a/src/main/java/kr/ac/pusan/pickle/networkpolicy/CidrBlock.java b/src/main/java/kr/ac/pusan/pickle/networkpolicy/CidrBlock.java new file mode 100644 index 00000000..822de94b --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/networkpolicy/CidrBlock.java @@ -0,0 +1,167 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import java.net.InetAddress; +import java.util.Arrays; + +/** A literal, network-aligned IP prefix. Parsing never performs a DNS lookup. */ +public final class CidrBlock { + + private final byte[] network; + private final int prefixLength; + private final String value; + + private CidrBlock(byte[] network, int prefixLength) { + this.network = network.clone(); + this.prefixLength = prefixLength; + this.value = format(network) + "/" + prefixLength; + } + + public static CidrBlock parse(String value) { + if (value == null || value.length() > 64) { + throw invalid(); + } + int slash = value.indexOf('/'); + if (slash <= 0 || slash != value.lastIndexOf('/')) { + throw invalid(); + } + byte[] address = literal(value.substring(0, slash)); + String bits = value.substring(slash + 1); + if (!bits.matches("0|[1-9][0-9]{0,2}")) { + throw invalid(); + } + int prefix = Integer.parseInt(bits); + if (prefix > address.length * 8) { + throw invalid(); + } + byte[] masked = mask(address, prefix); + if (!Arrays.equals(address, masked)) { + throw new IllegalArgumentException("CIDR에는 네트워크 주소를 입력해 주세요."); + } + return new CidrBlock(masked, prefix); + } + + /** Converts an explicit host literal to /32 or /128 for the editor boundary. */ + public static CidrBlock host(String value) { + byte[] address = literal(value); + return new CidrBlock(address, address.length * 8); + } + + public boolean ipv6() { + return network.length == 16; + } + + public boolean contains(String address) { + byte[] candidate = literal(address); + return candidate.length == network.length + && Arrays.equals(network, mask(candidate, prefixLength)); + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean equals(Object other) { + return other instanceof CidrBlock block && value.equals(block.value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + private static byte[] literal(String value) { + if (value == null || value.isEmpty() || value.length() > 45) { + throw invalid(); + } + if (!value.contains(":")) { + String[] parts = value.split("\\.", -1); + if (parts.length != 4) { + throw invalid(); + } + byte[] result = new byte[4]; + for (int i = 0; i < parts.length; i++) { + if (!parts[i].matches("0|[1-9][0-9]{0,2}")) { + throw invalid(); + } + int octet = Integer.parseInt(parts[i]); + if (octet > 255) { + throw invalid(); + } + result[i] = (byte) octet; + } + return result; + } + // Brackets, scope IDs, whitespace and mapped IPv4 forms are not accepted. + if (!value.matches("[0-9a-fA-F:]+")) { + throw invalid(); + } + try { + byte[] result = InetAddress.ofLiteral(value).getAddress(); + if (result.length != 16) { + throw invalid(); + } + return result; + } catch (IllegalArgumentException e) { + throw invalid(); + } + } + + private static byte[] mask(byte[] address, int prefix) { + byte[] result = address.clone(); + for (int i = 0; i < result.length; i++) { + int remaining = prefix - i * 8; + if (remaining <= 0) { + result[i] = 0; + } else if (remaining < 8) { + result[i] &= (byte) (0xff << (8 - remaining)); + } + } + return result; + } + + private static String format(byte[] address) { + if (address.length == 4) { + return (address[0] & 255) + "." + (address[1] & 255) + "." + + (address[2] & 255) + "." + (address[3] & 255); + } + int[] words = new int[8]; + for (int i = 0; i < words.length; i++) { + words[i] = ((address[i * 2] & 255) << 8) | (address[i * 2 + 1] & 255); + } + int bestStart = -1; + int bestLength = 1; + for (int i = 0; i < words.length;) { + if (words[i] != 0) { + i++; + continue; + } + int start = i; + while (i < words.length && words[i] == 0) { + i++; + } + if (i - start > bestLength) { + bestStart = start; + bestLength = i - start; + } + } + StringBuilder result = new StringBuilder(); + for (int i = 0; i < words.length;) { + if (i == bestStart) { + result.append("::"); + i += bestLength; + } else { + if (!result.isEmpty() && result.charAt(result.length() - 1) != ':') { + result.append(':'); + } + result.append(Integer.toHexString(words[i++])); + } + } + return result.toString(); + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("올바른 IP 주소 또는 CIDR을 입력해 주세요."); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/networkpolicy/NetworkPolicyCapability.java b/src/main/java/kr/ac/pusan/pickle/networkpolicy/NetworkPolicyCapability.java new file mode 100644 index 00000000..34b3aedf --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/networkpolicy/NetworkPolicyCapability.java @@ -0,0 +1,34 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import java.util.Set; +import kr.ac.pusan.pickle.config.NetworkPolicyProperties; +import org.springframework.stereotype.Component; + +/** No policy may be presented as configured on an unprepared enforcement path. */ +@Component +public class NetworkPolicyCapability { + + public static final String SOURCE_ACL = "source-acl-v1"; + private final NetworkPolicyProperties properties; + + public NetworkPolicyCapability(NetworkPolicyProperties properties) { + this.properties = properties; + } + + public boolean sourceAclAvailable(Set reportedCapabilities) { + return properties.enabled() && reportedCapabilities != null + && reportedCapabilities.contains(SOURCE_ACL); + } + + public void requireSourceAcl(Set reportedCapabilities) { + if (!sourceAclAvailable(reportedCapabilities)) { + throw new IllegalStateException("이 공개 경로에는 출발지 접근 정책이 구성되지 않았습니다."); + } + } + + public void requireVmBackend(boolean configured, boolean healthy) { + if (!properties.enabled() || !configured || !healthy) { + throw new IllegalStateException("이 노드에는 VM 네트워크 정책이 구성되지 않았습니다."); + } + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/networkpolicy/SourcePolicy.java b/src/main/java/kr/ac/pusan/pickle/networkpolicy/SourcePolicy.java new file mode 100644 index 00000000..f968e923 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/networkpolicy/SourcePolicy.java @@ -0,0 +1,40 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import java.util.HashSet; +import java.util.List; + +/** An explicit source allowlist. An empty policy denies all sources. */ +public record SourcePolicy(List allowedCidrs) { + + public static final int MAX_CIDRS = 128; + + public SourcePolicy { + if (allowedCidrs == null || allowedCidrs.size() > MAX_CIDRS + || allowedCidrs.stream().anyMatch(java.util.Objects::isNull)) { + throw new IllegalArgumentException("출발지 CIDR은 최대 " + MAX_CIDRS + "개까지 지정할 수 있습니다."); + } + allowedCidrs = List.copyOf(allowedCidrs); + if (new HashSet<>(allowedCidrs).size() != allowedCidrs.size()) { + throw new IllegalArgumentException("중복된 출발지 CIDR을 제거해 주세요."); + } + } + + public static SourcePolicy parse(List cidrs, boolean ipv6Supported) { + if (cidrs == null || cidrs.size() > MAX_CIDRS) { + throw new IllegalArgumentException("출발지 CIDR 목록을 지정해 주세요."); + } + SourcePolicy policy = new SourcePolicy(cidrs.stream().map(CidrBlock::parse).toList()); + if (!ipv6Supported && policy.allowedCidrs.stream().anyMatch(CidrBlock::ipv6)) { + throw new IllegalArgumentException("이 공개 경로는 IPv6 출발지를 지원하지 않습니다."); + } + return policy; + } + + public boolean allows(String address) { + return allowedCidrs.stream().anyMatch(cidr -> cidr.contains(address)); + } + + public List cidrValues() { + return allowedCidrs.stream().map(CidrBlock::toString).toList(); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkPolicyCompiler.java b/src/main/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkPolicyCompiler.java new file mode 100644 index 00000000..1ec39d32 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkPolicyCompiler.java @@ -0,0 +1,76 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Action.ACCEPT; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Direction.IN; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Protocol.TCP; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Compiles system-owned paths before user rules without adding workspace trust. */ +public final class VmNetworkPolicyCompiler { + + public static final int MAX_USER_RULES = 128; + + private VmNetworkPolicyCompiler() { + } + + public static Plan compile(String guestAddress, String sshGatewayAddress, + String terminalAddress, List publishedPaths, List userRules) { + CidrBlock guest = CidrBlock.host(guestAddress); + if (guest.ipv6()) { + throw new IllegalArgumentException("현재 VM IP 할당은 IPv4만 지원합니다."); + } + if (publishedPaths == null || userRules == null || userRules.size() > MAX_USER_RULES) { + throw new IllegalArgumentException("VM 네트워크 규칙은 최대 " + MAX_USER_RULES + "개까지 지정할 수 있습니다."); + } + List> rules = new ArrayList<>(); + rules.add(systemRule(new VmNetworkRule(IN, ACCEPT, TCP, + CidrBlock.host(sshGatewayAddress), 22, 22), "SSH gateway")); + rules.add(systemRule(new VmNetworkRule(IN, ACCEPT, TCP, + CidrBlock.host(terminalAddress), 22, 22), "Web terminal")); + for (PublishedPath path : publishedPaths) { + rules.add(systemRule(new VmNetworkRule(IN, ACCEPT, path.protocol(), + CidrBlock.host(path.sourceAddress()), path.targetPort(), path.targetPort()), + "Published service")); + } + for (VmNetworkRule rule : userRules) { + if (rule == null) { + throw new IllegalArgumentException("빈 VM 네트워크 규칙은 사용할 수 없습니다."); + } + rules.add(rule.pveFields()); + } + Map options = Map.of("enable", "1", "policy_in", "DROP", + "policy_out", "ACCEPT", "ipfilter", "1", "macfilter", "1", + "dhcp", "0", "radv", "0", "ndp", "1"); + return new Plan(options, rules, List.of(guest.toString())); + } + + private static Map systemRule(VmNetworkRule rule, String purpose) { + Map result = new LinkedHashMap<>(rule.pveFields()); + result.put("comment", "Managed platform path: " + purpose); + return Map.copyOf(result); + } + + /** Only a trusted source and the actually published target port are opened. */ + public record PublishedPath(String sourceAddress, VmNetworkRule.Protocol protocol, int targetPort) { + public PublishedPath { + CidrBlock.host(sourceAddress); + if ((protocol != TCP && protocol != VmNetworkRule.Protocol.UDP) + || targetPort < 1 || targetPort > 65535) { + throw new IllegalArgumentException("공개 경로에는 TCP/UDP와 유효한 대상 포트가 필요합니다."); + } + } + } + + public record Plan(Map options, List> rules, + List ipFilterNet0) { + public Plan { + options = Map.copyOf(options); + rules = rules.stream().map(Map::copyOf).toList(); + ipFilterNet0 = List.copyOf(ipFilterNet0); + } + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkRule.java b/src/main/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkRule.java new file mode 100644 index 00000000..ea9ec937 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkRule.java @@ -0,0 +1,51 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** A typed guest rule; untrusted text is never rendered as a firewall expression. */ +public record VmNetworkRule(Direction direction, Action action, Protocol protocol, + CidrBlock peer, Integer portStart, Integer portEnd) { + + public enum Direction { IN, OUT } + public enum Action { ACCEPT, DROP } + public enum Protocol { ANY, TCP, UDP, ICMP, ICMPV6 } + + public VmNetworkRule { + if (direction == null || action == null || protocol == null || peer == null) { + throw new IllegalArgumentException("방향, 동작, 프로토콜과 IP 범위를 지정해 주세요."); + } + if ((portStart == null) != (portEnd == null)) { + throw new IllegalArgumentException("포트 범위의 시작과 끝을 모두 지정해 주세요."); + } + if (portStart != null && (portStart < 1 || portEnd > 65535 || portStart > portEnd)) { + throw new IllegalArgumentException("포트 범위는 1부터 65535 사이여야 합니다."); + } + if (portStart != null && protocol != Protocol.TCP && protocol != Protocol.UDP) { + throw new IllegalArgumentException("포트 범위는 TCP 또는 UDP 규칙에만 지정할 수 있습니다."); + } + if ((protocol == Protocol.ICMP && peer.ipv6()) + || (protocol == Protocol.ICMPV6 && !peer.ipv6())) { + throw new IllegalArgumentException("ICMP 프로토콜과 IP 주소 종류가 일치하지 않습니다."); + } + } + + public Map pveFields() { + Map fields = new LinkedHashMap<>(); + fields.put("type", direction.name().toLowerCase(Locale.ROOT)); + fields.put("action", action.name()); + fields.put("enable", "1"); + fields.put("iface", "net0"); + fields.put(direction == Direction.IN ? "source" : "dest", peer.toString()); + if (protocol != Protocol.ANY) { + fields.put("proto", protocol == Protocol.ICMPV6 ? "ipv6-icmp" + : protocol.name().toLowerCase(Locale.ROOT)); + } + if (portStart != null) { + fields.put("dport", portStart.equals(portEnd) ? portStart.toString() + : portStart + ":" + portEnd); + } + return Map.copyOf(fields); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementBudget.java b/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementBudget.java new file mode 100644 index 00000000..3b08cc37 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementBudget.java @@ -0,0 +1,65 @@ +package kr.ac.pusan.pickle.provisioning; + +import java.time.Instant; +import kr.ac.pusan.pickle.inventory.Node; +import kr.ac.pusan.pickle.inventory.PlacementCapacity; +import org.jspecify.annotations.Nullable; + +/** Capacity arithmetic only; callers must serialize the allocation read and reservation write. */ +public final class NodePlacementBudget { + + private final long cpuThreads; + private final long memoryMb; + private final @Nullable Long diskGb; + private final boolean reservationAware; + + private NodePlacementBudget(long cpuThreads, long memoryMb, @Nullable Long diskGb, boolean reservationAware) { + if (cpuThreads <= 0 || memoryMb <= 0 || (diskGb != null && diskGb <= 0)) { + throw new IllegalStateException("노드의 배치 가능 용량을 확인할 수 없습니다."); + } + this.cpuThreads = cpuThreads; + this.memoryMb = memoryMb; + this.diskGb = diskGb; + this.reservationAware = reservationAware; + } + + public static NodePlacementBudget from(Node node, Instant observedAt) { + return node.placementCapacity(observedAt).map(capacity -> { + PlacementCapacity.CapacityAmounts available = capacity.allocatable(); + return new NodePlacementBudget(available.cpuThreads(), available.memoryMb(), available.diskGb(), true); + }).orElseGet(() -> new NodePlacementBudget(node.getCpuThreads(), node.getMemoryMb(), node.getDiskCapacityGb(), false)); + } + + public boolean fits(VmPlacementResources allocated, VmPlacementResources requested) { + if (allocated.cpuThreads() < 0 || allocated.memoryMb() < 0 || allocated.diskGb() < 0 + || requested.cpuThreads() <= 0 || requested.memoryMb() <= 0 || requested.diskGb() <= 0) { + return false; + } + if (!fitsDimension(memoryMb, allocated.memoryMb(), requested.memoryMb())) { + return false; + } + if (reservationAware) { + return fitsDimension(cpuThreads, allocated.cpuThreads(), requested.cpuThreads()) + && fitsDimension(diskGb, allocated.diskGb(), requested.diskGb()); + } + // A legacy disk total remains advisory for aggregate use, but one disk must fit that pool. + return diskGb == null || requested.diskGb() <= diskGb; + } + + public double score(VmPlacementResources allocated) { + double freeMemoryRatio = Math.max(0, 1.0 - (double) allocated.memoryMb() / memoryMb); + double cpuAllocationRatio = (double) allocated.cpuThreads() / cpuThreads; + return 0.6 * freeMemoryRatio + 0.4 * (1.0 - cpuAllocationRatio); + } + + public boolean reservationAware() { + return reservationAware; + } + + private static boolean fitsDimension(long limit, long allocated, long requested) { + return requested <= limit && allocated <= limit - requested; + } + + public record VmPlacementResources(long cpuThreads, long memoryMb, long diskGb) { + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementService.java b/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementService.java index 3d4d9d72..cf734dc2 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementService.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/NodePlacementService.java @@ -8,6 +8,7 @@ import kr.ac.pusan.pickle.inventory.NodeStatus; import kr.ac.pusan.pickle.inventory.CatalogStatus; import kr.ac.pusan.pickle.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageReplicaResolver; import kr.ac.pusan.pickle.inventory.OsImageRepository; import kr.ac.pusan.pickle.vm.Vm; import kr.ac.pusan.pickle.vm.VmRepository; @@ -19,7 +20,7 @@ /** * Step-1 node placement: among ACTIVE nodes that host an - * ACTIVE OS image of the same name, pick the one with the best headroom + * ACTIVE replica of the granted OS revision, pick the one with the best headroom * score; an admin-forced node (approval form {@code nodeId}) always wins. * *

Scoring is deliberately simple for the single-node cluster of today — @@ -65,8 +66,10 @@ public Node place(Vm vm, OsImage image, Long forcedNodeId) { // same IllegalStateException the no-candidate path throws, so the // pipeline errors cleanly at the place step — instead of proceeding // to a clone that would fail on a node without the image. - if (!imageRepository.existsByNameAndNodeIdAndStatus( - image.getName(), node.getId(), CatalogStatus.ACTIVE)) { + if (imageRepository.findByNameAndVersionAndNodeIdAndStatus( + image.getName(), image.getVersion(), node.getId(), CatalogStatus.ACTIVE) + .filter(candidate -> OsImageReplicaResolver.compatible(image, candidate)) + .isEmpty()) { throw new IllegalStateException("관리자 지정 노드 " + node.getId() + "에 OS 이미지 " + image.getName() + "이(가) 없습니다"); } @@ -74,11 +77,10 @@ public Node place(Vm vm, OsImage image, Long forcedNodeId) { node.getName()); return node; } - // Nodes hosting an ACTIVE image of the same name (image rows are - // per-node; a multi-node cluster clones the image under one name). + // A newer revision must not silently replace the one that was approved. Set imageNodeIds = imageRepository .findByStatus(CatalogStatus.ACTIVE).stream() - .filter(candidate -> candidate.getName().equals(image.getName())) + .filter(candidate -> OsImageReplicaResolver.compatible(image, candidate)) .map(OsImage::getNodeId) .collect(Collectors.toSet()); return nodeRepository.findByStatusOrderByIdAsc(NodeStatus.ACTIVE).stream() diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java index ded2df45..e3510d00 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java @@ -18,6 +18,8 @@ import kr.ac.pusan.pickle.notification.NotificationService; 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.inventory.CloneImagePin; import kr.ac.pusan.pickle.inventory.OsImage; import kr.ac.pusan.pickle.inventory.OsImageRepository; import kr.ac.pusan.pickle.ipam.AllocationStatus; @@ -40,8 +42,6 @@ import kr.ac.pusan.pickle.vm.VmEventType; import kr.ac.pusan.pickle.vm.VmRepository; import kr.ac.pusan.pickle.vm.VmStatus; -import kr.ac.pusan.pickle.request.vm.VmRequestDetail; -import kr.ac.pusan.pickle.request.vm.VmRequestDetailRepository; import org.jobrunr.jobs.annotations.Job; import org.jobrunr.scheduling.JobScheduler; import org.slf4j.Logger; @@ -109,11 +109,9 @@ public class ProvisionVmJob implements ProvisioningService { private final ProvisioningTaskRepository taskRepository; private final NodeRepository nodeRepository; private final OsImageRepository imageRepository; - private final VmRequestDetailRepository vmRequestDetailRepository; private final IpPoolRepository poolRepository; private final IpAllocationRepository allocationRepository; private final IpamService ipamService; - private final NodePlacementService placementService; private final ProxmoxClient proxmox; private final VmidSequence vmidSequence; private final JobScheduler jobScheduler; @@ -129,9 +127,9 @@ public class ProvisionVmJob implements ProvisioningService { public ProvisionVmJob(VmRepository vmRepository, VmEventRepository vmEventRepository, ProvisioningTaskRepository taskRepository, NodeRepository nodeRepository, - OsImageRepository imageRepository, VmRequestDetailRepository vmRequestDetailRepository, + OsImageRepository imageRepository, IpPoolRepository poolRepository, IpAllocationRepository allocationRepository, - IpamService ipamService, NodePlacementService placementService, ProxmoxClient proxmox, + IpamService ipamService, ProxmoxClient proxmox, VmidSequence vmidSequence, JobScheduler jobScheduler, PasswordEncoder passwordEncoder, VmPasswordGenerator passwordGenerator, CredentialCipher credentialCipher, NotificationService notificationService, ObjectMapper objectMapper, @@ -144,11 +142,9 @@ public ProvisionVmJob(VmRepository vmRepository, VmEventRepository vmEventReposi this.taskRepository = taskRepository; this.nodeRepository = nodeRepository; this.imageRepository = imageRepository; - this.vmRequestDetailRepository = vmRequestDetailRepository; this.poolRepository = poolRepository; this.allocationRepository = allocationRepository; this.ipamService = ipamService; - this.placementService = placementService; this.proxmox = proxmox; this.vmidSequence = vmidSequence; this.jobScheduler = jobScheduler; @@ -306,14 +302,23 @@ private void guard(ProvisioningTask task, Vm vm) { throw new PipelineHalted("vm status " + vm.getStatus() + " fails the guard"); } - /** Step 1: confirm the node (admin-forced node from the approval wins). */ + /** Step 1: verify the approval-time node and immutable clone source. */ private void place(Vm vm) { - OsImage image = imageRepository.findById(vm.getImageId()).orElseThrow( - () -> new IllegalStateException("OS 이미지 " + vm.getImageId() + "이 존재하지 않습니다")); - Long forcedNodeId = vmRequestDetailRepository.findById(vm.getRequestId()) - .map(VmRequestDetail::getNodeId).orElse(null); - Node node = placementService.place(vm, image, forcedNodeId); - vmRepository.assignNode(vm.getId(), node.getId(), Instant.now()); + try { + CloneImagePin pin = requiredClonePin(vm); + pin.requireCurrentNode(vm.getNodeId()); + Node node = node(vm); + if (node.getStatus() != NodeStatus.ACTIVE) { + throw new IllegalStateException("승인 시 선택한 노드가 더 이상 ACTIVE 상태가 아닙니다."); + } + OsImage granted = imageRepository.findById(vm.getImageId()).orElseThrow( + () -> new IllegalStateException("OS 이미지 " + vm.getImageId() + "이 존재하지 않습니다")); + OsImage replica = imageRepository.findById(pin.imageId()).orElseThrow( + () -> new IllegalStateException("고정된 복제 이미지가 존재하지 않습니다.")); + pin.requireUnchanged(granted, replica); + } catch (IllegalStateException unsafe) { + throw new ProvisioningSafetyException(unsafe.getMessage(), unsafe); + } } /** Step 2: allocate an IP from the node's pool (skip when already done). */ @@ -346,6 +351,18 @@ private void assignVmid(Vm vm) { /** Step 4: full clone of the OS image — only if the VMID does not exist yet. */ private void clone(Vm vm) { Node node = node(vm); + CloneImagePin pin; + try { + pin = requiredClonePin(vm); + pin.requireCurrentNode(node.getId()); + OsImage granted = imageRepository.findById(vm.getImageId()) + .orElseThrow(() -> new IllegalStateException("승인된 OS 이미지가 존재하지 않습니다.")); + OsImage replica = imageRepository.findById(pin.imageId()) + .orElseThrow(() -> new IllegalStateException("고정된 복제 이미지가 존재하지 않습니다.")); + pin.requireUnchanged(granted, replica); + } catch (IllegalStateException unsafe) { + throw new ProvisioningSafetyException(unsafe.getMessage(), unsafe); + } int vmid = requireVmid(vm); ClusterResource resident = findResource(node, vmid); if (resident != null) { @@ -366,8 +383,22 @@ private void clone(Vm vm) { log.info("provision vm {}: vmid {} already exists — clone skipped", vm.getId(), vmid); return; } - OsImage image = imageRepository.findById(vm.getImageId()).orElseThrow(); - String upid = proxmox.clone(node.getApiHost(), node.getName(), image.getProxmoxVmid(), + if (node.getStatus() != NodeStatus.ACTIVE) { + IllegalStateException inactive = new IllegalStateException( + "승인 시 선택한 노드가 더 이상 ACTIVE 상태가 아닙니다."); + throw new ProvisioningSafetyException(inactive.getMessage(), inactive); + } + try { + var requirements = node.vmNicRequirements(); + if (requirements.isPresent()) { + Map templateConfig = proxmox.currentVmConfig(node.getApiHost(), + node.getName(), pin.templateVmid()); + VmNicConfiguration.requirePrepared(nicConfig(templateConfig), requirements.get()); + } + } catch (IllegalStateException unsafe) { + throw new ProvisioningSafetyException(unsafe.getMessage(), unsafe); + } + String upid = proxmox.clone(node.getApiHost(), node.getName(), pin.templateVmid(), vmid, vm.getHostname()); proxmox.awaitTask(node.getApiHost(), node.getName(), upid); } @@ -414,7 +445,14 @@ private void configure(Vm vm) { params.put("ipconfig0", "ip=" + ip + "/" + cidrPrefix(pool.getCidr()) + ",gw=" + hostAddress(pool.getGateway())); firstDns(pool).ifPresent(dns -> params.put("nameserver", dns)); - params.put("net0", "virtio,bridge=" + node.getVmBridge()); + Map currentConfig = proxmox.currentVmConfig(node.getApiHost(), + node.getName(), requireVmid(vm)); + try { + params.put("net0", VmNicConfiguration.onBridge(nicConfig(currentConfig), + node.getVmBridge(), node.vmNicRequirements())); + } catch (IllegalStateException unsafe) { + throw new ProvisioningSafetyException(unsafe.getMessage(), unsafe); + } params.put("onboot", "1"); params.put("protection", "1"); params.put("tags", "pickle"); @@ -629,6 +667,14 @@ private void handleFailure(long taskId, long vmId, Exception e) { log.warn("provision vm {} failed at step {} (attempt {}): {}", vmId, step, task.getAttempts(), summarize(e), e); + if (e instanceof ProvisioningSafetyException) { + taskRepository.park(taskId, error, now); + vmRepository.transitionStatus(vmId, VmStatus.CREATING, VmStatus.NEEDS_ADMIN, + "프로비저닝 안전 조건을 확인할 수 없어 관리자 확인 대기 중입니다", now); + publishCreateFailed(vmId, error); + return; + } + if (isRetryable(e) && task.getAttempts() <= MAX_STEP_ATTEMPTS && taskRepository.markRetrying(taskId, error, now) == 1) { Duration backoff = RETRY_BACKOFF.get(Math.min(task.getAttempts(), MAX_STEP_ATTEMPTS) - 1); @@ -817,6 +863,16 @@ private static int requireVmid(Vm vm) { return vm.getProxmoxVmid(); } + private static CloneImagePin requiredClonePin(Vm vm) { + return vm.cloneImagePin().orElseThrow(() -> new IllegalStateException( + "기존 VM 생성 요청에는 복제 이미지 고정 정보가 없습니다. 관리자 확인이 필요합니다.")); + } + + private static String nicConfig(Map config) { + Object value = config == null ? null : config.get("net0"); + return value == null ? null : value.toString(); + } + private IpAllocation requireAllocation(Vm vm) { if (vm.getIpAllocationId() == null) { throw new IllegalStateException("IP가 아직 할당되지 않았습니다 (vm " + vm.getId() + ")"); @@ -880,6 +936,14 @@ private static final class VmidConflict extends RuntimeException { } } + /** A violated clone provenance or prepared-node precondition must never trigger cleanup. */ + private static final class ProvisioningSafetyException extends RuntimeException { + + ProvisioningSafetyException(String message, Throwable cause) { + super(message, cause); + } + } + /** Control-flow stop: this run must not touch the task any further. */ private static final class PipelineHalted extends RuntimeException { PipelineHalted(String message) { diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationService.java b/src/main/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationService.java new file mode 100644 index 00000000..2bccd76b --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationService.java @@ -0,0 +1,108 @@ +package kr.ac.pusan.pickle.provisioning; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import kr.ac.pusan.pickle.inventory.CatalogStatus; +import kr.ac.pusan.pickle.inventory.CloneImagePin; +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.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageReplicaResolver; +import kr.ac.pusan.pickle.inventory.OsImageRepository; +import kr.ac.pusan.pickle.inventory.OsImageSelection; +import kr.ac.pusan.pickle.vm.VmRepository; +import org.jspecify.annotations.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Serializes approval-time placement and exact clone-source reservation. */ +@Service +public class VmCloneReservationService { + + private final OsImageRepository images; + private final NodeRepository nodes; + private final VmRepository vms; + + public VmCloneReservationService(OsImageRepository images, NodeRepository nodes, + VmRepository vms) { + this.images = images; + this.nodes = nodes; + this.vms = vms; + } + + @Transactional + public Reservation reserve(UUID imagePublicId, @Nullable UUID forcedNodePublicId, + NodePlacementBudget.VmPlacementResources requested) { + OsImage preRead = images.findByPublicId(imagePublicId) + .orElseThrow(() -> new IllegalStateException("승인할 OS 이미지가 존재하지 않습니다.")); + List revision = images.findRevisionForUpdate(preRead.getName(), preRead.getVersion()); + OsImage granted = revision.stream().filter(row -> Objects.equals(row.getId(), preRead.getId())) + .findFirst().orElseThrow(() -> new IllegalStateException("승인할 OS 이미지가 변경되었습니다.")); + OsImage canonical = OsImageSelection.canonicalOf(granted, revision) + .orElseThrow(() -> new IllegalStateException("OS 이미지 revision을 확인할 수 없습니다.")); + if (!OsImageReplicaResolver.compatible(canonical, granted)) { + throw new IllegalStateException("승인할 OS 이미지 revision이 일치하지 않습니다."); + } + + List replicas = revision.stream() + .filter(row -> row.getStatus() == CatalogStatus.ACTIVE) + .filter(row -> OsImageReplicaResolver.compatible(canonical, row)) + .toList(); + List nodeIds = replicas.stream().map(OsImage::getNodeId).distinct().sorted().toList(); + if (nodeIds.isEmpty()) { + throw noCapacity(canonical); + } + List lockedNodes = nodes.findAllByIdForUpdate(nodeIds); + Instant observedAt = Instant.now(); + List candidates = lockedNodes.stream() + .filter(node -> node.getStatus() == NodeStatus.ACTIVE) + .filter(node -> forcedNodePublicId == null || forcedNodePublicId.equals(node.getPublicId())) + .map(node -> candidate(node, replicas, requested, observedAt)) + .filter(Objects::nonNull) + .sorted(Comparator.comparing((Candidate value) -> value.node().isGpuNode()) + .thenComparing(Comparator.comparingDouble(Candidate::score).reversed()) + .thenComparing(value -> value.node().getId())) + .toList(); + Candidate selected = candidates.stream().findFirst().orElseThrow(() -> noCapacity(canonical)); + CloneImagePin pin = CloneImagePin.from(selected.replica()); + return new Reservation(canonical, selected.replica(), selected.node(), pin); + } + + private Candidate candidate(Node node, List replicas, + NodePlacementBudget.VmPlacementResources requested, Instant observedAt) { + OsImage replica = replicas.stream().filter(row -> Objects.equals(row.getNodeId(), node.getId())) + .min(Comparator.comparing(OsImage::getId)).orElse(null); + if (replica == null) { + return null; + } + VmRepository.AllocatedCapacity sum = vms.sumActiveByNodeId(node.getId(), + kr.ac.pusan.pickle.vm.VmStatus.DELETED); + NodePlacementBudget.VmPlacementResources allocated = new NodePlacementBudget.VmPlacementResources( + sum.getVcpu(), sum.getMemoryMb(), sum.getDiskGb()); + NodePlacementBudget budget = NodePlacementBudget.from(node, observedAt); + return budget.fits(allocated, requested) + ? new Candidate(node, replica, budget.score(allocated)) : null; + } + + private static NoCapacityException noCapacity(OsImage image) { + return new NoCapacityException("요청 사양과 OS 이미지 " + image.getName() + + "을(를) 수용할 수 있는 노드가 없습니다."); + } + + private record Candidate(Node node, OsImage replica, double score) { + } + + public record Reservation(OsImage canonical, OsImage replica, Node node, CloneImagePin pin) { + } + + public static final class NoCapacityException extends RuntimeException { + + private NoCapacityException(String message) { + super(message); + } + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/VmNicConfiguration.java b/src/main/java/kr/ac/pusan/pickle/provisioning/VmNicConfiguration.java new file mode 100644 index 00000000..fc683fc4 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/VmNicConfiguration.java @@ -0,0 +1,76 @@ +package kr.ac.pusan.pickle.provisioning; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import kr.ac.pusan.pickle.inventory.NodeVmNicRequirements; + +/** Retargets an existing PVE NIC without losing its MAC, MTU or firewall configuration. */ +public final class VmNicConfiguration { + + private static final Set MODELS = Set.of("virtio", "e1000", "e1000e", "rtl8139", + "vmxnet3", "ne2k_pci", "pcnet", "i82551", "i82557b", "i82559er"); + + private VmNicConfiguration() { + } + + public static String onBridge(String existing, String bridge, Optional requirements) { + if (bridge == null || !bridge.matches("[A-Za-z][A-Za-z0-9_.-]{0,14}")) { + throw invalid(); + } + Map fields = parse(existing); + requirements.ifPresent(value -> requirePrepared(fields, value)); + fields.put("bridge", bridge); + return fields.entrySet().stream() + .map(entry -> entry.getValue() == null ? entry.getKey() : entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining(",")); + } + + public static void requirePrepared(String existing, NodeVmNicRequirements requirements) { + requirePrepared(parse(existing), requirements); + } + + private static void requirePrepared(Map fields, NodeVmNicRequirements requirements) { + if (!Integer.toString(requirements.mtu()).equals(fields.get("mtu")) + || !"1".equals(fields.get("firewall"))) { + throw new IllegalStateException("VM 네트워크의 MTU 또는 방화벽 준비 값이 노드 기준과 다릅니다."); + } + } + + private static Map parse(String existing) { + Map result = new LinkedHashMap<>(); + if (existing == null || existing.isBlank()) { + result.put("virtio", null); + return result; + } + if (existing.chars().anyMatch(Character::isWhitespace) + || existing.chars().anyMatch(Character::isISOControl)) { + throw invalid(); + } + boolean modelSeen = false; + for (String field : existing.split(",", -1)) { + String[] pair = field.split("=", -1); + if (pair.length > 2 || pair[0].isEmpty() || result.containsKey(pair[0]) + || (pair.length == 2 && pair[1].isEmpty()) + || !pair[0].matches("[A-Za-z][A-Za-z0-9_]*")) { + throw invalid(); + } + boolean model = MODELS.contains(pair[0]); + if ((pair.length == 1 && !model) || (model && modelSeen)) { + throw invalid(); + } + modelSeen |= model; + result.put(pair[0], pair.length == 1 ? null : pair[1]); + } + if (!modelSeen) { + throw invalid(); + } + return result; + } + + private static IllegalStateException invalid() { + return new IllegalStateException("VM 네트워크 설정을 안전하게 보존할 수 없습니다."); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/proxmox/ProxmoxClient.java b/src/main/java/kr/ac/pusan/pickle/proxmox/ProxmoxClient.java index 30452422..12bc2f4c 100644 --- a/src/main/java/kr/ac/pusan/pickle/proxmox/ProxmoxClient.java +++ b/src/main/java/kr/ac/pusan/pickle/proxmox/ProxmoxClient.java @@ -212,6 +212,69 @@ public Map currentVmStatus(String apiHost, String node, int vmid CONFIG_RESPONSE); } + /** Reads desired PVE firewall configuration; this is not a kernel enforcement acknowledgment. */ + public Map vmFirewallOptions(String apiHost, String node, int vmid) { + return call(HttpMethod.GET, + uri(apiHost, "nodes", node, "qemu", String.valueOf(vmid), "firewall", "options"), + null, CONFIG_RESPONSE); + } + + public List> vmFirewallRules(String apiHost, String node, int vmid) { + List> rules = call(HttpMethod.GET, + uri(apiHost, "nodes", node, "qemu", String.valueOf(vmid), "firewall", "rules"), + null, new TypeReference>>>() {}); + return rules == null ? List.of() : rules; + } + + public void setVmFirewallOptions(String apiHost, String node, int vmid, + Map options, String digest) { + call(HttpMethod.PUT, + uri(apiHost, "nodes", node, "qemu", String.valueOf(vmid), "firewall", "options"), + withDigest(options, digest), VOID_RESPONSE); + } + + /** PVE inserts a created rule at the front; callers must not assume append order. */ + public void createVmFirewallRule(String apiHost, String node, int vmid, Map rule) { + call(HttpMethod.POST, + uri(apiHost, "nodes", node, "qemu", String.valueOf(vmid), "firewall", "rules"), + rule, VOID_RESPONSE); + } + + public void updateVmFirewallRule(String apiHost, String node, int vmid, int position, + Map rule, String digest) { + requireRulePosition(position); + call(HttpMethod.PUT, uri(apiHost, "nodes", node, "qemu", String.valueOf(vmid), + "firewall", "rules", String.valueOf(position)), withDigest(rule, digest), VOID_RESPONSE); + } + + public void deleteVmFirewallRule(String apiHost, String node, int vmid, int position, String digest) { + requireRulePosition(position); + requireFirewallDigest(digest); + URI target = baseBuilder(apiHost).pathSegment("nodes", node, "qemu", String.valueOf(vmid), + "firewall", "rules", String.valueOf(position)).queryParam("digest", digest) + .build().encode().toUri(); + call(HttpMethod.DELETE, target, null, VOID_RESPONSE); + } + + private static Map withDigest(Map fields, String digest) { + requireFirewallDigest(digest); + Map guarded = new java.util.LinkedHashMap<>(fields); + guarded.put("digest", digest); + return guarded; + } + + private static void requireFirewallDigest(String digest) { + if (isBlank(digest)) { + throw new IllegalArgumentException("방화벽 설정을 다시 조회한 뒤 변경해 주세요."); + } + } + + private static void requireRulePosition(int position) { + if (position < 0) { + throw new IllegalArgumentException("방화벽 규칙 위치가 올바르지 않습니다."); + } + } + /** PVE renders boolean flags as 0/1 int or string across versions. */ private static boolean truthyFlag(Object value) { if (value instanceof Boolean b) { diff --git a/src/main/java/kr/ac/pusan/pickle/recovery/ManagedVmRecoveryGuard.java b/src/main/java/kr/ac/pusan/pickle/recovery/ManagedVmRecoveryGuard.java new file mode 100644 index 00000000..a6d3441d --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/recovery/ManagedVmRecoveryGuard.java @@ -0,0 +1,274 @@ +package kr.ac.pusan.pickle.recovery; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import kr.ac.pusan.pickle.networkpolicy.CidrBlock; +import kr.ac.pusan.pickle.vm.VmStatus; + +/** + * Pure checks for a manually restored VM retaining its logical identity. + * Observations must be collected by trusted operational code, never accepted as + * caller-provided assertions. Passing these checks neither restores a guest nor + * acquires a lock, fences a source, proves a backup, or updates the database. + */ +public final class ManagedVmRecoveryGuard { + + private ManagedVmRecoveryGuard() { + } + + public enum RuntimeState { STOPPED, RUNNING, UNKNOWN } + public enum GuestKind { QEMU, LXC, TEMPLATE, UNKNOWN } + + public enum Rejection { + STALE_OBSERVATION, INCOMPLETE_INVENTORY, WRONG_CLUSTER, TARGET_NODE_UNAVAILABLE, + IDENTITY_CHANGED, LOCATION_CHANGED, VM_NOT_PARKED, OWNER_CONFLICT, ACTIVE_WORK, + SOURCE_MISSING_OR_DUPLICATED, SOURCE_NOT_FENCED, TARGET_ALREADY_CLAIMED, + TARGET_EXISTS, RESTORE_NOT_CONFIRMED, TARGET_MISSING_OR_DUPLICATED, + TARGET_NOT_ISOLATED, TARGET_IDENTITY_MISMATCH, DUPLICATE_NETWORK_IDENTITY + } + + public static void requireRestoreReady(Manifest manifest, RecoveryObservation observation, + Instant now, Duration maximumObservationAge) { + requireCommon(manifest, observation, now, maximumObservationAge); + require(targets(manifest, observation).isEmpty(), Rejection.TARGET_EXISTS); + } + + /** Returns only the expected-old compare-and-set inputs for a later guarded transaction. */ + public static LocationChange requireAdoptionReady(Manifest manifest, RecoveryObservation observation, + RestoreReceipt receipt, Instant now, Duration maximumObservationAge) { + requireCommon(manifest, observation, now, maximumObservationAge); + require(receipt != null && receipt.succeeded() + && manifest.operationId().equals(receipt.operationId()) + && manifest.target().equals(receipt.target()) + && manifest.backupReference().equals(receipt.backupReference()) + && manifest.backupManifestSha256().equals(receipt.backupManifestSha256()) + && isRestoreTaskFor(receipt.upid(), manifest.target()) + && !receipt.completedAt().isBefore(observation.owner().acquiredAt()) + && !receipt.completedAt().isAfter(observation.observedAt()), Rejection.RESTORE_NOT_CONFIRMED); + + List candidates = targets(manifest, observation); + require(candidates.size() == 1, Rejection.TARGET_MISSING_OR_DUPLICATED); + ObservedGuest target = candidates.getFirst(); + require(manifest.target().equals(target.location()) + && manifest.operationId().equals(target.recoveryOperationId()) + && manifest.identity().equals(target.identity()), Rejection.TARGET_IDENTITY_MISMATCH); + require(isolated(target), Rejection.TARGET_NOT_ISOLATED); + + return new LocationChange(manifest.operationId(), manifest.logicalVmId(), + manifest.expectedSource(), manifest.expectedVmUpdatedAt(), manifest.target(), + observation.owner().fencingToken(), observation.owner().expiresAt()); + } + + private static void requireCommon(Manifest manifest, RecoveryObservation observation, + Instant now, Duration maximumAge) { + Objects.requireNonNull(manifest); + Objects.requireNonNull(observation); + Objects.requireNonNull(now); + if (maximumAge == null || maximumAge.isNegative() || maximumAge.isZero()) { + throw new IllegalArgumentException("관측 유효 시간을 지정해 주세요."); + } + require(!observation.observedAt().isAfter(now) + && !observation.observedAt().isBefore(now.minus(maximumAge)), Rejection.STALE_OBSERVATION); + require(observation.inventoryComplete(), Rejection.INCOMPLETE_INVENTORY); + require(manifest.clusterCaSha256().equals(observation.clusterCaSha256()), Rejection.WRONG_CLUSTER); + require(observation.reachableNodeIds().contains(manifest.target().nodeId()), Rejection.TARGET_NODE_UNAVAILABLE); + + DatabaseVm vm = observation.vm(); + require(manifest.logicalVmId().equals(vm.logicalVmId()) + && manifest.identity().equals(vm.identity()), Rejection.IDENTITY_CHANGED); + require(manifest.expectedSource().equals(vm.location()) + && manifest.expectedVmUpdatedAt().equals(vm.updatedAt()), Rejection.LOCATION_CHANGED); + require(vm.status() == VmStatus.STOPPED || vm.status() == VmStatus.NEEDS_ADMIN + || vm.status() == VmStatus.ERROR, Rejection.VM_NOT_PARKED); + + ExclusiveOwner owner = observation.owner(); + require(manifest.operationId().equals(owner.operationId()) + && manifest.logicalVmId().equals(owner.logicalVmId()) + && owner.activeOwnerCount() == 1 && owner.fencingToken() == manifest.ownerFencingToken() + && !owner.acquiredAt().isAfter(observation.observedAt()) + && owner.expiresAt().isAfter(now), Rejection.OWNER_CONFLICT); + Activity activity = observation.activity(); + require(activity.inventoryComplete() && activity.provisioningTasks() == 0 + && activity.powerDispatches() == 0 && activity.gpuOperations() == 0 + && !activity.pendingPowerAction() && !activity.pendingDeletion(), Rejection.ACTIVE_WORK); + + List sources = observation.guests().stream() + .filter(guest -> guest.location().vmid() == manifest.expectedSource().vmid()).toList(); + require(sources.size() == 1, Rejection.SOURCE_MISSING_OR_DUPLICATED); + ObservedGuest source = sources.getFirst(); + require(manifest.expectedSource().equals(source.location()) + && manifest.identity().equals(source.identity()) && isolated(source), Rejection.SOURCE_NOT_FENCED); + require(!observation.claimedDatabaseVmids().contains(manifest.target().vmid()), Rejection.TARGET_ALREADY_CLAIMED); + + // Unrelated stopped guests can still start later: sharing either identity is a conflict. + boolean anotherOwner = observation.guests().stream() + .filter(guest -> guest.location().vmid() != manifest.expectedSource().vmid() + && guest.location().vmid() != manifest.target().vmid()) + .anyMatch(guest -> guest.identity().address().equals(manifest.identity().address()) + || guest.identity().macAddress().equals(manifest.identity().macAddress())); + require(!anotherOwner, Rejection.DUPLICATE_NETWORK_IDENTITY); + } + + private static List targets(Manifest manifest, RecoveryObservation observation) { + return observation.guests().stream() + .filter(guest -> guest.location().vmid() == manifest.target().vmid()).toList(); + } + + private static boolean isolated(ObservedGuest guest) { + return guest.kind() == GuestKind.QEMU && guest.runtimeState() == RuntimeState.STOPPED && !guest.onboot() + && !guest.managedByHa() && guest.connectedInterfaces().isEmpty() + && !guest.pciPassthrough() && !guest.pendingPveTask(); + } + + private static boolean isRestoreTaskFor(String upid, Location target) { + if (upid == null || upid.length() > 1024) { + return false; + } + String[] fields = upid.split(":", -1); + return fields.length == 9 && fields[0].equals("UPID") + && fields[1].equals(target.nodeName()) && fields[2].matches("[0-9a-fA-F]+") + && fields[3].matches("[0-9a-fA-F]+") && fields[4].matches("[0-9a-fA-F]+") + && fields[5].equals("qmrestore") && fields[6].equals(Integer.toString(target.vmid())) + && !fields[7].isBlank() && fields[8].isEmpty(); + } + + private static void require(boolean condition, Rejection reason) { + if (!condition) { + throw new RecoveryRejected(reason); + } + } + + public record Location(long nodeId, String nodeName, int vmid) { + public Location { + if (nodeId <= 0 || nodeName == null || nodeName.isBlank() || vmid <= 0) { + throw new IllegalArgumentException("VM 위치가 올바르지 않습니다."); + } + } + } + + /** No private key material belongs in a recovery manifest. */ + public record GuestIdentity(String hostname, String address, String macAddress, String hostKeysSha256) { + public GuestIdentity { + if (hostname == null || hostname.isBlank() || macAddress == null + || !macAddress.matches("[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}")) { + throw new IllegalArgumentException("복구할 VM의 네트워크 신원이 올바르지 않습니다."); + } + address = CidrBlock.host(address).toString().split("/", 2)[0]; + macAddress = macAddress.toLowerCase(java.util.Locale.ROOT); + hostKeysSha256 = requireSha256(hostKeysSha256); + } + } + + public record Manifest(UUID operationId, UUID logicalVmId, long ownerFencingToken, String clusterCaSha256, + Location expectedSource, Instant expectedVmUpdatedAt, Location target, + String backupReference, String backupManifestSha256, GuestIdentity identity) { + public Manifest { + Objects.requireNonNull(operationId); + Objects.requireNonNull(logicalVmId); + Objects.requireNonNull(expectedSource); + Objects.requireNonNull(expectedVmUpdatedAt); + Objects.requireNonNull(target); + Objects.requireNonNull(identity); + clusterCaSha256 = requireSha256(clusterCaSha256); + backupManifestSha256 = requireSha256(backupManifestSha256); + if (ownerFencingToken <= 0 || expectedSource.vmid() == target.vmid() + || backupReference == null || backupReference.isBlank()) { + throw new IllegalArgumentException("원본과 다른 복구 VMID 및 확인된 백업을 지정해 주세요."); + } + } + } + + public record DatabaseVm(UUID logicalVmId, Location location, Instant updatedAt, + VmStatus status, GuestIdentity identity) { + public DatabaseVm { + Objects.requireNonNull(logicalVmId); + Objects.requireNonNull(location); + Objects.requireNonNull(updatedAt); + Objects.requireNonNull(status); + Objects.requireNonNull(identity); + } + } + + public record ExclusiveOwner(UUID operationId, UUID logicalVmId, long fencingToken, + Instant acquiredAt, Instant expiresAt, int activeOwnerCount) { + public ExclusiveOwner { + Objects.requireNonNull(acquiredAt); + Objects.requireNonNull(expiresAt); + } + } + + /** Counts include queued, retrying and parked operations, not only running workers. */ + public record Activity(boolean inventoryComplete, int provisioningTasks, int powerDispatches, + int gpuOperations, boolean pendingPowerAction, boolean pendingDeletion) { + public Activity { + if (provisioningTasks < 0 || powerDispatches < 0 || gpuOperations < 0) { + throw new IllegalArgumentException("진행 중인 작업 수가 올바르지 않습니다."); + } + } + } + + public record ObservedGuest(Location location, GuestIdentity identity, GuestKind kind, RuntimeState runtimeState, + boolean onboot, boolean managedByHa, Set connectedInterfaces, + boolean pciPassthrough, boolean pendingPveTask, UUID recoveryOperationId) { + public ObservedGuest { + Objects.requireNonNull(location); + Objects.requireNonNull(identity); + Objects.requireNonNull(kind); + Objects.requireNonNull(runtimeState); + connectedInterfaces = Set.copyOf(connectedInterfaces); + } + } + + public record RecoveryObservation(Instant observedAt, boolean inventoryComplete, String clusterCaSha256, + Set reachableNodeIds, DatabaseVm vm, ExclusiveOwner owner, Activity activity, + List guests, Set claimedDatabaseVmids) { + public RecoveryObservation { + Objects.requireNonNull(observedAt); + clusterCaSha256 = requireSha256(clusterCaSha256); + reachableNodeIds = Set.copyOf(reachableNodeIds); + Objects.requireNonNull(vm); + Objects.requireNonNull(owner); + Objects.requireNonNull(activity); + guests = List.copyOf(guests); + claimedDatabaseVmids = Set.copyOf(claimedDatabaseVmids); + } + } + + /** The operation's successful restore receipt must be independently read back before adoption. */ + public record RestoreReceipt(UUID operationId, Location target, String backupReference, + String backupManifestSha256, String upid, boolean succeeded, Instant completedAt) { + public RestoreReceipt { + Objects.requireNonNull(completedAt); + backupManifestSha256 = requireSha256(backupManifestSha256); + } + } + + /** Every field is a required compare-and-set condition, including the unexpired owner token. */ + public record LocationChange(UUID operationId, UUID logicalVmId, Location expectedSource, + Instant expectedVmUpdatedAt, Location target, long ownerFencingToken, Instant ownerExpiresAt) { + } + + public static final class RecoveryRejected extends IllegalStateException { + private final Rejection reason; + + private RecoveryRejected(Rejection reason) { + super("VM 복구 조건이 충족되지 않았습니다. 상태를 다시 확인해 주세요. (" + reason + ")"); + this.reason = reason; + } + + public Rejection reason() { + return reason; + } + } + + private static String requireSha256(String value) { + if (value == null || !value.matches("[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("확인된 SHA-256 지문을 지정해 주세요."); + } + return value.toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java b/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java index 243b469d..a298c998 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java +++ b/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java @@ -4,9 +4,10 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.OptionalLong; -import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.admin.dto.ApproveRequestRequest; import kr.ac.pusan.pickle.admin.dto.ApproveVmRequestSpec; @@ -19,10 +20,13 @@ import kr.ac.pusan.pickle.inventory.NodeRepository; import kr.ac.pusan.pickle.inventory.NodeStatus; import kr.ac.pusan.pickle.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageCatalogService; import kr.ac.pusan.pickle.inventory.OsImageRepository; import kr.ac.pusan.pickle.inventory.VmFlavor; import kr.ac.pusan.pickle.inventory.VmFlavorRepository; import kr.ac.pusan.pickle.provisioning.ProvisioningService; +import kr.ac.pusan.pickle.provisioning.NodePlacementBudget; +import kr.ac.pusan.pickle.provisioning.VmCloneReservationService; import kr.ac.pusan.pickle.request.Request; import kr.ac.pusan.pickle.request.RequestStatus; import kr.ac.pusan.pickle.request.RequestTypeHandler; @@ -47,6 +51,7 @@ public class VmRequestSupport implements RequestTypeHandler { private final VmRequestDetailRepository detailRepository; private final OsImageRepository imageRepository; + private final OsImageCatalogService imageCatalog; private final VmFlavorRepository flavorRepository; private final NodeRepository nodeRepository; private final VmRepository vmRepository; @@ -54,14 +59,17 @@ public class VmRequestSupport implements RequestTypeHandler { private final VmSettingsService vmSettingsService; private final JobScheduler jobScheduler; private final ProvisioningService provisioningService; + private final VmCloneReservationService cloneReservations; private final SecureRandom random = new SecureRandom(); public VmRequestSupport(VmRequestDetailRepository detailRepository, OsImageRepository imageRepository, + OsImageCatalogService imageCatalog, VmFlavorRepository flavorRepository, NodeRepository nodeRepository, VmRepository vmRepository, VmSlugPolicy slugPolicy, VmSettingsService vmSettingsService, JobScheduler jobScheduler, - ProvisioningService provisioningService) { + ProvisioningService provisioningService, VmCloneReservationService cloneReservations) { this.detailRepository = detailRepository; this.imageRepository = imageRepository; + this.imageCatalog = imageCatalog; this.flavorRepository = flavorRepository; this.nodeRepository = nodeRepository; this.vmRepository = vmRepository; @@ -69,6 +77,7 @@ public VmRequestSupport(VmRequestDetailRepository detailRepository, OsImageRepos this.vmSettingsService = vmSettingsService; this.jobScheduler = jobScheduler; this.provisioningService = provisioningService; + this.cloneReservations = cloneReservations; } @Override @@ -86,18 +95,13 @@ public void validateCreate(CreateRequestRequest form, List // A reference to something that does not exist is a 404 here, as it is // for the workspace and the organisation; only a row that exists but may // no longer be chosen is a validation error. - OsImage image = imageRepository.findByPublicId(spec.imageId()) - .orElseThrow(() -> notFound("해당 OS 이미지가 존재하지 않습니다.")); + OsImage image = selectableImage(spec.imageId(), "vm.imageId", errors, true); // 사양을 직접 적은 신청은 카탈로그 행을 가리키지 않는다. 사유가 언제 필요한지는 // validateSpec 이 정한다 — 직접 적었다는 것만으로는 필요하지 않다. VmFlavor flavor = spec.flavorId() == null ? null : flavorRepository.findByPublicId(spec.flavorId()) .orElseThrow(() -> notFound("해당 사양이 존재하지 않습니다.")); - boolean axesActive = true; - if (image.getStatus() != CatalogStatus.ACTIVE) { - errors.add(new FieldValidationError("vm.imageId", "더 이상 선택할 수 없는 OS 이미지입니다.")); - axesActive = false; - } + boolean axesActive = image != null; if (flavor != null && flavor.getStatus() != CatalogStatus.ACTIVE) { errors.add(new FieldValidationError("vm.flavorId", "더 이상 선택할 수 없는 사양입니다.")); axesActive = false; @@ -113,7 +117,7 @@ public void validateCreate(CreateRequestRequest form, List public void saveDetail(Request request, CreateRequestRequest form) { CreateVmRequestSpec spec = form.vm(); // validateCreate already 404'd on an unknown reference, so these resolve. - long imageId = imageRepository.findByPublicId(spec.imageId()).orElseThrow().getId(); + long imageId = imageCatalog.requireSelectable(spec.imageId()).getId(); // 사양을 직접 적은 신청은 가리키는 프리셋이 없다. Long flavorId = spec.flavorId() == null ? null : flavorRepository.findByPublicId(spec.flavorId()).orElseThrow().getId(); @@ -139,10 +143,8 @@ public void validateApprove(Request request, ApproveRequestRequest form, errors.add(new FieldValidationError("vm", "VM 승인 항목(vm)을 입력해 주세요.")); return; } - OsImage image = imageRepository.findByPublicId(spec.grantedImageId()).orElse(null); - if (image == null || image.getStatus() != CatalogStatus.ACTIVE) { - errors.add(new FieldValidationError("vm.grantedImageId", "사용할 수 없는 OS 이미지입니다.")); - } else if (spec.grantedDiskGb() < image.getMinDiskGb()) { + OsImage image = selectableImage(spec.grantedImageId(), "vm.grantedImageId", errors, false); + if (image != null && spec.grantedDiskGb() < image.getMinDiskGb()) { errors.add(new FieldValidationError("vm.grantedDiskGb", "이 OS 이미지의 최소 디스크 크기는 " + image.getMinDiskGb() + "GiB입니다.")); } @@ -151,8 +153,10 @@ public void validateApprove(Request request, ApproveRequestRequest form, .map(kr.ac.pusan.pickle.inventory.Node::getId).orElse(null); if (nodeId == null) { errors.add(new FieldValidationError("vm.nodeId", "존재하지 않는 노드입니다.")); - } else if (image != null && !imageRepository.existsByNameAndNodeIdAndStatus( - image.getName(), nodeId, CatalogStatus.ACTIVE)) { + } else if (image != null && imageRepository.findByNameAndVersionAndNodeIdAndStatus( + image.getName(), image.getVersion(), nodeId, CatalogStatus.ACTIVE) + .filter(candidate -> kr.ac.pusan.pickle.inventory.OsImageReplicaResolver + .compatible(image, candidate)).isEmpty()) { // Forced node must host the granted image — the provisioning // pipeline clones the image on the placed node, so a node without it // guarantees a mid-pipeline clone failure. @@ -180,27 +184,36 @@ public void validateApprove(Request request, ApproveRequestRequest form, @Override public Materialized materialize(Request request, ApproveRequestRequest form, AuthenticatedUser actor) { ApproveVmRequestSpec spec = form.vm(); - OsImage image = imageRepository.findByPublicId(spec.grantedImageId()).orElseThrow(); + VmCloneReservationService.Reservation reservation; + try { + reservation = cloneReservations.reserve(spec.grantedImageId(), spec.nodeId(), + new NodePlacementBudget.VmPlacementResources(spec.grantedVcpu(), + spec.grantedMemoryMb(), spec.grantedDiskGb())); + } catch (VmCloneReservationService.NoCapacityException unavailable) { + throw ApiException.validationFailed(List.of(new FieldValidationError( + spec.nodeId() == null ? "vm" : "vm.nodeId", unavailable.getMessage()))); + } + OsImage image = reservation.canonical(); VmRequestDetail detail = detail(request); - Long forcedNodeId = spec.nodeId() == null ? null - : nodeRepository.findByPublicId(spec.nodeId()) - .map(kr.ac.pusan.pickle.inventory.Node::getId).orElseThrow(); + Long forcedNodeId = spec.nodeId() == null ? null : reservation.node().getId(); detail.grant(spec.grantedVcpu(), spec.grantedMemoryMb(), spec.grantedDiskGb(), image.getId(), forcedNodeId); - // Auto placement: the image's node (single-node cluster; the - // scoring placement step arrives with the provisioning pipeline). - Long nodeId = forcedNodeId != null ? forcedNodeId : image.getNodeId(); + // Approval already reserved the exact node and clone source while holding + // their database locks, so the worker only verifies this persisted choice. + Long nodeId = reservation.node().getId(); String grantedSlug = Texts.blankToNull(spec.grantedSlug()); String hostname = grantedSlug != null ? grantedSlug : generateHostname(VmSlugPolicy.sanitizeSeed(request.getDisplayName(), request.getWorkspaceId()), request.getWorkspaceId()); // The guest admin account comes from the granted image (each // distribution ships its own), never from a platform-wide constant. - Vm vm = vmRepository.save(new Vm(nodeId, request.getWorkspaceId(), request.getOrgId(), + Vm vm = new Vm(nodeId, request.getWorkspaceId(), request.getOrgId(), request.getId(), hostname, hostname, image.getId(), image.getSshUsername(), spec.grantedVcpu(), spec.grantedMemoryMb(), spec.grantedDiskGb(), - form.grantedStartDate(), form.grantedEndDate())); + form.grantedStartDate(), form.grantedEndDate()); + vm.pinClone(reservation.pin()); + vm = vmRepository.save(vm); // Requester-chosen display name (request form) — seeded as the // vm_settings row; audited via the request.approve entry. The seeder // sanitizes, so it returns what was actually stored (null when the name @@ -244,6 +257,23 @@ private static ApiException notFound(String detail) { "리소스를 찾을 수 없습니다", detail); } + private OsImage selectableImage(UUID publicId, String field, List errors, + boolean unknownIsNotFound) { + try { + return imageCatalog.requireSelectable(publicId); + } catch (OsImageCatalogService.UnknownImageException missing) { + if (unknownIsNotFound) { + throw notFound("해당 OS 이미지가 존재하지 않습니다."); + } + errors.add(new FieldValidationError(field, "사용할 수 없는 OS 이미지입니다.")); + return null; + } catch (OsImageCatalogService.UnavailableImageException unavailable) { + errors.add(new FieldValidationError(field, unknownIsNotFound + ? "더 이상 선택할 수 없는 OS 이미지입니다." : "사용할 수 없는 OS 이미지입니다.")); + return null; + } + } + /** Whether the slug policy would accept this name, without collecting the reasons. */ private boolean policyAccepts(String candidate) { List rejected = new ArrayList<>(); diff --git a/src/main/java/kr/ac/pusan/pickle/vm/Vm.java b/src/main/java/kr/ac/pusan/pickle/vm/Vm.java index 9787a5cd..5511ebc3 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/Vm.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/Vm.java @@ -10,7 +10,9 @@ import jakarta.persistence.Table; import java.time.Instant; import java.time.LocalDate; +import java.util.Optional; import java.util.UUID; +import kr.ac.pusan.pickle.inventory.CloneImagePin; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -65,6 +67,19 @@ public class Vm { @Column(name = "image_id", nullable = false) private Long imageId; + /** Immutable coordinates of the exact template used for the original clone. */ + @Column(name = "clone_image_id") + private Long cloneImageId; + + @Column(name = "clone_node_id") + private Long cloneNodeId; + + @Column(name = "clone_template_vmid") + private Integer cloneTemplateVmid; + + @Column(name = "clone_revision_sha256", length = 64) + private String cloneRevisionSha256; + @Column(nullable = false) private int vcpu; @@ -249,6 +264,34 @@ public Long getImageId() { return imageId; } + public Optional cloneImagePin() { + int present = (cloneImageId == null ? 0 : 1) + (cloneNodeId == null ? 0 : 1) + + (cloneTemplateVmid == null ? 0 : 1) + (cloneRevisionSha256 == null ? 0 : 1); + if (present == 0) { + return Optional.empty(); + } + if (present != 4) { + throw new IllegalStateException("VM의 복제 이미지 고정 정보가 불완전합니다."); + } + return Optional.of(new CloneImagePin(cloneImageId, cloneNodeId, cloneTemplateVmid, + cloneRevisionSha256)); + } + + /** Approval may set the pin once; retries may only present the same coordinates. */ + public void pinClone(CloneImagePin pin) { + Optional existing = cloneImagePin(); + if (existing.isPresent()) { + if (!existing.get().equals(pin)) { + throw new IllegalStateException("VM의 복제 이미지 고정 정보를 변경할 수 없습니다."); + } + return; + } + cloneImageId = pin.imageId(); + cloneNodeId = pin.nodeId(); + cloneTemplateVmid = pin.templateVmid(); + cloneRevisionSha256 = pin.revisionSha256(); + } + public int getVcpu() { return vcpu; } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java b/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java index f1782c83..d32160ce 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java @@ -95,7 +95,8 @@ long countActiveByWorkspaceIdIn(@Param("workspaceIds") Collection workspac /** DB-intent capacity already granted on a node, for placement scoring. */ @Query(""" - select coalesce(sum(v.vcpu), 0) as vcpu, coalesce(sum(v.memoryMb), 0) as memoryMb + select coalesce(sum(v.vcpu), 0) as vcpu, coalesce(sum(v.memoryMb), 0) as memoryMb, + coalesce(sum(v.diskGb), 0) as diskGb from Vm v where v.nodeId = :nodeId and v.deletedAt is null and v.status <> :deleted """) @@ -105,6 +106,8 @@ interface AllocatedCapacity { long getVcpu(); long getMemoryMb(); + + long getDiskGb(); } /** VMs the status poller may look at: a Proxmox identity and a pollable status. */ diff --git a/src/main/resources/db/migration/V127__node_scoped_images_and_clone_pins.sql b/src/main/resources/db/migration/V127__node_scoped_images_and_clone_pins.sql new file mode 100644 index 00000000..7f043386 --- /dev/null +++ b/src/main/resources/db/migration/V127__node_scoped_images_and_clone_pins.sql @@ -0,0 +1,35 @@ +-- Preserve every catalog row and public UUID while allowing node-local replicas. +alter table os_images + drop constraint os_images_name_version_key, + add constraint os_images_node_name_version_key unique (node_id, name, version), + add constraint os_images_clone_coordinates_key unique (id, node_id, proxmox_vmid); + +-- Existing VMs remain unpinned. Never infer their creation provenance in a migration. +alter table vms + add column clone_image_id bigint, + add column clone_node_id bigint, + add column clone_template_vmid integer, + add column clone_revision_sha256 varchar(64), + add constraint vms_clone_pin_complete_check check ( + num_nonnulls(clone_image_id, clone_node_id, clone_template_vmid, + clone_revision_sha256) in (0, 4) + ), + add constraint vms_clone_template_vmid_positive_check check (clone_template_vmid > 0), + add constraint vms_clone_revision_sha256_check check ( + clone_revision_sha256 ~ '^[0-9a-f]{64}$' + ), + add constraint vms_clone_image_coordinates_fkey + foreign key (clone_image_id, clone_node_id, clone_template_vmid) + references os_images (id, node_id, proxmox_vmid) match full; + +create index vms_clone_image_id_idx on vms (clone_image_id) + where clone_image_id is not null; + +comment on column vms.clone_image_id is + '최초 생성에 고정한 실제 노드별 이미지 행. NULL은 이력 고정 도입 전 VM이며 추정값으로 채우지 않는다.'; +comment on column vms.clone_node_id is + '최초 복제 노드. 수동 복구 후 현재 node_id와 달라도 생성 이력은 유지한다.'; +comment on column vms.clone_template_vmid is + '최초 복제에 고정한 PVE 템플릿 VMID. 재시도에서 다른 템플릿으로 교체하지 않는다.'; +comment on column vms.clone_revision_sha256 is + '승인 이미지와 실제 복제 이미지의 논리 revision 메타데이터 SHA-256. 디스크 내용 전체의 해시가 아니다.'; diff --git a/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java b/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java index 14f0baa2..78544f39 100644 --- a/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java +++ b/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java @@ -294,6 +294,10 @@ private void stubProxmoxHappyPath() { wm.server().stubFor(WireMock.post(WireMock.urlPathEqualTo("/api2/json/nodes/pve1/qemu/" + SeedFixtures.TEMPLATE_VMID + "/clone")) .willReturn(okFixture("10-clone"))); stubTaskStatus(CLONE_UPID, "10-clone-status"); + wm.server().stubFor(WireMock.get(WireMock.urlPathEqualTo(qemu + "/config")) + .willReturn(WireMock.aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"data\":{\"net0\":\"virtio=02:00:00:00:00:02,bridge=old\"}}"))); wm.server().stubFor(WireMock.put(WireMock.urlPathEqualTo(qemu + "/config")) .willReturn(okFixture("20-config"))); wm.server().stubFor(WireMock.put(WireMock.urlPathEqualTo(qemu + "/resize")) diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java index 5125913a..1700008e 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java @@ -105,6 +105,29 @@ void adminOsImageListShowsRetiredRevisionsThePublicListHides() throws Exception .andExpect(jsonPath(byId(imageId)).doesNotExist()); } + @Test + void publicCatalogKeepsTheFirstUuidWhenOnlyAnotherNodeReplicaIsAvailable() throws Exception { + long firstNode = jdbcTemplate.queryForObject("select min(id) from nodes", Long.class); + String suffix = UUID.randomUUID().toString().substring(0, 8); + long secondNode = jdbcTemplate.queryForObject(""" + insert into nodes (name, api_host, status, cpu_threads, memory_mb, labels, + vm_bridge, storage, ip_pool_id, disk_capacity_gb) + select ?, api_host, status, cpu_threads, memory_mb, labels, + vm_bridge, storage, ip_pool_id, disk_capacity_gb + from nodes where id = ? + returning id + """, Long.class, "ait-replica-" + suffix, firstNode); + String name = "ait-canonical-" + suffix; + long canonical = insertReplica(name, firstNode, 991001, "DISABLED"); + long replica = insertReplica(name, secondNode, 991002, "ACTIVE"); + + mockMvc.perform(get("/api/v1/os-images") + .header("Authorization", "Bearer " + sysAdminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath(byId(canonical) + ".status").value("ACTIVE")) + .andExpect(jsonPath(byId(replica)).doesNotExist()); + } + /** * The admin catalog is the same catalog the wizard shows, so it is read in * the same order — the admin decides what students see and should not have @@ -244,6 +267,16 @@ insert into os_images (name, display_name, os_family, os_version, ssh_username, return name; } + private long insertReplica(String name, long nodeId, int vmid, String status) { + return jdbcTemplate.queryForObject(""" + insert into os_images (name, display_name, os_family, os_version, ssh_username, + proxmox_vmid, node_id, version, min_disk_gb, status) + values (?, '공개 카탈로그 복제 테스트', 'ubuntu', '24.04', 'ubuntu', ?, ?, 1, 10, + cast(? as catalog_status)) + returning id + """, Long.class, name, vmid, nodeId, status); + } + private long auditCount(String action, String table, long targetId) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", diff --git a/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java b/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java index 2f7c2a81..facff31b 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java @@ -41,6 +41,7 @@ import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import tools.jackson.databind.ObjectMapper; @@ -55,6 +56,7 @@ @AutoConfigureMockMvc @ActiveProfiles("test") @Import(EmbeddedPostgresConfig.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) class ApprovalTest { @Autowired @@ -236,6 +238,11 @@ void approveWritesReviewCreatesVmAndEnqueuesJob() throws Exception { assertThat(vm.getSshUsername()).isEqualTo("ubuntu"); assertThat(vm.getProxmoxVmid()).isNull(); assertThat(vm.getOrgId()).isEqualTo(org.getId()); + assertThat(vm.cloneImagePin()).hasValueSatisfying(pin -> { + assertThat(pin.imageId()).isEqualTo(image.getId()); + assertThat(pin.nodeId()).isEqualTo(vm.getNodeId()); + assertThat(pin.templateVmid()).isEqualTo(image.getProxmoxVmid()); + }); // a mock-provisioning job was enqueued through the ProvisioningService // seam; the enqueue runs afterCommit (completed by the time MockMvc diff --git a/src/test/java/kr/ac/pusan/pickle/inventory/CloneImagePinTest.java b/src/test/java/kr/ac/pusan/pickle/inventory/CloneImagePinTest.java new file mode 100644 index 00000000..1dd58a26 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/inventory/CloneImagePinTest.java @@ -0,0 +1,75 @@ +package kr.ac.pusan.pickle.inventory; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.test.util.ReflectionTestUtils; + +class CloneImagePinTest { + + @Test + void retryRefusesRetargetedTemplateRatherThanCloningAnotherImage() { + OsImage granted = image(1, 1, 1001, CatalogStatus.ACTIVE); + OsImage original = image(2, 2, 1011, CatalogStatus.ACTIVE); + CloneImagePin pin = CloneImagePin.from(original); + assertThatCode(() -> pin.requireUnchanged(granted, original)).doesNotThrowAnyException(); + assertThatThrownBy(() -> pin.requireUnchanged(granted, image(2, 2, 1012, CatalogStatus.ACTIVE))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> pin.requireUnchanged(granted, image(2, 3, 1011, CatalogStatus.ACTIVE))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> pin.requireUnchanged(granted, image(3, 2, 1011, CatalogStatus.ACTIVE))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> pin.requireUnchanged(granted, image(2, 2, 1011, CatalogStatus.DISABLED))) + .isInstanceOf(IllegalStateException.class); + } + + @ParameterizedTest + @MethodSource("changedRevisionFields") + void storedRevisionHashRejectsDriftEvenWhenBothCatalogRowsChangeTogether(String field, Object value) { + OsImage granted = image(1, 1, 1001, CatalogStatus.ACTIVE); + OsImage replica = image(2, 2, 1011, CatalogStatus.ACTIVE); + CloneImagePin pin = CloneImagePin.from(replica); + ReflectionTestUtils.setField(granted, field, value); + ReflectionTestUtils.setField(replica, field, value); + assertThatThrownBy(() -> pin.requireUnchanged(granted, replica)) + .isInstanceOf(IllegalStateException.class); + } + + static Stream changedRevisionFields() { + return Stream.of(Arguments.of("name", "another-os"), Arguments.of("version", 3), + Arguments.of("osFamily", "debian"), Arguments.of("osVersion", "26.04"), + Arguments.of("sshUsername", "another-user"), Arguments.of("minDiskGb", 20)); + } + + @Test + void revisionHashIsSharedAcrossReplicasButIgnoresPresentationAndLocalStatus() { + OsImage anchor = image(1, 1, 1001, CatalogStatus.DISABLED); + OsImage replica = image(2, 2, 1011, CatalogStatus.ACTIVE); + ReflectionTestUtils.setField(replica, "displayName", "Another display label"); + ReflectionTestUtils.setField(replica, "notes", "Another operator note"); + assertThat(CloneImagePin.revisionSha256(anchor)).isEqualTo(CloneImagePin.revisionSha256(replica)); + assertThatCode(() -> CloneImagePin.from(replica).requireUnchanged(anchor, replica)) + .doesNotThrowAnyException(); + } + + @Test + void recoveredVmKeepsCreationProvenanceWithoutGainingRecloneAuthority() { + CloneImagePin pin = CloneImagePin.from(image(2, 2, 1011, CatalogStatus.ACTIVE)); + assertThatCode(() -> pin.requireCurrentNode(2)).doesNotThrowAnyException(); + assertThatThrownBy(() -> pin.requireCurrentNode(3)).isInstanceOf(IllegalStateException.class); + assertThat(pin.nodeId()).isEqualTo(2); + } + + private OsImage image(long id, long node, int vmid, CatalogStatus status) { + OsImage image = new OsImage("example-os", "Example OS", "ubuntu", "24.04", "ubuntu", + vmid, node, 2, 10, status, null); + ReflectionTestUtils.setField(image, "id", id); + return image; + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/inventory/DomainCheckConstraintsTest.java b/src/test/java/kr/ac/pusan/pickle/inventory/DomainCheckConstraintsTest.java index 21bdea0a..b1e9e0c2 100644 --- a/src/test/java/kr/ac/pusan/pickle/inventory/DomainCheckConstraintsTest.java +++ b/src/test/java/kr/ac/pusan/pickle/inventory/DomainCheckConstraintsTest.java @@ -162,6 +162,60 @@ void malformedOsImageIdentityIsRejected() { assertThat(insertImage("rocky", "10", "rocky")).isEqualTo(1); } + @Test + void imageRevisionIsUniquePerNodeAndMayHaveAReplicaOnAnotherNode() { + String name = "chk-replica-" + UUID.randomUUID().toString().substring(0, 8); + long otherNode = jdbc.queryForObject(""" + insert into nodes (name, api_host, status, cpu_threads, memory_mb, labels, + vm_bridge, storage, ip_pool_id, disk_capacity_gb) + select ?, api_host, status, cpu_threads, memory_mb, labels, + vm_bridge, storage, ip_pool_id, disk_capacity_gb + from nodes where id = ? + returning id + """, Long.class, name + "-node", nodeId); + insertReplica(name, nodeId, 1009); + assertThat(insertReplica(name, otherNode, 1010)).isEqualTo(1); + assertThatThrownBy(() -> insertReplica(name, nodeId, 1011)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("os_images_node_name_version_key"); + } + + @Test + void clonePinIsAllNullOrAnExactImageCoordinate() { + long requestId = insertRequest(1, 1024, 10); + String hostname = "chk-pin-" + UUID.randomUUID().toString().substring(0, 12); + long vmId = jdbc.queryForObject(""" + insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, + image_id, vcpu, memory_mb, disk_gb) + values (?, ?, ?, ?, ?, ?, ?, 1, 1024, 10) + returning id + """, Long.class, nodeId, workspaceId, orgId, requestId, hostname, hostname, imageId); + Integer templateVmid = jdbc.queryForObject( + "select proxmox_vmid from os_images where id = ?", Integer.class, imageId); + String hash = "a".repeat(64); + + assertThatThrownBy(() -> jdbc.update( + "update vms set clone_image_id = ? where id = ?", imageId, vmId)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("vms_clone_pin_complete_check"); + assertThatThrownBy(() -> jdbc.update(""" + update vms set clone_image_id = ?, clone_node_id = ?, clone_template_vmid = ?, + clone_revision_sha256 = ? where id = ? + """, imageId, nodeId, templateVmid + 1, hash, vmId)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("vms_clone_image_coordinates_fkey"); + assertThatThrownBy(() -> jdbc.update(""" + update vms set clone_image_id = ?, clone_node_id = ?, clone_template_vmid = ?, + clone_revision_sha256 = 'ABC' where id = ? + """, imageId, nodeId, templateVmid, vmId)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("vms_clone_revision_sha256_check"); + assertThat(jdbc.update(""" + update vms set clone_image_id = ?, clone_node_id = ?, clone_template_vmid = ?, + clone_revision_sha256 = ? where id = ? + """, imageId, nodeId, templateVmid, hash, vmId)).isEqualTo(1); + } + private int insertImage(String osFamily, String osVersion, String sshUsername) { return jdbc.update(""" insert into os_images (name, display_name, os_family, os_version, ssh_username, @@ -171,6 +225,15 @@ insert into os_images (name, display_name, os_family, os_version, ssh_username, osFamily, osVersion, sshUsername, nodeId); } + private int insertReplica(String name, long targetNodeId, int vmid) { + return jdbc.update(""" + insert into os_images (name, display_name, os_family, os_version, ssh_username, + proxmox_vmid, node_id, version, min_disk_gb, status) + values (?, '복제 제약 테스트', 'ubuntu', '24.04', 'ubuntu', ?, ?, 1, 10, + 'ACTIVE'::catalog_status) + """, name, vmid, targetNodeId); + } + private long insertRequest(int vcpu, int memoryMb, int diskGb) { return RequestFixtures.insertVmRequest(jdbc, workspaceId, orgId, requesterId, "제약 테스트", imageId, vcpu, memoryMb, diskGb); diff --git a/src/test/java/kr/ac/pusan/pickle/inventory/NodeVmNicRequirementsTest.java b/src/test/java/kr/ac/pusan/pickle/inventory/NodeVmNicRequirementsTest.java new file mode 100644 index 00000000..f6cbb07f --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/inventory/NodeVmNicRequirementsTest.java @@ -0,0 +1,36 @@ +package kr.ac.pusan.pickle.inventory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NodeVmNicRequirementsTest { + + @Test + void absentLabelsKeepLegacyBehaviorButPresentMalformedLabelsNeverFallBack() { + assertThat(NodeVmNicRequirements.read(null)).isEmpty(); + assertThat(NodeVmNicRequirements.read(Map.of("gpu", true))).isEmpty(); + Map labels = new HashMap<>(); + labels.put("vm_nic_requirements", null); + assertThatThrownBy(() -> NodeVmNicRequirements.read(labels)).isInstanceOf(IllegalStateException.class); + } + + @Test + void readsOnlyTheExactVersionedRequirements() { + Map requirement = new HashMap<>(Map.of("schema_version", 1, "mtu", 1370L, "firewall", true)); + assertThat(NodeVmNicRequirements.read(Map.of("vm_nic_requirements", requirement))) + .contains(new NodeVmNicRequirements(1370, true)); + for (Map.Entry invalid : Map.of("schema_version", 2, "mtu", 1370.0, "firewall", false).entrySet()) { + Map changed = new HashMap<>(requirement); + changed.put(invalid.getKey(), invalid.getValue()); + assertThatThrownBy(() -> NodeVmNicRequirements.read(Map.of("vm_nic_requirements", changed))) + .isInstanceOf(IllegalStateException.class); + } + requirement.put("unexpected", true); + assertThatThrownBy(() -> NodeVmNicRequirements.read(Map.of("vm_nic_requirements", requirement))) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/inventory/OsImageReplicaResolverTest.java b/src/test/java/kr/ac/pusan/pickle/inventory/OsImageReplicaResolverTest.java new file mode 100644 index 00000000..5f060175 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/inventory/OsImageReplicaResolverTest.java @@ -0,0 +1,55 @@ +package kr.ac.pusan.pickle.inventory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class OsImageReplicaResolverTest { + + private final OsImageRepository images = mock(OsImageRepository.class); + private final OsImageReplicaResolver resolver = new OsImageReplicaResolver(images); + + @Test + void cloneUsesTheSelectedNodesTemplateWithoutRewritingTheGrantedImage() { + OsImage granted = image(1L, 1001, 2, "ubuntu", 10); + OsImage replica = image(2L, 1011, 2, "ubuntu", 10); + when(images.findByNameAndVersionAndNodeIdAndStatus("example-os", 2, 2L, CatalogStatus.ACTIVE)) + .thenReturn(Optional.of(replica)); + + OsImage resolved = resolver.resolve(granted, 2L); + + assertThat(resolved.getProxmoxVmid()).isEqualTo(1011); + assertThat(granted.getNodeId()).isEqualTo(1L); + assertThat(granted.getProxmoxVmid()).isEqualTo(1001); + verify(images).findByNameAndVersionAndNodeIdAndStatus("example-os", 2, 2L, CatalogStatus.ACTIVE); + } + + @Test + void missingRevisionDoesNotFallBackToAnotherRevision() { + assertThatThrownBy(() -> resolver.resolve(image(1L, 1001, 2, "ubuntu", 10), 2L)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("버전 2"); + } + + @Test + void identicalRevisionWithDifferentGuestSemanticsIsRejected() { + OsImage granted = image(1L, 1001, 2, "ubuntu", 10); + for (OsImage mismatch : new OsImage[] { + image(2L, 1011, 3, "ubuntu", 10), + image(2L, 1011, 2, "debian", 10), + image(2L, 1011, 2, "ubuntu", 20)}) { + when(images.findByNameAndVersionAndNodeIdAndStatus("example-os", 2, 2L, CatalogStatus.ACTIVE)) + .thenReturn(Optional.of(mismatch)); + assertThatThrownBy(() -> resolver.resolve(granted, 2L)).isInstanceOf(IllegalStateException.class); + } + } + + private OsImage image(long node, int vmid, int version, String username, int minDisk) { + return new OsImage("example-os", "Example OS", "ubuntu", "24.04", username, + vmid, node, version, minDisk, CatalogStatus.ACTIVE, null); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/inventory/OsImageSelectionTest.java b/src/test/java/kr/ac/pusan/pickle/inventory/OsImageSelectionTest.java new file mode 100644 index 00000000..81c09b06 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/inventory/OsImageSelectionTest.java @@ -0,0 +1,65 @@ +package kr.ac.pusan.pickle.inventory; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class OsImageSelectionTest { + + @Test + void replicaRegistrationAndRetirementNeverReplaceTheOriginalPublicIdentity() { + OsImage original = image(1, 1, 1, CatalogStatus.ACTIVE); + OsImage replica = image(2, 2, 1, CatalogStatus.ACTIVE); + assertThat(OsImageSelection.selectable(List.of(original, replica), Set.of(1L, 2L))) + .containsExactly(original); + original.setStatus(CatalogStatus.DISABLED); + assertThat(OsImageSelection.selectable(List.of(replica, original), Set.of(2L))).containsExactly(original); + assertThat(OsImageSelection.isSelectable(original, List.of(original, replica), Set.of(2L))).isTrue(); + assertThat(original.getStatus()).isEqualTo(CatalogStatus.DISABLED); + assertThat(OsImageSelection.canonicalOf(replica, List.of(replica, original))) + .contains(original); + } + + @Test + void aNewerRevisionCannotMakeTheRequestedOldRevisionAvailable() { + OsImage old = image(1, 1, 1, CatalogStatus.DISABLED); + OsImage latest = image(2, 2, 2, CatalogStatus.ACTIVE); + assertThat(OsImageSelection.selectable(List.of(latest, old), Set.of(2L))).containsExactly(latest); + assertThat(OsImageSelection.isSelectable(old, List.of(old, latest), Set.of(2L))).isFalse(); + } + + @Test + void inconsistentReplicaMetadataCannotCreateAnotherMeaningForOnePublicRevision() { + OsImage original = image(1, 1, 1, CatalogStatus.DISABLED); + OsImage replica = image(2, 2, 1, CatalogStatus.ACTIVE); + ReflectionTestUtils.setField(replica, "sshUsername", "another-user"); + assertThat(OsImageSelection.selectable(List.of(original, replica), Set.of(2L))).isEmpty(); + assertThat(OsImageSelection.isSelectable(replica, List.of(original, replica), Set.of(2L))).isFalse(); + } + + @Test + void aReplicaOnAnInactiveNodeIsNotASelectableCatalogOffering() { + OsImage original = image(1, 1, 1, CatalogStatus.ACTIVE); + assertThat(OsImageSelection.selectable(List.of(original), Set.of())).isEmpty(); + assertThat(OsImageSelection.isSelectable(original, List.of(original), Set.of())).isFalse(); + } + + @Test + void deduplicationPreservesTheEstablishedCatalogDisplayOrder() { + OsImage old = image(1, 1, 1, CatalogStatus.ACTIVE); + OsImage latest = image(2, 1, 2, CatalogStatus.ACTIVE); + OsImage replica = image(3, 2, 1, CatalogStatus.ACTIVE); + assertThat(OsImageSelection.selectable(List.of(latest, old, replica), Set.of(1L, 2L))) + .containsExactly(latest, old); + } + + private OsImage image(long id, long node, int revision, CatalogStatus status) { + OsImage image = new OsImage("example-os", "Example OS", "ubuntu", "26.04", "ubuntu", + 1000 + (int) id, node, revision, 10, status, null); + ReflectionTestUtils.setField(image, "id", id); + return image; + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/inventory/PlacementCapacityTest.java b/src/test/java/kr/ac/pusan/pickle/inventory/PlacementCapacityTest.java new file mode 100644 index 00000000..dec6e022 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/inventory/PlacementCapacityTest.java @@ -0,0 +1,116 @@ +package kr.ac.pusan.pickle.inventory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class PlacementCapacityTest { + + private static final Instant NOW = Instant.parse("2026-09-16T00:00:00Z"); + + @Test + void preservesLegacyColumnMeaningsWithoutSubtractingMemoryReserveTwice() { + PlacementCapacity capacity = read(document()); + assertThat(capacity.physical()).isEqualTo(new PlacementCapacity.CapacityAmounts(32, 65536, 1000)); + assertThat(capacity.reserved()).isEqualTo(new PlacementCapacity.CapacityAmounts(4, 8192, 200)); + assertThat(capacity.allocatable()).isEqualTo(new PlacementCapacity.CapacityAmounts(28, 57344, 800)); + assertThat(capacity.measuredAt()).isEqualTo(NOW); + } + + @Test + void distinguishesAbsentLegacyMetadataFromPresentMalformedMetadata() { + assertThat(PlacementCapacity.read(null, 32, 57344, 1000L, NOW)).isEmpty(); + assertThat(PlacementCapacity.read(Map.of("gpu", true), 32, 57344, 1000L, NOW)).isEmpty(); + Map labels = new HashMap<>(); + labels.put("placement_capacity", null); + assertThatThrownBy(() -> PlacementCapacity.read(labels, 32, 57344, 1000L, NOW)) + .isInstanceOf(IllegalStateException.class); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidDocuments") + void rejectsMalformedOrAmbiguousMetadata(String name, Consumer> change) { + Map document = document(); + change.accept(document); + assertThatThrownBy(() -> read(document)).isInstanceOf(IllegalStateException.class); + } + + static Stream invalidDocuments() { + return Stream.of( + scenario("unknown schema", d -> d.put("schema_version", 2)), + scenario("text schema", d -> d.put("schema_version", "1")), + scenario("decimal schema", d -> d.put("schema_version", 1.0)), + scenario("unknown document field", d -> d.put("effective", 1)), + scenario("missing resource group", d -> d.remove("reserved")), + scenario("unknown resource dimension", d -> group(d, "physical").put("swap", 0)), + scenario("missing resource dimension", d -> group(d, "physical").remove("disk_gb")), + scenario("floating capacity", d -> group(d, "physical").put("disk_gb", 1000.0)), + scenario("decimal capacity", d -> group(d, "physical").put("disk_gb", new BigDecimal("1000"))), + scenario("boolean capacity", d -> group(d, "physical").put("disk_gb", true)), + scenario("text capacity", d -> group(d, "physical").put("disk_gb", "1000")), + scenario("overflow capacity", d -> group(d, "physical").put("disk_gb", BigInteger.ONE.shiftLeft(64))), + scenario("negative reserve", d -> group(d, "reserved").put("memory_mb", -1)), + scenario("reserve consumes all capacity", d -> group(d, "reserved").put("disk_gb", 1000)), + scenario("reserve exceeds capacity", d -> group(d, "reserved").put("cpu_threads", 33)), + scenario("inconsistent allocation", d -> group(d, "allocatable").put("memory_mb", 65536)), + scenario("future measurement", d -> d.put("measured_at", NOW.plusSeconds(1).toString())), + scenario("timestamp without timezone", d -> d.put("measured_at", "2026-09-16T00:00:00")), + scenario("invalid timestamp", d -> d.put("measured_at", "yesterday"))); + } + + @Test + void rejectsLegacyColumnsThatDoNotMatchTheirEstablishedMeanings() { + Map labels = Map.of("placement_capacity", document()); + assertThatThrownBy(() -> PlacementCapacity.read(labels, 28, 57344, 1000L, NOW)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> PlacementCapacity.read(labels, 32, 65536, 1000L, NOW)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> PlacementCapacity.read(labels, 32, 49152, 1000L, NOW)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> PlacementCapacity.read(labels, 32, 57344, 800L, NOW)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> PlacementCapacity.read(labels, 32, 57344, null, NOW)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void acceptsExactIntegralJsonRepresentationsAndUnrelatedNodeLabels() { + Map document = document(); + group(document, "physical").put("disk_gb", BigInteger.valueOf(1000)); + document.put("schema_version", 1L); + assertThat(PlacementCapacity.read(Map.of("placement_capacity", document, "gpu", true), + 32, 57344, 1000L, NOW)).isPresent(); + } + + private static Arguments scenario(String name, Consumer> change) { + return Arguments.of(name, change); + } + + @SuppressWarnings("unchecked") + private static Map group(Map document, String name) { + return (Map) document.get(name); + } + + private static Map document() { + return new HashMap<>(Map.of("schema_version", 1, "measured_at", NOW.toString(), + "physical", new HashMap<>(Map.of("cpu_threads", 32, "memory_mb", 65536, "disk_gb", 1000L)), + "reserved", new HashMap<>(Map.of("cpu_threads", 4, "memory_mb", 8192, "disk_gb", 200L)), + "allocatable", new HashMap<>(Map.of("cpu_threads", 28, "memory_mb", 57344, "disk_gb", 800L)))); + } + + private static PlacementCapacity read(Map document) { + return PlacementCapacity.read(Map.of("placement_capacity", document), 32, 57344, 1000L, NOW) + .orElseThrow(); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/networkpolicy/CidrBlockTest.java b/src/test/java/kr/ac/pusan/pickle/networkpolicy/CidrBlockTest.java new file mode 100644 index 00000000..d23b8f43 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/networkpolicy/CidrBlockTest.java @@ -0,0 +1,61 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CidrBlockTest { + + @Test + void ipv4MembershipIncludesBothBoundariesWithoutCrossingTheNetwork() { + CidrBlock block = CidrBlock.parse("192.0.2.128/25"); + assertThat(block.contains("192.0.2.128")).isTrue(); + assertThat(block.contains("192.0.2.255")).isTrue(); + assertThat(block.contains("192.0.2.127")).isFalse(); + assertThat(block.contains("192.0.3.0")).isFalse(); + assertThat(block.contains("2001:db8::1")).isFalse(); + } + + @Test + void ipv6CanonicalizationAndMembershipCoverPartialBytes() { + CidrBlock block = CidrBlock.parse("2001:DB8:0:0:8000:0:0:0/65"); + assertThat(block.toString()).isEqualTo("2001:db8:0:0:8000::/65"); + assertThat(block.contains("2001:db8::ffff:ffff:ffff:ffff")).isTrue(); + assertThat(block.contains("2001:db8::7fff:ffff:ffff:ffff")).isFalse(); + assertThat(block.contains("192.0.2.1")).isFalse(); + } + + @Test + void aHostLiteralBecomesOnlyThatHost() { + assertThat(CidrBlock.host("192.0.2.7").toString()).isEqualTo("192.0.2.7/32"); + assertThat(CidrBlock.host("2001:db8::7").toString()).isEqualTo("2001:db8::7/128"); + assertThat(CidrBlock.host("2001:db8::7").contains("2001:db8::8")).isFalse(); + } + + @Test + void unrestrictedPrefixesAreExplicitAndFamilySpecific() { + assertThat(CidrBlock.parse("0.0.0.0/0").contains("255.255.255.255")).isTrue(); + assertThat(CidrBlock.parse("::/0").contains("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")).isTrue(); + assertThat(CidrBlock.parse("::/0").contains("127.0.0.1")).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"localhost/32", "example.com/32", "127.1/32", "2130706433/32", + "192.000.2.1/32", "192.0.2.256/32", "192.0.2.0/-1", "192.0.2.0/33", + "192.0.2.0/024", "192.0.2.1/24", "192.0.2.0/24 ", "192.0.2.0/24\ndeny all;", + "2001:db8::1/64", "2001:db8::/129", "fe80::%eth0/64", "[::1]/128", + "::ffff:192.0.2.1/128", "::ffff:c000:201/128", "::gggg/128", "::/0/0", ""}) + void unsafeOrAmbiguousInputIsRejected(String value) { + assertThatThrownBy(() -> CidrBlock.parse(value)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void equalPrefixesHaveOneCanonicalIdentity() { + assertThat(CidrBlock.parse("2001:DB8:0000:0000:0000:0000:0000:0000/32")) + .isEqualTo(CidrBlock.parse("2001:db8::/32")); + assertThat(CidrBlock.parse("::1/128").toString()).isEqualTo("::1/128"); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/networkpolicy/NetworkPolicyCapabilityTest.java b/src/test/java/kr/ac/pusan/pickle/networkpolicy/NetworkPolicyCapabilityTest.java new file mode 100644 index 00000000..d31cc750 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/networkpolicy/NetworkPolicyCapabilityTest.java @@ -0,0 +1,44 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Set; +import kr.ac.pusan.pickle.config.NetworkPolicyProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class NetworkPolicyCapabilityTest { + + @Test + void anUnconfiguredDeploymentRemainsDisabledEvenIfAnAgentSupportsAcl() { + var properties = new Binder(new MapConfigurationPropertySource()) + .bindOrCreate("pickle.network-policy", Bindable.of(NetworkPolicyProperties.class)); + assertThat(properties.enabled()).isFalse(); + var capability = new NetworkPolicyCapability(properties); + assertThat(capability.sourceAclAvailable(Set.of(NetworkPolicyCapability.SOURCE_ACL))).isFalse(); + assertThatThrownBy(() -> capability.requireVmBackend(true, true)).isInstanceOf(IllegalStateException.class); + } + + @Test + void enablingTheFeatureDoesNotInventAnAgentsSupportOrNodeHealth() { + var capability = new NetworkPolicyCapability(new NetworkPolicyProperties(true, List.of())); + assertThat(capability.sourceAclAvailable(null)).isFalse(); + assertThat(capability.sourceAclAvailable(Set.of())).isFalse(); + assertThat(capability.sourceAclAvailable(Set.of(NetworkPolicyCapability.SOURCE_ACL))).isTrue(); + assertThatThrownBy(() -> capability.requireVmBackend(true, false)).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> capability.requireVmBackend(false, true)).isInstanceOf(IllegalStateException.class); + } + + @Test + void campusPresetHasNoInferredOrBroadDefault() { + var empty = new NetworkPolicyProperties(true, null); + assertThatThrownBy(() -> empty.campusPreset(true)).isInstanceOf(IllegalStateException.class); + var configured = new NetworkPolicyProperties(true, List.of("192.0.2.0/24", "2001:DB8::/32")); + assertThat(configured.campusPreset(true).cidrValues()).containsExactly("192.0.2.0/24", "2001:db8::/32"); + assertThatThrownBy(() -> configured.campusPreset(false)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/networkpolicy/SourcePolicyTest.java b/src/test/java/kr/ac/pusan/pickle/networkpolicy/SourcePolicyTest.java new file mode 100644 index 00000000..ac608ac5 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/networkpolicy/SourcePolicyTest.java @@ -0,0 +1,58 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SourcePolicyTest { + + @Test + void anExplicitEmptyPolicyDeniesEverySource() { + SourcePolicy policy = SourcePolicy.parse(List.of(), true); + assertThat(policy.allows("192.0.2.1")).isFalse(); + assertThat(policy.allows("2001:db8::1")).isFalse(); + } + + @Test + void selectedSourcesDoNotGrantOtherNetworks() { + SourcePolicy policy = SourcePolicy.parse(List.of("192.0.2.0/24", "2001:db8::/32"), true); + assertThat(policy.allows("192.0.2.1")).isTrue(); + assertThat(policy.allows("2001:db8:1::1")).isTrue(); + assertThat(policy.allows("198.51.100.1")).isFalse(); + assertThat(policy.allows("2001:db9::1")).isFalse(); + } + + @Test + void ipv6IsRejectedWhenTheEnforcementPathCannotSupportIt() { + assertThatThrownBy(() -> SourcePolicy.parse(List.of("192.0.2.0/24", "::/0"), false)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("IPv6"); + } + + @Test + void nullDoesNotMeanLegacyOrUnrestricted() { + assertThatThrownBy(() -> SourcePolicy.parse(null, true)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SourcePolicy.parse(Collections.singletonList(null), true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void duplicatesAndOversizedPoliciesAreRejected() { + assertThatThrownBy(() -> SourcePolicy.parse(List.of("2001:DB8::/32", "2001:db8::/32"), true)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("중복"); + assertThatThrownBy(() -> SourcePolicy.parse(Collections.nCopies(SourcePolicy.MAX_CIDRS + 1, + "192.0.2.0/24"), true)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void callersCannotMutateAnAlreadyValidatedPolicy() { + List input = new ArrayList<>(List.of(CidrBlock.parse("192.0.2.0/24"))); + SourcePolicy policy = new SourcePolicy(input); + input.add(CidrBlock.parse("0.0.0.0/0")); + assertThat(policy.cidrValues()).containsExactly("192.0.2.0/24"); + assertThatThrownBy(() -> policy.allowedCidrs().clear()).isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkPolicyCompilerTest.java b/src/test/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkPolicyCompilerTest.java new file mode 100644 index 00000000..74b8d1d7 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkPolicyCompilerTest.java @@ -0,0 +1,46 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Action.DROP; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Direction.IN; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Protocol.ANY; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Protocol.TCP; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class VmNetworkPolicyCompilerTest { + + @Test + void defaultPolicyDoesNotGrantTheRestOfTheGuestsSubnet() { + var plan = VmNetworkPolicyCompiler.compile("192.0.2.20", "198.51.100.10", + "198.51.100.20", List.of(), List.of()); + assertThat(plan.options()).containsEntry("policy_in", "DROP").containsEntry("policy_out", "ACCEPT") + .containsEntry("ipfilter", "1").containsEntry("macfilter", "1"); + assertThat(plan.ipFilterNet0()).containsExactly("192.0.2.20/32"); + assertThat(plan.rules()).hasSize(2).allSatisfy(rule -> { + assertThat(rule).containsEntry("dport", "22").containsEntry("action", "ACCEPT"); + assertThat(rule.get("source")).endsWith("/32"); + }); + } + + @Test + void userDropAllCannotOverrideRequiredGatewayAndTerminalPaths() { + var dropAll = new VmNetworkRule(IN, DROP, ANY, CidrBlock.parse("0.0.0.0/0"), null, null); + var plan = VmNetworkPolicyCompiler.compile("192.0.2.20", "198.51.100.10", + "198.51.100.20", List.of(), List.of(dropAll)); + assertThat(plan.rules()).hasSize(3); + assertThat(plan.rules().get(0)).containsEntry("action", "ACCEPT").containsEntry("dport", "22"); + assertThat(plan.rules().get(1)).containsEntry("action", "ACCEPT").containsEntry("dport", "22"); + assertThat(plan.rules().get(2)).containsEntry("action", "DROP"); + } + + @Test + void publishingAddsOnlyTheActualTrustedSourceAndPort() { + var path = new VmNetworkPolicyCompiler.PublishedPath("203.0.113.7", TCP, 8080); + var plan = VmNetworkPolicyCompiler.compile("192.0.2.20", "198.51.100.10", + "198.51.100.20", List.of(path), List.of()); + assertThat(plan.rules().get(2)).containsEntry("source", "203.0.113.7/32") + .containsEntry("dport", "8080").containsEntry("proto", "tcp"); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkRuleTest.java b/src/test/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkRuleTest.java new file mode 100644 index 00000000..46f367ac --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/networkpolicy/VmNetworkRuleTest.java @@ -0,0 +1,53 @@ +package kr.ac.pusan.pickle.networkpolicy; + +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Action.ACCEPT; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Action.DROP; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Direction.IN; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Direction.OUT; +import static kr.ac.pusan.pickle.networkpolicy.VmNetworkRule.Protocol.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class VmNetworkRuleTest { + + @Test + void inboundRulesMatchThePeerSourceAndDestinationPort() { + var rule = new VmNetworkRule(IN, ACCEPT, TCP, CidrBlock.parse("192.0.2.7/32"), 22, 22); + assertThat(rule.pveFields()).containsEntry("type", "in").containsEntry("source", "192.0.2.7/32") + .containsEntry("dport", "22").containsEntry("iface", "net0") + .containsEntry("enable", "1").doesNotContainKey("dest"); + } + + @Test + void outboundRulesMatchTheRemoteDestinationInsteadOfGuestSource() { + var rule = new VmNetworkRule(OUT, DROP, UDP, CidrBlock.parse("198.51.100.0/24"), 5000, 6000); + assertThat(rule.pveFields()).containsEntry("type", "out").containsEntry("dest", "198.51.100.0/24") + .containsEntry("dport", "5000:6000").containsEntry("action", "DROP") + .doesNotContainKey("source"); + } + + @Test + void icmpRulesCarryTheCorrectAddressFamilyAndNoPorts() { + var rule = new VmNetworkRule(IN, ACCEPT, ICMPV6, CidrBlock.parse("2001:db8::/32"), null, null); + assertThat(rule.pveFields()).containsEntry("proto", "ipv6-icmp").doesNotContainKey("dport"); + assertThatThrownBy(() -> new VmNetworkRule(IN, ACCEPT, ICMP, + CidrBlock.parse("::/0"), null, null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void malformedPortConstraintsNeverReachTheHypervisor() { + var peer = CidrBlock.parse("192.0.2.0/24"); + assertThatThrownBy(() -> new VmNetworkRule(IN, ACCEPT, TCP, peer, 22, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new VmNetworkRule(IN, ACCEPT, UDP, peer, 0, 53)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new VmNetworkRule(IN, ACCEPT, TCP, peer, 100, 99)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new VmNetworkRule(IN, ACCEPT, TCP, peer, 1, 65536)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new VmNetworkRule(IN, ACCEPT, ANY, peer, 22, 22)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementBudgetTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementBudgetTest.java new file mode 100644 index 00000000..5908bde9 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementBudgetTest.java @@ -0,0 +1,70 @@ +package kr.ac.pusan.pickle.provisioning; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Map; +import kr.ac.pusan.pickle.inventory.Node; +import kr.ac.pusan.pickle.provisioning.NodePlacementBudget.VmPlacementResources; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class NodePlacementBudgetTest { + + private static final Instant NOW = Instant.parse("2026-09-18T00:00:00Z"); + + @Test + void coreReservesAreExcludedExactlyOnceAndEachDimensionIsAHardLimit() { + Node node = node(true); + NodePlacementBudget budget = NodePlacementBudget.from(node, NOW); + assertThat(budget.reservationAware()).isTrue(); + assertThat(budget.fits(new VmPlacementResources(27, 57000, 790), new VmPlacementResources(1, 344, 10))).isTrue(); + assertThat(budget.fits(new VmPlacementResources(28, 1, 1), new VmPlacementResources(1, 1, 1))).isFalse(); + assertThat(budget.fits(new VmPlacementResources(1, 57344, 1), new VmPlacementResources(1, 1, 1))).isFalse(); + assertThat(budget.fits(new VmPlacementResources(1, 1, 800), new VmPlacementResources(1, 1, 1))).isFalse(); + assertThat(node.getCpuThreads()).isEqualTo(32); + assertThat(node.getMemoryMb()).isEqualTo(57344); + assertThat(node.getDiskCapacityGb()).isEqualTo(1000); + } + + @Test + void legacyCpuAndAggregateThinDiskPoliciesRemainAdvisory() { + NodePlacementBudget budget = NodePlacementBudget.from(node(false), NOW); + assertThat(budget.reservationAware()).isFalse(); + assertThat(budget.fits(new VmPlacementResources(100, 1000, 5000), new VmPlacementResources(100, 1000, 100))).isTrue(); + assertThat(budget.fits(new VmPlacementResources(0, 57344, 0), new VmPlacementResources(1, 1, 1))).isFalse(); + assertThat(budget.fits(new VmPlacementResources(0, 0, 0), new VmPlacementResources(1, 1, 1001))).isFalse(); + } + + @Test + void overflowOrNegativeAccountingCannotProduceFalseHeadroom() { + NodePlacementBudget budget = NodePlacementBudget.from(node(true), NOW); + assertThat(budget.fits(new VmPlacementResources(Long.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE), + new VmPlacementResources(1, 1, 1))).isFalse(); + assertThat(budget.fits(new VmPlacementResources(-1, 0, 0), new VmPlacementResources(1, 1, 1))).isFalse(); + assertThat(budget.fits(new VmPlacementResources(0, 0, 0), new VmPlacementResources(0, 1, 1))).isFalse(); + } + + @Test + void malformedReservationMetadataDoesNotSilentlyBecomeALegacyNode() { + Node node = node(false); + ReflectionTestUtils.setField(node, "labels", Map.of("placement_capacity", Map.of("schema_version", 99))); + assertThatThrownBy(() -> NodePlacementBudget.from(node, NOW)).isInstanceOf(IllegalStateException.class); + } + + private Node node(boolean reserved) { + Node node = org.springframework.beans.BeanUtils.instantiateClass(Node.class); + ReflectionTestUtils.setField(node, "cpuThreads", 32); + ReflectionTestUtils.setField(node, "memoryMb", 57344); + ReflectionTestUtils.setField(node, "diskCapacityGb", 1000L); + if (reserved) { + ReflectionTestUtils.setField(node, "labels", Map.of("placement_capacity", Map.of( + "schema_version", 1, "measured_at", NOW.toString(), + "physical", Map.of("cpu_threads", 32, "memory_mb", 65536, "disk_gb", 1000), + "reserved", Map.of("cpu_threads", 4, "memory_mb", 8192, "disk_gb", 200), + "allocatable", Map.of("cpu_threads", 28, "memory_mb", 57344, "disk_gb", 800)))); + } + return node; + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementServiceTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementServiceTest.java index 4186e362..348695a0 100644 --- a/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementServiceTest.java +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/NodePlacementServiceTest.java @@ -98,6 +98,35 @@ void forcedNodeWhoseImageWasDeactivatedFailsPlacement() { .hasMessageContaining(imageName); } + @Test + void forcedNodeHostingOnlyAnotherRevisionFailsPlacement() { + long imageNodeId = insertNode(); + image = insertImage(imageNodeId, "ACTIVE"); + long otherNodeId = insertNode(); + jdbc.update(""" + insert into os_images (name, display_name, os_family, os_version, + ssh_username, proxmox_vmid, node_id, version, min_disk_gb, status) + values (?, 'Other revision', 'ubuntu', '24.04', 'ubuntu', 1013, ?, 2, 10, 'ACTIVE') + """, imageName, otherNodeId); + + assertThatThrownBy(() -> placementService.place(vm(otherNodeId), image, otherNodeId)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining(imageName); + } + + @Test + void autoPlacementDoesNotChooseMoreFreeCapacityWithTheWrongRevision() { + long imageNodeId = insertNode(); + image = insertImage(imageNodeId, "ACTIVE"); + long otherNodeId = insertNode(); + jdbc.update(""" + insert into os_images (name, display_name, os_family, os_version, + ssh_username, proxmox_vmid, node_id, version, min_disk_gb, status) + values (?, 'Other revision', 'ubuntu', '24.04', 'ubuntu', 1013, ?, 2, 10, 'ACTIVE') + """, imageName, otherNodeId); + + assertThat(placementService.place(vm(imageNodeId), image, null).getId()).isEqualTo(imageNodeId); + } + private long insertNode() { return jdbc.queryForObject(""" insert into nodes (name, api_host, cpu_threads, memory_mb, vm_bridge, storage) diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/ProvisionPipelineTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/ProvisionPipelineTest.java index 99ff9c67..10a70ca0 100644 --- a/src/test/java/kr/ac/pusan/pickle/provisioning/ProvisionPipelineTest.java +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/ProvisionPipelineTest.java @@ -22,6 +22,8 @@ import java.nio.charset.StandardCharsets; import java.util.UUID; import kr.ac.pusan.pickle.ipam.IpamService; +import kr.ac.pusan.pickle.inventory.CloneImagePin; +import kr.ac.pusan.pickle.inventory.OsImageRepository; import kr.ac.pusan.pickle.mail.MailMessage; import kr.ac.pusan.pickle.mail.MockMailSender; import kr.ac.pusan.pickle.support.AccessGrantFixtures; @@ -83,6 +85,9 @@ class ProvisionPipelineTest { @Autowired private VmRepository vmRepository; + @Autowired + private OsImageRepository imageRepository; + @Autowired private IpamService ipamService; @@ -618,6 +623,109 @@ void assignsVmidFromTheSequenceBand() { assertThat(vm.getProxmoxVmid()).isEqualTo(vmid).isGreaterThanOrEqualTo(100_000); } + @Test + void legacyUnpinnedCloneResumeParksWithoutTouchingExistingResources() { + long vmId = createVm(); + String ip = preallocateIp(vmId); + int vmid = 120; + preassignVmid(vmId, vmid); + jdbc.update(""" + update vms set clone_image_id = null, clone_node_id = null, + clone_template_vmid = null, clone_revision_sha256 = null + where id = ? + """, vmId); + pendingAtClone(vmId); + wm.server().stubFor(get(urlPathEqualTo("/api2/json/cluster/resources")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(clusterResourcesWith(vmid, hostnameOf(vmId))))); + + job.provisionVm(vmId); + + assertSafetyParked(vmId, vmid, ip); + assertThat(vmRepository.findById(vmId).orElseThrow().cloneImagePin()).isEmpty(); + assertNoDestructiveProvisioningCalls(vmid); + } + + @Test + void clonePinDriftParksWithoutTouchingExistingResources() { + long vmId = createVm(); + String ip = preallocateIp(vmId); + int vmid = 121; + preassignVmid(vmId, vmid); + String driftedHash = "b".repeat(64); + jdbc.update("update vms set clone_revision_sha256 = ? where id = ?", driftedHash, vmId); + pendingAtClone(vmId); + wm.server().stubFor(get(urlPathEqualTo("/api2/json/cluster/resources")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(clusterResourcesWith(vmid, hostnameOf(vmId))))); + + job.provisionVm(vmId); + + assertSafetyParked(vmId, vmid, ip); + assertThat(vmRepository.findById(vmId).orElseThrow().cloneImagePin()) + .hasValueSatisfying(pin -> assertThat(pin.revisionSha256()).isEqualTo(driftedHash)); + assertNoDestructiveProvisioningCalls(vmid); + } + + @Test + void preparedTemplateNicMismatchParksWithoutCleanupOrClone() { + long vmId = createVm(); + String ip = preallocateIp(vmId); + int vmid = 122; + preassignVmid(vmId, vmid); + pendingAtClone(vmId); + jdbc.update(""" + update nodes set labels = cast(? as jsonb) where id = ? + """, """ + {"placement_capacity":{"schema_version":1,"measured_at":"2026-09-18T00:00:00Z", + "physical":{"cpu_threads":32,"memory_mb":65536,"disk_gb":1000}, + "reserved":{"cpu_threads":4,"memory_mb":8192,"disk_gb":200}, + "allocatable":{"cpu_threads":28,"memory_mb":57344,"disk_gb":800}}, + "vm_nic_requirements":{"schema_version":1,"mtu":1370,"firewall":true}} + """, nodeId); + try { + wm.server().stubFor(get(urlPathEqualTo("/api2/json/cluster/resources")) + .willReturn(okFixture("03-cluster-resources"))); + wm.server().stubFor(get(urlPathEqualTo(qemuPath(SeedFixtures.TEMPLATE_VMID) + "/config")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"data\":{\"net0\":\"virtio=02:00:00:00:00:03," + + "bridge=vmbr2,mtu=1500,firewall=0\"}}"))); + + job.provisionVm(vmId); + + assertSafetyParked(vmId, vmid, ip); + assertThat(vmRepository.findById(vmId).orElseThrow().cloneImagePin()).isPresent(); + assertNoDestructiveProvisioningCalls(vmid); + } finally { + jdbc.update("update nodes set labels = '{}'::jsonb where id = ?", nodeId); + } + } + + @Test + void nodeEnteringMaintenanceBeforeFirstCloneParksWithoutCleanup() { + long vmId = createVm(); + String ip = preallocateIp(vmId); + int vmid = 123; + preassignVmid(vmId, vmid); + pendingAtClone(vmId); + jdbc.update("update nodes set status = 'MAINTENANCE'::node_status where id = ?", nodeId); + try { + wm.server().stubFor(get(urlPathEqualTo("/api2/json/cluster/resources")) + .willReturn(okFixture("03-cluster-resources"))); + + job.provisionVm(vmId); + + assertSafetyParked(vmId, vmid, ip); + assertThat(vmRepository.findById(vmId).orElseThrow().cloneImagePin()).isPresent(); + assertNoDestructiveProvisioningCalls(vmid); + } finally { + jdbc.update("update nodes set status = 'ACTIVE'::node_status where id = ?", nodeId); + } + } + // ── helpers ─────────────────────────────────────────────────────────────── /** Minimal request→vm graph, mirroring what an approval writes. */ @@ -637,6 +745,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, // that list is where the pipeline's notices look for an audience — a // fixture VM without it is one nobody is responsible for. AccessGrantFixtures.grantVmToUser(jdbc, vmId, adminUserId, "OWNER"); + Vm vm = vmRepository.findById(vmId).orElseThrow(); + vm.pinClone(CloneImagePin.from(imageRepository.findById(imageId).orElseThrow())); + vmRepository.saveAndFlush(vm); return vmId; } @@ -653,6 +764,32 @@ private ProvisioningTask latestTask(long vmId) { return taskRepository.findByVmIdOrderByIdDesc(vmId).getFirst(); } + private void pendingAtClone(long vmId) { + jdbc.update(""" + insert into provisioning_tasks (vm_id, kind, current_step, status, attempts) + values (?, 'PROVISION', ?, 'PENDING', 0) + """, vmId, ProvisioningStep.CLONE.index()); + } + + private void assertSafetyParked(long vmId, int vmid, String ip) { + ProvisioningTask task = latestTask(vmId); + assertThat(task.getStatus()).isEqualTo(ProvisioningTaskStatus.NEEDS_ADMIN); + assertThat(task.getCurrentStep()).isEqualTo(ProvisioningStep.CLONE.index()); + Vm vm = vmRepository.findById(vmId).orElseThrow(); + assertThat(vm.getStatus()).isEqualTo(VmStatus.NEEDS_ADMIN); + assertThat(vm.getProxmoxVmid()).isEqualTo(vmid); + assertThat(jdbc.queryForObject( + "select host(ip) from ip_allocations where vm_id = ? and status = 'ALLOCATED'", + String.class, vmId)).isEqualTo(ip); + } + + private void assertNoDestructiveProvisioningCalls(int vmid) { + wm.server().verify(0, postRequestedFor( + urlPathEqualTo(qemuPath(SeedFixtures.TEMPLATE_VMID) + "/clone"))); + wm.server().verify(0, putRequestedFor(urlPathEqualTo(qemuPath(vmid) + "/config"))); + wm.server().verify(0, deleteRequestedFor(urlPathEqualTo(qemuPath(vmid)))); + } + /** Pre-assigns the vmid, exercising the crash-guard path in assignVmid. */ private void preassignVmid(long vmId, int vmid) { jdbc.update("update vms set proxmox_vmid = ? where id = ?", vmid, vmId); @@ -665,6 +802,10 @@ private void stubClone() { } private void stubConfig(int vmid) { + wm.server().stubFor(get(urlPathEqualTo(qemuPath(vmid) + "/config")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"data\":{\"net0\":\"virtio=02:00:00:00:00:01,bridge=old\"}}"))); wm.server().stubFor(put(urlPathEqualTo(qemuPath(vmid) + "/config")) .willReturn(okFixture("20-config"))); } diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationConcurrencyTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationConcurrencyTest.java new file mode 100644 index 00000000..fb136e9c --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationConcurrencyTest.java @@ -0,0 +1,128 @@ +package kr.ac.pusan.pickle.provisioning; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import kr.ac.pusan.pickle.inventory.CatalogStatus; +import kr.ac.pusan.pickle.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageRepository; +import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig; +import kr.ac.pusan.pickle.support.RequestFixtures; +import kr.ac.pusan.pickle.support.SeedFixtures; +import kr.ac.pusan.pickle.vm.Vm; +import kr.ac.pusan.pickle.vm.VmRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest(properties = "jobrunr.background-job-server.enabled=false") +@ActiveProfiles("test") +@Import(EmbeddedPostgresConfig.class) +class VmCloneReservationConcurrencyTest { + + @Autowired + private VmCloneReservationService reservations; + + @Autowired + private OsImageRepository images; + + @Autowired + private VmRepository vms; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private TransactionTemplate transactions; + + @Test + void concurrentApprovalsCannotBothConsumeOnePreparedNodeBudget() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + long poolId = jdbc.queryForObject("select min(id) from ip_pools", Long.class); + long nodeId = jdbc.queryForObject(""" + insert into nodes (name, api_host, status, cpu_threads, memory_mb, labels, + vm_bridge, storage, ip_pool_id, disk_capacity_gb) + values (?, 'https://127.0.0.1:8006', 'ACTIVE', 3, 2048, + cast(? as jsonb), 'vmbr2', 'local-lvm', ?, 30) + returning id + """, Long.class, "reserve-" + suffix, + """ + {"placement_capacity":{"schema_version":1,"measured_at":"2026-09-18T00:00:00Z", + "physical":{"cpu_threads":3,"memory_mb":3072,"disk_gb":30}, + "reserved":{"cpu_threads":1,"memory_mb":1024,"disk_gb":10}, + "allocatable":{"cpu_threads":2,"memory_mb":2048,"disk_gb":20}}, + "vm_nic_requirements":{"schema_version":1,"mtu":1370,"firewall":true}} + """, poolId); + OsImage image = images.saveAndFlush(new OsImage("reserve-os-" + suffix, "Concurrent OS", + "ubuntu", "24.04", "ubuntu", 990100, nodeId, 1, 10, + CatalogStatus.ACTIVE, null)); + UUID nodePublicId = jdbc.queryForObject( + "select public_id from nodes where id = ?", UUID.class, nodeId); + long orgId = SeedFixtures.seedOrgId(jdbc); + long requesterId = SeedFixtures.orgadminId(jdbc); + long[] workspaces = {workspace("reserve-a-" + suffix), workspace("reserve-b-" + suffix)}; + long[] requests = { + RequestFixtures.insertVmRequest(jdbc, workspaces[0], orgId, requesterId, + "동시 승인 A", image.getId(), 2, 2048, 20), + RequestFixtures.insertVmRequest(jdbc, workspaces[1], orgId, requesterId, + "동시 승인 B", image.getId(), 2, 2048, 20) + }; + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + List> results = List.of( + executor.submit(() -> reserve(start, image, nodePublicId, requests[0], + workspaces[0], orgId, "a-" + suffix)), + executor.submit(() -> reserve(start, image, nodePublicId, requests[1], + workspaces[1], orgId, "b-" + suffix))); + start.countDown(); + assertThat(results.stream().map(this::get).toList()) + .containsExactlyInAnyOrder(true, false); + } + assertThat(vms.sumActiveByNodeId(nodeId, kr.ac.pusan.pickle.vm.VmStatus.DELETED).getVcpu()) + .isEqualTo(2); + } + + private boolean reserve(CountDownLatch start, OsImage image, UUID nodePublicId, + long requestId, long workspaceId, long orgId, String hostname) throws Exception { + start.await(); + try { + transactions.executeWithoutResult(ignored -> { + VmCloneReservationService.Reservation reservation = reservations.reserve( + image.getPublicId(), nodePublicId, + new NodePlacementBudget.VmPlacementResources(2, 2048, 20)); + Vm vm = new Vm(reservation.node().getId(), workspaceId, orgId, requestId, + hostname, hostname, reservation.canonical().getId(), "ubuntu", + 2, 2048, 20, LocalDate.now(), LocalDate.now().plusMonths(1)); + vm.pinClone(reservation.pin()); + vms.saveAndFlush(vm); + }); + return true; + } catch (VmCloneReservationService.NoCapacityException noCapacity) { + return false; + } + } + + private long workspace(String name) { + return jdbc.queryForObject( + "insert into workspaces (kind, name) values ('PROJECT', ?) returning id", + Long.class, name); + } + + private boolean get(Future future) { + try { + return future.get(); + } catch (Exception failure) { + throw new AssertionError(failure); + } + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationServiceTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationServiceTest.java new file mode 100644 index 00000000..ca2191a8 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/VmCloneReservationServiceTest.java @@ -0,0 +1,131 @@ +package kr.ac.pusan.pickle.provisioning; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import kr.ac.pusan.pickle.inventory.CatalogStatus; +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.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageRepository; +import kr.ac.pusan.pickle.vm.VmRepository; +import kr.ac.pusan.pickle.vm.VmStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.test.util.ReflectionTestUtils; + +class VmCloneReservationServiceTest { + + private final OsImageRepository images = mock(OsImageRepository.class); + private final NodeRepository nodes = mock(NodeRepository.class); + private final VmRepository vms = mock(VmRepository.class); + private final VmCloneReservationService service = new VmCloneReservationService(images, nodes, vms); + + private Node first; + private Node second; + private OsImage canonical; + private OsImage replica; + + @BeforeEach + void setUp() { + first = node(1, "first", true); + second = node(2, "second", true); + canonical = image(10, first.getId(), 1000, CatalogStatus.DISABLED); + replica = image(11, second.getId(), 1001, CatalogStatus.ACTIVE); + when(images.findByPublicId(canonical.getPublicId())).thenReturn(java.util.Optional.of(canonical)); + when(images.findRevisionForUpdate(canonical.getName(), canonical.getVersion())) + .thenReturn(List.of(canonical, replica)); + when(nodes.findAllByIdForUpdate(List.of(second.getId()))).thenReturn(List.of(second)); + when(vms.sumActiveByNodeId(second.getId(), VmStatus.DELETED)).thenReturn(allocated(0, 0, 0)); + } + + @Test + void disabledOriginalIdentityPinsAnAvailableCompatibleReplica() { + VmCloneReservationService.Reservation result = service.reserve(canonical.getPublicId(), null, + new NodePlacementBudget.VmPlacementResources(2, 2048, 20)); + + assertThat(result.canonical()).isSameAs(canonical); + assertThat(result.replica()).isSameAs(replica); + assertThat(result.node()).isSameAs(second); + assertThat(result.pin().imageId()).isEqualTo(replica.getId()); + assertThat(result.pin().templateVmid()).isEqualTo(replica.getProxmoxVmid()); + } + + @Test + void forcedNodeCannotEscapeTheLockedEligibleReplicaSet() { + assertThatThrownBy(() -> service.reserve(canonical.getPublicId(), first.getPublicId(), + new NodePlacementBudget.VmPlacementResources(1, 1024, 10))) + .isInstanceOf(VmCloneReservationService.NoCapacityException.class) + .hasMessageContaining("수용할 수 있는 노드"); + } + + @Test + void reservedCapacityIsCheckedAgainstCommittedVmIntent() { + when(vms.sumActiveByNodeId(second.getId(), VmStatus.DELETED)).thenReturn(allocated(27, 57000, 790)); + + assertThatThrownBy(() -> service.reserve(canonical.getPublicId(), null, + new NodePlacementBudget.VmPlacementResources(2, 344, 10))) + .isInstanceOf(VmCloneReservationService.NoCapacityException.class) + .hasMessageContaining("수용할 수 있는 노드"); + } + + @Test + void mutableRevisionMetadataCannotBecomeAnEligibleReplica() { + ReflectionTestUtils.setField(replica, "sshUsername", "another-user"); + + assertThatThrownBy(() -> service.reserve(canonical.getPublicId(), null, + new NodePlacementBudget.VmPlacementResources(1, 1024, 10))) + .isInstanceOf(VmCloneReservationService.NoCapacityException.class); + } + + private static Node node(long id, String name, boolean prepared) { + Node node = BeanUtils.instantiateClass(Node.class); + ReflectionTestUtils.setField(node, "id", id); + ReflectionTestUtils.setField(node, "name", name); + ReflectionTestUtils.setField(node, "status", NodeStatus.ACTIVE); + ReflectionTestUtils.setField(node, "cpuThreads", 32); + ReflectionTestUtils.setField(node, "memoryMb", 57344); + ReflectionTestUtils.setField(node, "diskCapacityGb", 1000L); + if (prepared) { + ReflectionTestUtils.setField(node, "labels", Map.of("placement_capacity", Map.of( + "schema_version", 1, "measured_at", Instant.parse("2026-09-18T00:00:00Z").toString(), + "physical", Map.of("cpu_threads", 32, "memory_mb", 65536, "disk_gb", 1000), + "reserved", Map.of("cpu_threads", 4, "memory_mb", 8192, "disk_gb", 200), + "allocatable", Map.of("cpu_threads", 28, "memory_mb", 57344, "disk_gb", 800)))); + } + return node; + } + + private static OsImage image(long id, long nodeId, int vmid, CatalogStatus status) { + OsImage image = new OsImage("example-os", "Example OS", "ubuntu", "26.04", "ubuntu", + vmid, nodeId, 1, 10, status, null); + ReflectionTestUtils.setField(image, "id", id); + return image; + } + + private static VmRepository.AllocatedCapacity allocated(long cpu, long memory, long disk) { + return new VmRepository.AllocatedCapacity() { + @Override + public long getVcpu() { + return cpu; + } + + @Override + public long getMemoryMb() { + return memory; + } + + @Override + public long getDiskGb() { + return disk; + } + }; + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/VmNicConfigurationTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/VmNicConfigurationTest.java new file mode 100644 index 00000000..d418c2ba --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/VmNicConfigurationTest.java @@ -0,0 +1,49 @@ +package kr.ac.pusan.pickle.provisioning; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import kr.ac.pusan.pickle.inventory.NodeVmNicRequirements; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class VmNicConfigurationTest { + + @Test + void replacingBridgePreservesEveryOtherPropertyAndItsOrder() { + String existing = "virtio=02:00:00:00:00:01,bridge=oldnet,firewall=1,mtu=1370,queues=4,rate=10,tag=9,trunks=10;11,link_down=1"; + assertThat(VmNicConfiguration.onBridge(existing, "newnet", Optional.of(new NodeVmNicRequirements(1370, true)))) + .isEqualTo(existing.replace("bridge=oldnet", "bridge=newnet")); + } + + @Test + void anUnconfiguredLegacyNodeDoesNotEnableOrReplacePolicyOptions() { + assertThat(VmNicConfiguration.onBridge("virtio=02:00:00:00:00:02,firewall=0,mtu=1500", "vmbr2", Optional.empty())) + .isEqualTo("virtio=02:00:00:00:00:02,firewall=0,mtu=1500,bridge=vmbr2"); + assertThat(VmNicConfiguration.onBridge(null, "vmbr2", Optional.empty())).isEqualTo("virtio,bridge=vmbr2"); + } + + @ParameterizedTest + @ValueSource(strings = {"virtio", "virtio,mtu=1370", "virtio,firewall=1", "virtio,firewall=0,mtu=1370", "virtio,firewall=1,mtu=1500", "virtio,firewall=1,mtu=0"}) + void aPreparedNodeRejectsMissingOrDriftedTemplateRequirements(String existing) { + assertThatThrownBy(() -> VmNicConfiguration.onBridge(existing, "newnet", + Optional.of(new NodeVmNicRequirements(1370, true)))) + .isInstanceOf(IllegalStateException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"virtio,mtu=1370,mtu=1500", "virtio,,bridge=vmbr2", "virtio,bridge=", "virtio,bridge=x\n", "bridge=vmbr2", "virtio,e1000", "virtio,firewall", "virtio,bridge=x=y"}) + void rejectsAmbiguousOrUnparseableConfigurations(String existing) { + assertThatThrownBy(() -> VmNicConfiguration.onBridge(existing, "newnet", Optional.empty())) + .isInstanceOf(IllegalStateException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"bad,firewall=0", "has space", "newnet\n", "more-than-fifteen-characters"}) + void bridgeCannotInjectOtherProperties(String bridge) { + assertThatThrownBy(() -> VmNicConfiguration.onBridge("virtio", bridge, Optional.empty())) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/proxmox/ProxmoxClientTest.java b/src/test/java/kr/ac/pusan/pickle/proxmox/ProxmoxClientTest.java index 1de05ebf..c8562755 100644 --- a/src/test/java/kr/ac/pusan/pickle/proxmox/ProxmoxClientTest.java +++ b/src/test/java/kr/ac/pusan/pickle/proxmox/ProxmoxClientTest.java @@ -83,6 +83,54 @@ private static String taskStatusPath(String upid) { return "/api2/json/nodes/" + NODE + "/tasks/" + upid + "/status"; } + @Test + void firewallMutationUsesTheObservedDigestAndNeverTargetsTheHostFirewall() { + String base = "/api2/json/nodes/pve1/qemu/102/firewall"; + wm.server().stubFor(put(urlPathEqualTo(base + "/options")) + .willReturn(aResponse().withHeader("Content-Type", "application/json").withBody("{\"data\":null}"))); + wm.server().stubFor(put(urlPathEqualTo(base + "/rules/3")) + .willReturn(aResponse().withHeader("Content-Type", "application/json").withBody("{\"data\":null}"))); + wm.server().stubFor(delete(urlPathEqualTo(base + "/rules/3")) + .willReturn(aResponse().withHeader("Content-Type", "application/json").withBody("{\"data\":null}"))); + + client.setVmFirewallOptions(wm.apiHost(), NODE, 102, Map.of("enable", "1"), "observed-options"); + client.updateVmFirewallRule(wm.apiHost(), NODE, 102, 3, + Map.of("action", "DROP", "type", "in"), "observed-rules"); + client.deleteVmFirewallRule(wm.apiHost(), NODE, 102, 3, "new-rules"); + + wm.server().verify(putRequestedFor(urlPathEqualTo(base + "/options")) + .withRequestBody(containing("digest=observed-options"))); + wm.server().verify(putRequestedFor(urlPathEqualTo(base + "/rules/3")) + .withRequestBody(containing("digest=observed-rules"))); + wm.server().verify(deleteRequestedFor(urlPathEqualTo(base + "/rules/3")) + .withQueryParam("digest", equalTo("new-rules"))); + } + + @Test + void firewallRulesAreReadAsConfigurationWithoutClaimingEnforcement() { + String base = "/api2/json/nodes/pve1/qemu/102/firewall"; + wm.server().stubFor(get(urlPathEqualTo(base + "/options")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("{\"data\":{\"enable\":1,\"digest\":\"options-digest\"}}"))); + wm.server().stubFor(get(urlPathEqualTo(base + "/rules")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("{\"data\":[{\"pos\":0,\"type\":\"in\",\"action\":\"DROP\",\"digest\":\"rules-digest\"}]}"))); + assertThat(client.vmFirewallOptions(wm.apiHost(), NODE, 102)).containsEntry("digest", "options-digest"); + assertThat(client.vmFirewallRules(wm.apiHost(), NODE, 102).getFirst()) + .containsEntry("pos", 0).containsEntry("digest", "rules-digest"); + } + + @Test + void firewallUpdatesWithoutAReadbackDigestFailBeforeAnHttpWrite() { + assertThatThrownBy(() -> client.setVmFirewallOptions(wm.apiHost(), NODE, 102, + Map.of("enable", "1"), "")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.deleteVmFirewallRule(wm.apiHost(), NODE, 102, 0, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.updateVmFirewallRule(wm.apiHost(), NODE, 102, -1, + Map.of("action", "DROP"), "digest")).isInstanceOf(IllegalArgumentException.class); + assertThat(wm.server().getAllServeEvents()).isEmpty(); + } + @Test void appliedConfigurationPendingConfigurationAndRuntimeStatusStaySeparate() { String base = "/api2/json/nodes/pve1/qemu/102"; diff --git a/src/test/java/kr/ac/pusan/pickle/recovery/ManagedVmRecoveryGuardTest.java b/src/test/java/kr/ac/pusan/pickle/recovery/ManagedVmRecoveryGuardTest.java new file mode 100644 index 00000000..4eb76bbf --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/recovery/ManagedVmRecoveryGuardTest.java @@ -0,0 +1,345 @@ +package kr.ac.pusan.pickle.recovery; + +import static kr.ac.pusan.pickle.recovery.ManagedVmRecoveryGuard.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import kr.ac.pusan.pickle.vm.VmStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class ManagedVmRecoveryGuardTest { + + private static final Instant NOW = Instant.parse("2026-09-16T00:10:00Z"); + private static final Instant BASELINE = NOW.minusSeconds(600); + private static final Duration MAX_AGE = Duration.ofSeconds(30); + private static final UUID VM = UUID.fromString("e9bc7387-f2b4-4a86-b1ab-a34daa801f04"); + private static final UUID OP = UUID.fromString("ef0ed714-408f-4b3b-abd3-7557d913ea34"); + private static final Location SOURCE = new Location(1, "source-node", 100001); + private static final Location TARGET = new Location(2, "target-node", 900001); + private static final String CA = "1".repeat(64); + private static final String BACKUP_HASH = "2".repeat(64); + private static final String BACKUP = "pbs-test:backup/vm/100001/2026-09-16T00:00:00Z"; + private static final GuestIdentity IDENTITY = new GuestIdentity( + "restore-example", "192.0.2.25", "02:00:00:00:00:25", "3".repeat(64)); + + @Test + void preparedRestoreRequiresAnUnusedTargetAndLeavesTheLogicalIdentityIntact() { + Fixture fixture = new Fixture(); + assertThatCode(() -> requireRestoreReady(fixture.manifest, fixture.observation(), NOW, MAX_AGE)) + .doesNotThrowAnyException(); + assertThat(fixture.database.logicalVmId()).isEqualTo(VM); + assertThat(fixture.database.location()).isEqualTo(SOURCE); + } + + @Test + void verifiedRestoreProducesOnlyExpectedOldCoordinatesForTheLaterTransaction() { + Fixture fixture = restored(); + LocationChange result = requireAdoptionReady(fixture.manifest, fixture.observation(), + receipt(true, OP, BACKUP, TARGET, NOW.minusSeconds(2)), NOW, MAX_AGE); + assertThat(result.logicalVmId()).isEqualTo(VM); + assertThat(result.expectedSource()).isEqualTo(SOURCE); + assertThat(result.expectedVmUpdatedAt()).isEqualTo(BASELINE); + assertThat(result.target()).isEqualTo(TARGET); + assertThat(result.ownerFencingToken()).isEqualTo(7); + assertThat(fixture.database.location()).isEqualTo(SOURCE); + } + + @ParameterizedTest + @MethodSource("unsafeSources") + void sourceMustBeStoppedAndFencedAcrossEveryRestartAndNetworkPath(ObservedGuest source) { + Fixture fixture = new Fixture(); + fixture.guests = List.of(source); + rejected(fixture, Rejection.SOURCE_NOT_FENCED); + } + + static Stream unsafeSources() { + return Stream.of( + guest(SOURCE, RuntimeState.RUNNING, false, false, Set.of(), false, false, null), + guest(SOURCE, RuntimeState.UNKNOWN, false, false, Set.of(), false, false, null), + guest(SOURCE, RuntimeState.STOPPED, true, false, Set.of(), false, false, null), + guest(SOURCE, RuntimeState.STOPPED, false, true, Set.of(), false, false, null), + guest(SOURCE, RuntimeState.STOPPED, false, false, Set.of("net0"), false, false, null), + guest(SOURCE, RuntimeState.STOPPED, false, false, Set.of("net1"), false, false, null), + guest(SOURCE, RuntimeState.STOPPED, false, false, Set.of(), true, false, null), + guest(SOURCE, RuntimeState.STOPPED, false, false, Set.of(), false, true, null)); + } + + @Test + void missingOrDuplicateSourceCannotBeAssumedSafe() { + Fixture missing = new Fixture(); + missing.guests = List.of(); + rejected(missing, Rejection.SOURCE_MISSING_OR_DUPLICATED); + Fixture duplicate = new Fixture(); + duplicate.guests = List.of(isolated(SOURCE, null), + isolated(new Location(2, "target-node", SOURCE.vmid()), null)); + rejected(duplicate, Rejection.SOURCE_MISSING_OR_DUPLICATED); + } + + @ParameterizedTest + @MethodSource("nonVmKinds") + void reusedVmidPointingAtAContainerOrTemplateCannotBecomeTheSource(GuestKind kind) { + Fixture fixture = new Fixture(); + fixture.guests = List.of(new ObservedGuest(SOURCE, IDENTITY, kind, RuntimeState.STOPPED, + false, false, Set.of(), false, false, null)); + rejected(fixture, Rejection.SOURCE_NOT_FENCED); + } + + static Stream nonVmKinds() { + return Stream.of(GuestKind.LXC, GuestKind.TEMPLATE, GuestKind.UNKNOWN); + } + + @ParameterizedTest + @MethodSource("conflictingOwners") + void expiredReplacedOrConcurrentOwnersCannotAdvanceRecovery(ExclusiveOwner owner) { + Fixture fixture = new Fixture(); + fixture.owner = owner; + rejected(fixture, Rejection.OWNER_CONFLICT); + } + + static Stream conflictingOwners() { + return Stream.of( + owner(OP, VM, 7, NOW, 1), + owner(OP, VM, 8, NOW.plusSeconds(60), 1), + owner(OP, VM, 7, NOW.plusSeconds(60), 2), + owner(OP, VM, 7, NOW.plusSeconds(60), 0), + owner(UUID.randomUUID(), VM, 7, NOW.plusSeconds(60), 1), + owner(OP, UUID.randomUUID(), 7, NOW.plusSeconds(60), 1), + new ExclusiveOwner(OP, VM, 7, NOW.plusSeconds(1), NOW.plusSeconds(60), 1)); + } + + @ParameterizedTest + @MethodSource("activeWork") + void anyQueuedRetryingOrParkedWorkBlocksRecovery(Activity activity) { + Fixture fixture = new Fixture(); + fixture.activity = activity; + rejected(fixture, Rejection.ACTIVE_WORK); + } + + static Stream activeWork() { + return Stream.of(new Activity(false, 0, 0, 0, false, false), + new Activity(true, 1, 0, 0, false, false), + new Activity(true, 0, 1, 0, false, false), + new Activity(true, 0, 0, 1, false, false), + new Activity(true, 0, 0, 0, true, false), + new Activity(true, 0, 0, 0, false, true)); + } + + @Test + void staleOrFutureObservationsAreNotReusableProof() { + Fixture stale = new Fixture(); + stale.observedAt = NOW.minusSeconds(31); + rejected(stale, Rejection.STALE_OBSERVATION); + Fixture future = new Fixture(); + future.observedAt = NOW.plusSeconds(1); + rejected(future, Rejection.STALE_OBSERVATION); + } + + @Test + void clusterInventoryAndTargetReachabilityMustBeCurrentAndComplete() { + Fixture incomplete = new Fixture(); + incomplete.complete = false; + rejected(incomplete, Rejection.INCOMPLETE_INVENTORY); + Fixture otherCluster = new Fixture(); + otherCluster.ca = "4".repeat(64); + rejected(otherCluster, Rejection.WRONG_CLUSTER); + Fixture targetOffline = new Fixture(); + targetOffline.nodes = Set.of(SOURCE.nodeId()); + rejected(targetOffline, Rejection.TARGET_NODE_UNAVAILABLE); + } + + @Test + void changedDatabaseLocationOrVersionCannotBeOverwritten() { + Fixture moved = new Fixture(); + moved.database = new DatabaseVm(VM, TARGET, BASELINE, VmStatus.STOPPED, IDENTITY); + rejected(moved, Rejection.LOCATION_CHANGED); + Fixture edited = new Fixture(); + edited.database = new DatabaseVm(VM, SOURCE, BASELINE.plusSeconds(1), VmStatus.STOPPED, IDENTITY); + rejected(edited, Rejection.LOCATION_CHANGED); + } + + @Test + void anotherLogicalVmOrChangedAccessIdentityCannotBeAdopted() { + Fixture otherVm = new Fixture(); + otherVm.database = new DatabaseVm(UUID.randomUUID(), SOURCE, BASELINE, VmStatus.STOPPED, IDENTITY); + rejected(otherVm, Rejection.IDENTITY_CHANGED); + Fixture changedHostKeys = new Fixture(); + var changed = new GuestIdentity(IDENTITY.hostname(), IDENTITY.address(), IDENTITY.macAddress(), "4".repeat(64)); + changedHostKeys.database = new DatabaseVm(VM, SOURCE, BASELINE, VmStatus.STOPPED, changed); + rejected(changedHostKeys, Rejection.IDENTITY_CHANGED); + } + + @ParameterizedTest + @MethodSource("unparkedStates") + void runningCreatingOrDeletingDatabaseRowsAreNotRecoveryTargets(VmStatus state) { + Fixture fixture = new Fixture(); + fixture.database = new DatabaseVm(VM, SOURCE, BASELINE, state, IDENTITY); + rejected(fixture, Rejection.VM_NOT_PARKED); + } + + static Stream unparkedStates() { + return Stream.of(VmStatus.RUNNING, VmStatus.CREATING, VmStatus.REBOOTING, + VmStatus.DELETING, VmStatus.DELETED); + } + + @Test + void aVmidClaimedEvenByHistoricalDatabaseStateCannotBeReused() { + Fixture fixture = new Fixture(); + fixture.claimed = Set.of(SOURCE.vmid(), TARGET.vmid()); + rejected(fixture, Rejection.TARGET_ALREADY_CLAIMED); + } + + @Test + void anExistingTargetIsNeverOverwrittenDuringRestorePreparation() { + Fixture fixture = restored(); + rejected(fixture, Rejection.TARGET_EXISTS); + } + + @ParameterizedTest + @MethodSource("invalidReceipts") + void taskSuccessAloneDoesNotProveWhichBackupWasRestored(RestoreReceipt receipt) { + Fixture fixture = restored(); + rejectedAdoption(fixture, receipt, Rejection.RESTORE_NOT_CONFIRMED); + } + + static Stream invalidReceipts() { + return Stream.of(null, + receipt(false, OP, BACKUP, TARGET, NOW.minusSeconds(2)), + receipt(true, UUID.randomUUID(), BACKUP, TARGET, NOW.minusSeconds(2)), + receipt(true, OP, "another-backup", TARGET, NOW.minusSeconds(2)), + receipt(true, OP, BACKUP, SOURCE, NOW.minusSeconds(2)), + receipt(true, OP, BACKUP, TARGET, NOW.plusSeconds(1)), + receipt(true, OP, BACKUP, TARGET, NOW.minusSeconds(1000)), + new RestoreReceipt(OP, TARGET, BACKUP, "9".repeat(64), restoreUpid(TARGET), true, NOW.minusSeconds(2))); + } + + @ParameterizedTest + @MethodSource("unrelatedTasks") + void aSuccessfulUnrelatedTaskIsNotACompletedRestore(String upid) { + rejectedAdoption(restored(), new RestoreReceipt(OP, TARGET, BACKUP, BACKUP_HASH, + upid, true, NOW.minusSeconds(2)), Rejection.RESTORE_NOT_CONFIRMED); + } + + static Stream unrelatedTasks() { + return Stream.of("", "OK", restoreUpid(SOURCE), + restoreUpid(TARGET).replace("qmrestore", "qmstart"), + restoreUpid(TARGET).replace("900001", "900002")); + } + + @Test + void aMissingOrDuplicatedRestoreTargetIsNotAdopted() { + Fixture missing = new Fixture(); + rejectedAdoption(missing, goodReceipt(), Rejection.TARGET_MISSING_OR_DUPLICATED); + Fixture duplicate = restored(); + duplicate.guests = List.of(isolated(SOURCE, null), isolated(TARGET, OP), + isolated(new Location(3, "other-node", TARGET.vmid()), OP)); + rejectedAdoption(duplicate, goodReceipt(), Rejection.TARGET_MISSING_OR_DUPLICATED); + } + + @Test + void aGenericManagedTagOrMatchingNameIsNotAnOperationOwnershipProof() { + Fixture fixture = new Fixture(); + fixture.guests = List.of(isolated(SOURCE, null), isolated(TARGET, null)); + rejectedAdoption(fixture, goodReceipt(), Rejection.TARGET_IDENTITY_MISMATCH); + } + + @Test + void restoredHostKeyIdentityCannotBeSilentlyReplaced() { + Fixture fixture = new Fixture(); + var changed = new GuestIdentity(IDENTITY.hostname(), IDENTITY.address(), IDENTITY.macAddress(), "9".repeat(64)); + var target = new ObservedGuest(TARGET, changed, GuestKind.QEMU, RuntimeState.STOPPED, + false, false, Set.of(), false, false, OP); + fixture.guests = List.of(isolated(SOURCE, null), target); + rejectedAdoption(fixture, goodReceipt(), Rejection.TARGET_IDENTITY_MISMATCH); + } + + @Test + void aRestoredVmCannotBeExposedBeforeTheGuardedLocationSwitch() { + Fixture fixture = new Fixture(); + fixture.guests = List.of(isolated(SOURCE, null), + guest(TARGET, RuntimeState.RUNNING, false, false, Set.of("net0"), false, false, OP)); + rejectedAdoption(fixture, goodReceipt(), Rejection.TARGET_NOT_ISOLATED); + } + + @Test + void anotherGuestWithTheSameAddressOrMacBlocksTheSwitchEvenWhenStopped() { + Fixture fixture = new Fixture(); + var other = new Location(2, "target-node", 800001); + fixture.guests = List.of(isolated(SOURCE, null), isolated(other, null)); + rejected(fixture, Rejection.DUPLICATE_NETWORK_IDENTITY); + } + + @Test + void manifestsCannotRestoreOverTheSourceVmid() { + assertThatThrownBy(() -> new Manifest(OP, VM, 7, CA, SOURCE, BASELINE, SOURCE, + BACKUP, BACKUP_HASH, IDENTITY)).isInstanceOf(IllegalArgumentException.class); + } + + private static RestoreReceipt goodReceipt() { + return receipt(true, OP, BACKUP, TARGET, NOW.minusSeconds(2)); + } + + private static RestoreReceipt receipt(boolean success, UUID operation, String backup, Location target, Instant completed) { + return new RestoreReceipt(operation, target, backup, BACKUP_HASH, restoreUpid(target), success, completed); + } + + private static String restoreUpid(Location target) { + return "UPID:" + target.nodeName() + ":00000001:00000002:6A4E2CB0:qmrestore:" + + target.vmid() + ":operator@pve:"; + } + + private static ExclusiveOwner owner(UUID operation, UUID vm, long token, Instant expires, int count) { + return new ExclusiveOwner(operation, vm, token, NOW.minusSeconds(300), expires, count); + } + + private static ObservedGuest isolated(Location location, UUID operation) { + return guest(location, RuntimeState.STOPPED, false, false, Set.of(), false, false, operation); + } + + private static ObservedGuest guest(Location location, RuntimeState state, boolean onboot, boolean ha, + Set interfaces, boolean pci, boolean task, UUID operation) { + return new ObservedGuest(location, IDENTITY, GuestKind.QEMU, state, onboot, ha, interfaces, pci, task, operation); + } + + private static Fixture restored() { + Fixture fixture = new Fixture(); + fixture.guests = List.of(isolated(SOURCE, null), isolated(TARGET, OP)); + return fixture; + } + + private static void rejected(Fixture fixture, Rejection reason) { + assertThatThrownBy(() -> requireRestoreReady(fixture.manifest, fixture.observation(), NOW, MAX_AGE)) + .isInstanceOfSatisfying(RecoveryRejected.class, error -> assertThat(error.reason()).isEqualTo(reason)); + } + + private static void rejectedAdoption(Fixture fixture, RestoreReceipt receipt, Rejection reason) { + assertThatThrownBy(() -> requireAdoptionReady(fixture.manifest, fixture.observation(), receipt, NOW, MAX_AGE)) + .isInstanceOfSatisfying(RecoveryRejected.class, error -> assertThat(error.reason()).isEqualTo(reason)); + } + + private static final class Fixture { + private Manifest manifest = new Manifest(OP, VM, 7, CA, SOURCE, BASELINE, TARGET, + BACKUP, BACKUP_HASH, IDENTITY); + private Instant observedAt = NOW.minusSeconds(1); + private boolean complete = true; + private String ca = CA; + private Set nodes = Set.of(SOURCE.nodeId(), TARGET.nodeId()); + private DatabaseVm database = new DatabaseVm(VM, SOURCE, BASELINE, VmStatus.STOPPED, IDENTITY); + private ExclusiveOwner owner = owner(OP, VM, 7, NOW.plusSeconds(60), 1); + private Activity activity = new Activity(true, 0, 0, 0, false, false); + private List guests = List.of(isolated(SOURCE, null)); + private Set claimed = Set.of(SOURCE.vmid()); + + private RecoveryObservation observation() { + return new RecoveryObservation(observedAt, complete, ca, nodes, database, owner, activity, guests, claimed); + } + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java b/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java index 641ad2ec..8db132f7 100644 --- a/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java +++ b/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java @@ -211,6 +211,18 @@ void oversizedBodyAnswers413() throws Exception { // ── report sanitization ───────────────────────────────────────────────── + @Test + void additionalAgentCapabilitiesRemainCompatibleWithTheExistingReportReceiver() throws Exception { + RelayFixture relay = newRelay("capabilities"); + sync(relay.id(), relay.sourceIp(), relay.token(), Map.of( + "appliedGeneration", 0, + "agentVersion", "capability-report-test", + "capabilities", List.of("source-acl-v1"))) + .andExpect(status().isOk()); + assertThat(jdbcTemplate.queryForObject("select agent_version from relays where id = ?", + String.class, relay.id())).isEqualTo("capability-report-test"); + } + @Test void reportedStringsAreControlStrippedAndTruncated() throws Exception { RelayFixture relay = newRelay("sanitize");