From 0f6cefcb66817816681f844c205189be29298df4 Mon Sep 17 00:00:00 2001 From: rook Date: Thu, 15 Oct 2026 19:00:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20WorkloadGroup=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EC=9E=90=20=EA=B8=B0=EB=B0=98=20update/delete=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20(AIP-3518)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 namespace 의 WorkloadGroup(aipub.ten1010.io/v1alpha1, namespaced)에 대해 모든 프로젝트 멤버가 create/get/watch/list 가능하도록 project Role 규칙을 추가하고, 수정/삭제(update/patch/delete)는 생성자 본인(AipubUser per-user Role, resourceName 단위) 또는 project manager(*)에게만 부여한다. FileServer(#110)와 동일한 계약이다. - ProjectApiConstants: workloadgroups kind/plural 상수 추가 - V1alpha1WorkloadGroup(+List) DTO, K8sApiProvider/informer 등록 - reconcileProjectRoleRules: MANAGER `*` / DEVELOPER BASIC_VERBS 규칙 추가 - AipubUserRoleReconciler: per-user 소유 워크로드 목록에 workloadgroups 포함 - WorkloadResourceResolver: WorkloadGroup → workloadgroups 매핑 + instanceof 폴백 - WorkloadGroup watch wiring (OnUpdateFilter/RequestBuilder/ControllerFactory) - 단위 테스트 11건 추가 (Role 규칙 6건, 소유자 이전 watch 5건) 소유자 라벨은 user-v2 mutating webhook 이 namespaced CREATE 에 주입하므로 웹훅 rule 변경은 없다. 컨트롤러 ClusterRole 은 와일드카드라 인포머 권한도 그대로다. DTO 는 metadata 만 담아 CRD 스키마에 의존하지 않는다. 검증: ./gradlew build — 전체 56 스위트 통과 Refs: AIP-3518 Co-Authored-By: Claude Fable 5.1 --- .../aipub/AipubUserRoleControllerFactory.java | 13 ++ .../rbac/aipub/AipubUserRoleReconciler.java | 8 + .../watch/OnUpdateFilterFactory.java | 7 + .../watch/RequestBuilderFactory.java | 14 ++ .../domain/k8s/K8sApiProvider.java | 16 ++ .../domain/k8s/ProjectApiConstants.java | 3 + .../domain/k8s/ReconciliationService.java | 17 +- .../domain/k8s/WorkloadResourceResolver.java | 5 + .../domain/k8s/dto/V1alpha1WorkloadGroup.java | 18 ++ .../k8s/dto/V1alpha1WorkloadGroupList.java | 21 +++ .../SharedInformerFactoryProvider.java | 12 ++ .../WorkloadGroupOwnerTransferWatchTest.java | 154 +++++++++++++++++ ...iliationServiceWorkloadGroupRulesTest.java | 157 ++++++++++++++++++ 13 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroup.java create mode 100644 src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroupList.java create mode 100644 src/test/java/io/ten1010/aipub/projectcontroller/controller/watch/WorkloadGroupOwnerTransferWatchTest.java create mode 100644 src/test/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationServiceWorkloadGroupRulesTest.java diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleControllerFactory.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleControllerFactory.java index b1893c14..4f36c5c1 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleControllerFactory.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleControllerFactory.java @@ -24,6 +24,7 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Operation; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServer; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; import io.ten1010.aipub.projectcontroller.informer.owned.OwnedObjectInformerManager; public class AipubUserRoleControllerFactory implements ControllerFactory { @@ -77,6 +78,8 @@ public Controller createController() { V1alpha1ImageBuild.class)::hasSynced) .withReadyFunc(this.sharedInformerFactory.getExistingSharedIndexInformer( V1alpha1FileServer.class)::hasSynced) + .withReadyFunc(this.sharedInformerFactory.getExistingSharedIndexInformer( + V1alpha1WorkloadGroup.class)::hasSynced) .watch(this::createRoleWatch) .watch(this::createProjectWatch) .watch(this::createAipubUserWatch) @@ -89,6 +92,7 @@ public Controller createController() { .watch(this::createSftpServerWatch) .watch(this::createImageBuildWatch) .watch(this::createFileServerWatch) + .watch(this::createWorkloadGroupWatch) .withReconciler(new AipubUserRoleReconciler(this.sharedInformerFactory, this.k8sApiProvider, this.reconciliationService, this.ownedObjectInformerManager)) .build(); @@ -194,4 +198,13 @@ private ControllerWatch createFileServerWatch(WorkQueue createWorkloadGroupWatch( + WorkQueue workQueue) { + DefaultControllerWatch watch = new DefaultControllerWatch<>(workQueue, + V1alpha1WorkloadGroup.class); + watch.setOnUpdateFilter(this.onUpdateFilterFactory.workloadGroupFilter()); + watch.setRequestBuilder(this.requestBuilderFactory.workloadGroupToAipubUserRoles()); + return watch; + } + } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleReconciler.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleReconciler.java index c97f641e..d469832d 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleReconciler.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/rbac/aipub/AipubUserRoleReconciler.java @@ -32,6 +32,7 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Operation; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServer; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.ProjectUtils; import io.ten1010.aipub.projectcontroller.informer.IndexerConstants; @@ -62,6 +63,7 @@ public class AipubUserRoleReconciler extends AbstractReconciler { private final Indexer sftpServerIndexer; private final Indexer imageBuildIndexer; private final Indexer fileServerIndexer; + private final Indexer workloadGroupIndexer; private final BoundObjectResolver boundObjectResolver; private final RbacAuthorizationV1Api rbacAuthorizationV1Api; private final OwnedObjectInformerManager ownedObjectInformerManager; @@ -113,6 +115,9 @@ public AipubUserRoleReconciler( this.fileServerIndexer = sharedInformerFactory .getExistingSharedIndexInformer(V1alpha1FileServer.class) .getIndexer(); + this.workloadGroupIndexer = sharedInformerFactory + .getExistingSharedIndexInformer(V1alpha1WorkloadGroup.class) + .getIndexer(); this.boundObjectResolver = new BoundObjectResolver(sharedInformerFactory); this.ownedObjectInformerManager = ownedObjectInformerManager; this.rbacAuthorizationV1Api = new RbacAuthorizationV1Api(k8sApiProvider.getApiClient()); @@ -174,6 +179,8 @@ protected Result reconcileInternal(Request request) throws ApiException { IndexerConstants.NAMESPACE_TO_OBJECTS_INDEXER_NAME, request.getNamespace()); List fileServers = this.fileServerIndexer.byIndex( IndexerConstants.NAMESPACE_TO_OBJECTS_INDEXER_NAME, request.getNamespace()); + List workloadGroups = this.workloadGroupIndexer.byIndex( + IndexerConstants.NAMESPACE_TO_OBJECTS_INDEXER_NAME, request.getNamespace()); workloads.addAll(v1beta1Workspaces); workloads.addAll(chainJobs); workloads.addAll(jobs); @@ -183,6 +190,7 @@ protected Result reconcileInternal(Request request) throws ApiException { workloads.addAll(sftpServers); workloads.addAll(imageBuilds); workloads.addAll(fileServers); + workloads.addAll(workloadGroups); List reconciledReferences = this.reconciliationService.reconcileOwnerReferences( roleOpt.orElse(null), userOpt.get()); diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/OnUpdateFilterFactory.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/OnUpdateFilterFactory.java index c7275750..1254fd78 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/OnUpdateFilterFactory.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/OnUpdateFilterFactory.java @@ -32,6 +32,7 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ResourceSet; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServer; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; import io.ten1010.aipub.projectcontroller.domain.k8s.util.AipubUserUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.NodeUtils; @@ -353,6 +354,12 @@ public BiPredicate fileServerFilter() { || !K8sObjectUtils.getLabels(oldObj).equals(K8sObjectUtils.getLabels(newObj)); } + public BiPredicate workloadGroupFilter() { + return (oldObj, newObj) -> + !K8sObjectUtils.getOwnerReferences(oldObj).equals(K8sObjectUtils.getOwnerReferences(newObj)) + || !K8sObjectUtils.getLabels(oldObj).equals(K8sObjectUtils.getLabels(newObj)); + } + /** resourceNames 산정 입력(claimRef, phase)이 바뀔 때만 리컨실한다. 나머지는 권한과 무관. */ public BiPredicate persistentVolumeClaimAndPhaseFilter() { return (oldObj, newObj) -> !Objects.equals( diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/RequestBuilderFactory.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/RequestBuilderFactory.java index 2c5e68a9..ed5a3996 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/RequestBuilderFactory.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/watch/RequestBuilderFactory.java @@ -33,6 +33,7 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ResourceSet; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServer; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; import io.ten1010.aipub.projectcontroller.domain.k8s.util.AipubUserUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.PersistentVolumeUtils; @@ -499,4 +500,17 @@ public Function> fileServerToAipubUserRoles() }; } + public Function> workloadGroupToAipubUserRoles() { + return workloadGroup -> { + String projName = K8sObjectUtils.getNamespace(workloadGroup); + Optional usernameOpt = UsernameUtils.getUsername(workloadGroup); + + if (usernameOpt.isPresent()) { + String roleName = this.aipubUserRoleNameResolver.resolveRoleName(usernameOpt.get()); + return List.of(new Request(projName, roleName)); + } + return List.of(); + }; + } + } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/K8sApiProvider.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/K8sApiProvider.java index 6366d107..9651055f 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/K8sApiProvider.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/K8sApiProvider.java @@ -26,6 +26,8 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ResourceSetList; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServer; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServerList; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroupList; import lombok.Getter; @Getter @@ -44,6 +46,7 @@ public class K8sApiProvider { private final GenericKubernetesApi sftpServerApi; private final GenericKubernetesApi imageBuildApi; private final GenericKubernetesApi fileServerApi; + private final GenericKubernetesApi workloadGroupApi; public K8sApiProvider(ApiClient apiClient) { this.apiClient = apiClient; @@ -59,6 +62,7 @@ public K8sApiProvider(ApiClient apiClient) { this.sftpServerApi = createSftpServerApi(apiClient); this.imageBuildApi = createImageBuildApi(apiClient); this.fileServerApi = createFileServerApi(apiClient); + this.workloadGroupApi = createWorkloadGroupApi(apiClient); } private static GenericKubernetesApi createProjectApi( @@ -201,4 +205,16 @@ private static GenericKubernetesApi ); } + private static GenericKubernetesApi createWorkloadGroupApi( + ApiClient apiClient) { + return new GenericKubernetesApi<>( + V1alpha1WorkloadGroup.class, + V1alpha1WorkloadGroupList.class, + ProjectApiConstants.AIPUB_GROUP, + ProjectApiConstants.VERSION_V1ALPHA1, + ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL, + apiClient + ); + } + } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ProjectApiConstants.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ProjectApiConstants.java index 5d4dde0e..99d80f6c 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ProjectApiConstants.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ProjectApiConstants.java @@ -78,6 +78,9 @@ public final class ProjectApiConstants { public static final String IMAGE_BUILD_RESOURCE_KIND = "ImageBuild"; public static final String IMAGE_BUILD_RESOURCE_PLURAL = "imagebuilds"; + public static final String WORKLOAD_GROUP_RESOURCE_KIND = "WorkloadGroup"; + public static final String WORKLOAD_GROUP_RESOURCE_PLURAL = "workloadgroups"; + public static final String CUEUE_RESOURCE_PLURAL = "cueues"; private ProjectApiConstants() { diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationService.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationService.java index 729d47b1..d1675952 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationService.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationService.java @@ -757,6 +757,11 @@ public List reconcileProjectRoleRules( .withResources(ProjectApiConstants.FILE_SERVER_RESOURCE_PLURAL) .withVerbs("*") .build(); + V1PolicyRule workloadGroupApiRule = new V1PolicyRuleBuilder() + .withApiGroups(ProjectApiConstants.AIPUB_GROUP) + .withResources(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL) + .withVerbs("*") + .build(); //todo -- yield List.of( @@ -776,7 +781,8 @@ public List reconcileProjectRoleRules( resourceQuotaApiRule, commitApiRule, imageBuildApiRule, - fileServerApiRule + fileServerApiRule, + workloadGroupApiRule ); } @@ -877,6 +883,12 @@ public List reconcileProjectRoleRules( .withResources(ProjectApiConstants.FILE_SERVER_RESOURCE_PLURAL) .withVerbs(BASIC_VERBS) .build(); + // WorkloadGroup 도 같은 계약 — 수정/삭제는 생성자 본인의 AipubUser Role 에만 붙는다. + V1PolicyRule workloadGroupApiRule = new V1PolicyRuleBuilder() + .withApiGroups(ProjectApiConstants.AIPUB_GROUP) + .withResources(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL) + .withVerbs(BASIC_VERBS) + .build(); //todo -- yield List.of( @@ -896,7 +908,8 @@ public List reconcileProjectRoleRules( resourceQuotaApiRule, commitApiRule, imageBuildApiRule, - fileServerApiRule + fileServerApiRule, + workloadGroupApiRule ); } }; diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/WorkloadResourceResolver.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/WorkloadResourceResolver.java index 4c91aa2a..85bea5c2 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/WorkloadResourceResolver.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/WorkloadResourceResolver.java @@ -5,6 +5,7 @@ import io.kubernetes.client.openapi.models.V1Job; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1FileServer; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ImageBuild; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; import java.util.Optional; import org.jspecify.annotations.Nullable; @@ -25,6 +26,7 @@ public Optional resolveResource(KubernetesObject workload) { case "CronJob" -> "cronjobs"; case "ChainJob" -> "chainjobs"; case "ImageBuild" -> "imagebuilds"; + case "WorkloadGroup" -> "workloadgroups"; default -> null; }; if (resourceName != null) { @@ -43,6 +45,9 @@ public Optional resolveResource(KubernetesObject workload) { if (workload instanceof V1alpha1FileServer) { return Optional.of("fileservers"); } + if (workload instanceof V1alpha1WorkloadGroup) { + return Optional.of("workloadgroups"); + } return Optional.empty(); } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroup.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroup.java new file mode 100644 index 00000000..bdb4473c --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroup.java @@ -0,0 +1,18 @@ +package io.ten1010.aipub.projectcontroller.domain.k8s.dto; + +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import lombok.Data; +import org.jspecify.annotations.Nullable; + +@Data +public class V1alpha1WorkloadGroup implements KubernetesObject { + + @Nullable + private String apiVersion; + @Nullable + private String kind; + @Nullable + private V1ObjectMeta metadata; + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroupList.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroupList.java new file mode 100644 index 00000000..bf244961 --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/dto/V1alpha1WorkloadGroupList.java @@ -0,0 +1,21 @@ +package io.ten1010.aipub.projectcontroller.domain.k8s.dto; + +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.openapi.models.V1ListMeta; +import java.util.List; +import lombok.Data; +import org.jspecify.annotations.Nullable; + +@Data +public class V1alpha1WorkloadGroupList implements KubernetesListObject { + + @Nullable + private String apiVersion; + @Nullable + private String kind; + @Nullable + private V1ListMeta metadata; + @Nullable + private List items; + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/informer/SharedInformerFactoryProvider.java b/src/main/java/io/ten1010/aipub/projectcontroller/informer/SharedInformerFactoryProvider.java index 05a6fba5..dbf39dac 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/informer/SharedInformerFactoryProvider.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/informer/SharedInformerFactoryProvider.java @@ -42,6 +42,7 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ProjectMember; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ResourceSet; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1SftpServer; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.LabelUtils; import io.ten1010.aipub.projectcontroller.domain.k8s.util.NodeGroupUtils; @@ -91,6 +92,7 @@ public SharedInformerFactory createSharedInformerFactory() { registerSftpServerInformer(informerFactory); registerImageBuildInformer(informerFactory); registerFileServerInformer(informerFactory); + registerWorkloadGroupInformer(informerFactory); registerPodInformer(informerFactory); this.registrars.forEach(e -> e.registerInformer(informerFactory)); @@ -399,6 +401,16 @@ private void registerFileServerInformer(SharedInformerFactory informerFactory) { obj -> List.of(K8sObjectUtils.getNamespace(obj)))); } + private void registerWorkloadGroupInformer(SharedInformerFactory informerFactory) { + SharedIndexInformer informer = informerFactory.sharedIndexInformerFor( + this.k8sApiProvider.getWorkloadGroupApi(), + V1alpha1WorkloadGroup.class, + DEFAULT_RESYNC_PERIOD); + informer.addIndexers(Map.of( + IndexerConstants.NAMESPACE_TO_OBJECTS_INDEXER_NAME, + obj -> List.of(K8sObjectUtils.getNamespace(obj)))); + } + private void registerPodInformer(SharedInformerFactory informerFactory) { ApiClient apiClient = this.k8sApiProvider.getApiClient(); SharedIndexInformer informer = informerFactory.sharedIndexInformerFor( diff --git a/src/test/java/io/ten1010/aipub/projectcontroller/controller/watch/WorkloadGroupOwnerTransferWatchTest.java b/src/test/java/io/ten1010/aipub/projectcontroller/controller/watch/WorkloadGroupOwnerTransferWatchTest.java new file mode 100644 index 00000000..0e588013 --- /dev/null +++ b/src/test/java/io/ten1010/aipub/projectcontroller/controller/watch/WorkloadGroupOwnerTransferWatchTest.java @@ -0,0 +1,154 @@ +package io.ten1010.aipub.projectcontroller.controller.watch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; + +import io.kubernetes.client.extended.controller.reconciler.Request; +import io.kubernetes.client.extended.workqueue.WorkQueue; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.ten1010.aipub.projectcontroller.domain.k8s.AipubUserRoleNameResolver; +import io.ten1010.aipub.projectcontroller.domain.k8s.LabelConstants; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; +import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * WorkloadGroup 소유자 이전(transfer) 시 구·신 소유자의 개인 Role 이 모두 즉시 재조정되는지 검증한다. + * 구 소유자 Role 이 큐에 들어가지 않으면 이전된 오브젝트에 대한 update/delete 권한이 + * 다음 백스톱 주기(OwnedObjectRoleResweeper, 10분)까지 남는다. + */ +class WorkloadGroupOwnerTransferWatchTest { + + // 네임스페이스명 = 프로젝트명 (NamespaceNameResolver 가 그대로 매핑). 실 클러스터의 프로젝트 이름을 쓴다. + private static final String NAMESPACE = "ten-test-project-01"; + private static final String OLD_OWNER = "taehyeong-qa-03"; + private static final String NEW_OWNER = "taehyeong-qa-04"; + + private DefaultControllerWatch watch; + private List enqueued; + + private static V1alpha1WorkloadGroup workloadGroup(String name, String owner) { + V1alpha1WorkloadGroup workloadGroup = new V1alpha1WorkloadGroup(); + V1ObjectMeta meta = new V1ObjectMeta(); + meta.setName(name); + meta.setNamespace(NAMESPACE); + if (owner != null) { + meta.setLabels(Map.of(LabelConstants.OBJECT_OWN_USERNAME_KEY, owner)); + } + workloadGroup.setMetadata(meta); + return workloadGroup; + } + + /** + * 기대 Request 를 오브젝트에서 유도한다 — 네임스페이스는 오브젝트 메타데이터에서 꺼내고, + * Role 이름은 프로덕션 리졸버로 만든다. 상수를 다시 비교하면 전파를 검증하지 못한다. + */ + private static Request roleRequest(V1alpha1WorkloadGroup workloadGroup, String owner) { + return new Request(K8sObjectUtils.getNamespace(workloadGroup), + new AipubUserRoleNameResolver().resolveRoleName(owner)); + } + + @BeforeEach + void setUp() { + this.enqueued = new ArrayList<>(); + WorkQueue queue = new WorkQueue<>() { + + @Override + public void add(Request item) { + WorkloadGroupOwnerTransferWatchTest.this.enqueued.add(item); + } + + @Override + public Request get() { + throw new UnsupportedOperationException(); + } + + @Override + public void done(Request item) { + throw new UnsupportedOperationException(); + } + + @Override + public int length() { + return WorkloadGroupOwnerTransferWatchTest.this.enqueued.size(); + } + + @Override + public void shutDown() { + } + + @Override + public boolean isShuttingDown() { + return false; + } + + }; + RequestBuilderFactory requestBuilderFactory = + new RequestBuilderFactory(mock(SharedInformerFactory.class, RETURNS_DEEP_STUBS)); + this.watch = new DefaultControllerWatch<>(queue, V1alpha1WorkloadGroup.class); + this.watch.setOnUpdateFilter(new OnUpdateFilterFactory().workloadGroupFilter()); + this.watch.setRequestBuilder(requestBuilderFactory.workloadGroupToAipubUserRoles()); + } + + @Test + @DisplayName("소유자 라벨이 바뀌면 구 소유자와 신 소유자 Role 이 모두 큐에 들어간다") + void ownerLabelChanged_enqueuesBothOwnerRoles() { + V1alpha1WorkloadGroup before = workloadGroup("wg1", OLD_OWNER); + V1alpha1WorkloadGroup after = workloadGroup("wg1", NEW_OWNER); + + this.watch.getResourceEventHandler().onUpdate(before, after); + + assertThat(this.enqueued).containsExactlyInAnyOrder( + roleRequest(before, OLD_OWNER), roleRequest(after, NEW_OWNER)); + } + + @Test + @DisplayName("요청 네임스페이스는 오브젝트의 네임스페이스에서 나온다") + void requestNamespace_comesFromObject() { + V1alpha1WorkloadGroup other = workloadGroup("wg1", OLD_OWNER); + Objects.requireNonNull(other.getMetadata()).setNamespace("yshee"); + + this.watch.getResourceEventHandler().onAdd(other); + + assertThat(this.enqueued).containsExactly(roleRequest(other, OLD_OWNER)); + assertThat(this.enqueued.get(0).getNamespace()).isEqualTo("yshee"); + } + + @Test + @DisplayName("status 변경 등 라벨·ownerReference 가 그대로인 update 는 큐에 넣지 않는다") + void unrelatedUpdate_enqueuesNothing() { + this.watch.getResourceEventHandler() + .onUpdate(workloadGroup("wg1", OLD_OWNER), workloadGroup("wg1", OLD_OWNER)); + + assertThat(this.enqueued).isEmpty(); + } + + @Test + @DisplayName("생성·삭제는 해당 소유자 Role 을 큐에 넣는다") + void addAndDelete_enqueueOwnerRole() { + V1alpha1WorkloadGroup workloadGroup = workloadGroup("wg1", OLD_OWNER); + + this.watch.getResourceEventHandler().onAdd(workloadGroup); + this.watch.getResourceEventHandler().onDelete(workloadGroup, false); + + assertThat(this.enqueued).containsExactly( + roleRequest(workloadGroup, OLD_OWNER), roleRequest(workloadGroup, OLD_OWNER)); + } + + @Test + @DisplayName("소유자 라벨이 없는 오브젝트는 큐에 넣을 대상이 없다") + void unlabeledObject_enqueuesNothing() { + this.watch.getResourceEventHandler().onAdd(workloadGroup("wg1", null)); + + assertThat(this.enqueued).isEmpty(); + } + +} diff --git a/src/test/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationServiceWorkloadGroupRulesTest.java b/src/test/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationServiceWorkloadGroupRulesTest.java new file mode 100644 index 00000000..5bb2358f --- /dev/null +++ b/src/test/java/io/ten1010/aipub/projectcontroller/domain/k8s/ReconciliationServiceWorkloadGroupRulesTest.java @@ -0,0 +1,157 @@ +package io.ten1010.aipub.projectcontroller.domain.k8s; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.informer.cache.Cache; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1PolicyRule; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1WorkloadGroup; +import io.ten1010.aipub.projectcontroller.domain.k8s.util.WorkloadExclusionResolver; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * WorkloadGroup(namespaced, aipub.ten1010.io/v1alpha1) 의 소유자 기반 권한 경로 검증. + * 프로젝트 Role 은 조회·생성만 주고, 수정/삭제는 생성자 본인의 AipubUser Role 에 + * resourceName 단위로만 붙는다 — FileServer 와 동일한 계약이다. + */ +class ReconciliationServiceWorkloadGroupRulesTest { + + private ReconciliationService reconciliationService; + private V1alpha1AipubUser user; + private V1alpha1Project project; + + private static V1alpha1WorkloadGroup workloadGroup(String name, String ownerUsername) { + V1alpha1WorkloadGroup workloadGroup = new V1alpha1WorkloadGroup(); + workloadGroup.setApiVersion(ProjectApiConstants.AIPUB_API_VERSION); + workloadGroup.setKind(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_KIND); + V1ObjectMeta meta = new V1ObjectMeta(); + meta.setName(name); + meta.setNamespace("proj1"); + meta.setLabels(Map.of(LabelConstants.OBJECT_OWN_USERNAME_KEY, ownerUsername)); + workloadGroup.setMetadata(meta); + return workloadGroup; + } + + private static V1PolicyRule findRule(List rules, String resource) { + return rules.stream() + .filter(rule -> rule.getResources() != null && rule.getResources().contains(resource)) + .findAny() + .orElseThrow(() -> new AssertionError("no rule for " + resource)); + } + + @BeforeEach + void setUp() { + this.reconciliationService = new ReconciliationService( + mock(SubjectResolver.class), + mock(DockerConfigJsonResolver.class), + List.of(), + new WorkloadExclusionResolver(List.of()), + new NamespaceAllowlistResolver(new Cache<>())); + + this.user = new V1alpha1AipubUser(); + V1ObjectMeta userMeta = new V1ObjectMeta(); + userMeta.setName("alice"); + this.user.setMetadata(userMeta); + + this.project = new V1alpha1Project(); + V1ObjectMeta projectMeta = new V1ObjectMeta(); + projectMeta.setName("proj1"); + this.project.setMetadata(projectMeta); + } + + @Test + @DisplayName("developer Role 은 workloadgroups 에 조회·생성만 부여한다") + void developerRole_grantsBasicVerbsOnWorkloadGroups() { + List rules = this.reconciliationService.reconcileProjectRoleRules( + this.project, ProjectRoleEnum.PROJECT_DEVELOPER); + + V1PolicyRule rule = findRule(rules, ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL); + assertThat(rule.getApiGroups()).containsExactly(ProjectApiConstants.AIPUB_GROUP); + assertThat(rule.getVerbs()).containsExactlyInAnyOrder("create", "get", "watch", "list"); + assertThat(rule.getVerbs()).doesNotContain("update", "patch", "delete"); + assertThat(rule.getResourceNames()).isNullOrEmpty(); + } + + @Test + @DisplayName("manager Role 은 workloadgroups 에 전권을 부여한다 (fileservers 와 동일)") + void managerRole_grantsAllVerbsOnWorkloadGroups() { + List rules = this.reconciliationService.reconcileProjectRoleRules( + this.project, ProjectRoleEnum.PROJECT_MANAGER); + + V1PolicyRule workloadGroupRule = findRule(rules, + ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL); + V1PolicyRule fileServerRule = findRule(rules, + ProjectApiConstants.FILE_SERVER_RESOURCE_PLURAL); + assertThat(workloadGroupRule.getApiGroups()).containsExactly(ProjectApiConstants.AIPUB_GROUP); + assertThat(workloadGroupRule.getVerbs()).isEqualTo(fileServerRule.getVerbs()); + assertThat(workloadGroupRule.getVerbs()).containsExactly("*"); + } + + @Test + @DisplayName("본인이 만든 WorkloadGroup 에는 update/patch/delete 가 resourceName 단위로 붙는다") + void ownedWorkloadGroup_getsUpdateDeleteRuleScopedToName() { + List workloads = List.of(workloadGroup("alice-wg", "alice")); + + List rules = this.reconciliationService.reconcileAipubUserRoleRules( + this.user, this.project, workloads); + + assertThat(rules).hasSize(1); + V1PolicyRule rule = rules.get(0); + assertThat(rule.getApiGroups()).containsExactly(ProjectApiConstants.AIPUB_GROUP); + assertThat(rule.getResources()) + .containsExactly(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL); + assertThat(rule.getResourceNames()).containsExactly("alice-wg"); + assertThat(rule.getVerbs()).containsExactly("update", "patch", "delete"); + } + + @Test + @DisplayName("다른 사용자가 만든 WorkloadGroup 에는 아무 권한도 붙지 않는다") + void otherUsersWorkloadGroup_getsNoRule() { + List workloads = List.of(workloadGroup("bob-wg", "bob")); + + List rules = this.reconciliationService.reconcileAipubUserRoleRules( + this.user, this.project, workloads); + + assertThat(rules).isEmpty(); + } + + @Test + @DisplayName("소유자 라벨이 없는 WorkloadGroup 에는 아무 권한도 붙지 않는다") + void unlabeledWorkloadGroup_getsNoRule() { + V1alpha1WorkloadGroup unlabeled = new V1alpha1WorkloadGroup(); + unlabeled.setKind(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_KIND); + V1ObjectMeta meta = new V1ObjectMeta(); + meta.setName("orphan-wg"); + meta.setNamespace("proj1"); + unlabeled.setMetadata(meta); + + List rules = this.reconciliationService.reconcileAipubUserRoleRules( + this.user, this.project, List.of(unlabeled)); + + assertThat(rules).isEmpty(); + } + + @Test + @DisplayName("kind 가 비어도 instanceof 폴백으로 workloadgroups 로 해석된다") + void resourceResolver_fallsBackToTypeWhenKindMissing() { + WorkloadResourceResolver resolver = new WorkloadResourceResolver(); + V1alpha1WorkloadGroup withKind = workloadGroup("alice-wg", "alice"); + V1alpha1WorkloadGroup withoutKind = workloadGroup("alice-wg", "alice"); + withoutKind.setKind(null); + + assertThat(resolver.resolveResource(withKind)) + .contains(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL); + assertThat(resolver.resolveResource(withoutKind)) + .contains(ProjectApiConstants.WORKLOAD_GROUP_RESOURCE_PLURAL); + assertThat(resolver.resolveGroup(withKind)).isEqualTo(ProjectApiConstants.AIPUB_GROUP); + } + +}