diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..8d1f2f7c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,83 @@ +name: Build and Push Image + +on: + workflow_dispatch: + inputs: + github_tag: + description: 'GitHub tag / Image tag' + required: true + type: string + +jobs: + build-and-push: + name: Build and Push + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + steps: + - name: Checkout source code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Create GitHub tag + run: | + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com" + git tag -f ${{ inputs.github_tag }} + git push origin ${{ inputs.github_tag }} --force + + - name: Set up JDK + uses: actions/setup-java@v5.2.0 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Build container image + run: | + chmod +x mvnw + echo "==================== spring-boot:build-image ====================" + ./mvnw spring-boot:build-image \ + -Dspring-boot.build-image.imageName=ten1010io/project-controller:${{ inputs.github_tag }} \ + -DskipTests + + - name: Login to Harbor Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Publish container image + run: | + HARBOR_IMAGE="${{ secrets.HARBOR_REGISTRY }}/project-controller/project-controller:${{ inputs.github_tag }}" + + echo "==================== docker tag ====================" + docker tag ten1010io/project-controller:${{ inputs.github_tag }} ${HARBOR_IMAGE} + + echo "==================== docker push ====================" + docker push ${HARBOR_IMAGE} + + echo "Pushed: ${HARBOR_IMAGE}" + + summary: + name: Summary + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: Summary + run: | + echo "### Build and Push Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ inputs.github_tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Harbor Image | \`${{ secrets.HARBOR_REGISTRY }}/project-controller/project-controller:${{ inputs.github_tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Triggered by | @${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Image pushed to Harbor**" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 549e00a2..b21a5d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,11 @@ build/ ### VS Code ### .vscode/ +/.sdkmanrc +/CLAUDE.md +/k8s-dev +/.claude/worktrees/ +/.claude/hooks/codex-review.sh +/.claude/skills/review/SKILL.md +/.claude/settings.json +harbor/.env diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 00000000..5018e2a0 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,120 @@ +# Production Deploy Guide + +## 사전 준비 (맥북) + +```bash +# 이미지 빌드 & Harbor push +./image_push.sh 1.5.0 +``` + +## 프로덕션 서버에서 실행 + +### 1. 기존 caBundle 값 확인 + +```bash +CA_BUNDLE=$(sudo kubectl get mutatingwebhookconfiguration project-controller.project.aipub.ten1010.io \ + -o jsonpath='{.webhooks[0].clientConfig.caBundle}') +echo $CA_BUNDLE +``` + +### 2. 컨트롤러 이미지 업데이트 + +```bash +sudo kubectl -n project-controller set image deployment/project-controller \ + project-controller=registry.ten1010.io:8443/project-controller/project-controller:1.5.0 + +# 롤아웃 확인 +sudo kubectl -n project-controller rollout status deployment/project-controller --timeout=120s +``` + +### 3. v2 웹훅 apply + +```bash +# caBundle 치환 후 apply +sed "s||${CA_BUNDLE}|g" \ + ./mutating-webhook-user-v2.yaml \ + | sudo kubectl apply -f - +``` + +### 4. 확인 + +```bash +# 웹훅 등록 확인 +sudo kubectl get mutatingwebhookconfiguration | grep user-v2 + +# 컨트롤러 로그 +sudo kubectl -n project-controller logs deployment/project-controller -f --tail=50 +``` + +### 5. 테스트 + +```bash +# 사용자 네임스페이스 확인 (시스템 네임스페이스 제외) +sudo kubectl get ns --no-headers | grep -v -E "kube-system|kube-public|kube-node-lease|kube-flannel|aipub|coaster|harbor|ingress-nginx|keycloak|linkerd|metallb|project-controller|aipub-promstack|trident|aipub-efk" + +# 테스트할 네임스페이스에서 deployment 생성 (네임스페이스 변경해서 사용) +sudo kubectl -n taehyeong create deployment test-v2 --image=nginx + +# v2 라벨 확인 +sudo kubectl -n taehyeong get workspace label2 -o jsonpath='{.metadata.labels}' | jq . + +# v2 owner annotation 확인 +sudo kubectl -n taehyeong get workspace label2 -o jsonpath='{.metadata.annotations.aipub\.ten1010\.io/owner-reference-v2}' | jq . + +# 실제 ownerReference 확인 (v1 기존 방식) +sudo kubectl -n taehyeong get workspace label2 -o jsonpath='{.metadata.ownerReferences}' | jq . + +# 정리 +sudo kubectl -n delete deployment test-v2 +``` + +### 6. 기존 라벨/ownerReference 확인 + +```bash +# 기존 라벨 확인 (v1) +sudo kubectl -n get deployment test-v2 -o jsonpath='{.metadata.labels}' | jq . + +# 기존 ownerReference 확인 (v1) +sudo kubectl -n get deployment test-v2 -o jsonpath='{.metadata.ownerReferences}' | jq . + +# v2 라벨 확인 +sudo kubectl -n get deployment test-v2 -o jsonpath='{.metadata.labels.aipub\.ten1010\.io/username-v2}' + +# v2 owner annotation 확인 +sudo kubectl -n get deployment test-v2 -o jsonpath='{.metadata.annotations.aipub\.ten1010\.io/owner-reference-v2}' | jq . +``` + +### 7. 특정 라벨로 리소스 검색 + +```bash +# v2 username 라벨로 검색 +sudo kubectl -n taehyeong get all -l aipub.ten1010.io/username-v2=taehyeong + +# v2 userid 라벨로 검색 +sudo kubectl -n taehyeong get all -l aipub.ten1010.io/userid-v2=test-user-001 + +# 기존 v1 username 라벨로 검색 +sudo kubectl -n get all -l aipub.ten1010.io/username=testuser + +# 특정 네임스페이스 전체에서 v2 라벨 가진 리소스 검색 +sudo kubectl -n get deployments,pods,services,configmaps,secrets -l aipub.ten1010.io/username-v2 + +# 모든 네임스페이스에서 검색 +sudo kubectl get deployments --all-namespaces -l aipub.ten1010.io/username-v2=testuser +``` + +## 한줄 배포 명령어 + +```bash +CA_BUNDLE=$(sudo kubectl get mutatingwebhookconfiguration project-controller.project.aipub.ten1010.io -o jsonpath='{.webhooks[0].clientConfig.caBundle}') && sed "s||${CA_BUNDLE}|g" kubernetes/controller/project-controller/templates/mutating-webhook-user-v2.yaml | sudo kubectl apply -f - +``` + +## 롤백 + +```bash +# v2 웹훅만 제거 (기존 웹훅에 영향 없음) +sudo kubectl delete mutatingwebhookconfiguration userrelationship-v2.project-controller.project.aipub.ten1010.io + +# 컨트롤러 이미지 이전 버전으로 복구 +sudo kubectl -n project-controller rollout undo deployment/project-controller +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..42e181fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM eclipse-temurin:21-jre +COPY target/*.jar /app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/harbor/README.md b/harbor/README.md new file mode 100644 index 00000000..e40fa0d4 --- /dev/null +++ b/harbor/README.md @@ -0,0 +1,45 @@ +# Harbor Image Push Setup (macOS) + +## 1. harbor/.env 설정 + +``` +HARBOR_REGISTRY= +HARBOR_USERNAME= +HARBOR_PASSWORD= +``` + +## 2. /etc/hosts 추가 + +Harbor가 토큰 인증 시 내부 주소(`vnode2.pnode1.idc1.ten1010.io`)로 리다이렉트하므로, 외부 IP로 매핑 필요: + +```bash +sudo sh -c 'echo "101.202.0.27 vnode2.pnode1.idc1.ten1010.io" >> /etc/hosts' +``` + +## 3. Docker Desktop insecure registry 설정 + +Harbor 인증서가 self-signed이므로 Docker Desktop에서 insecure registry로 등록: + +Docker Desktop → Settings → Docker Engine: + +```json +{ + "insecure-registries": [ + "vnode2.pnode1.idc1.ten1010.io:8443", + "external.vnode2.pnode1.idc1.ten1010.io:8443" + ] +} +``` + +Apply & Restart. + +## 4. 이미지 빌드 & Push + +```bash +./image_push.sh + +# 예시 +./image_push.sh 1.5.0 +``` + +이미지 경로: `vnode2.pnode1.idc1.ten1010.io:8443/project-controller/project-controller:` diff --git a/image_push.sh b/image_push.sh new file mode 100755 index 00000000..0e8e3bb9 --- /dev/null +++ b/image_push.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${1:-}" ]; then + echo "Usage: ./image_push.sh " + echo "Example: ./image_push.sh 1.5.0" + exit 1 +fi + +TAG="$1" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Load harbor config +if [ -f "$SCRIPT_DIR/harbor/.env" ]; then + set -a + source "$SCRIPT_DIR/harbor/.env" + set +a +fi + +if [ -z "${HARBOR_REGISTRY:-}" ]; then + echo "HARBOR_REGISTRY not set in harbor/.env" + exit 1 +fi +REGISTRY="${HARBOR_REGISTRY}" +IMAGE="${REGISTRY}/project-controller/project-controller:${TAG}" + +echo "=== Login to Harbor ===" +if [ -n "${HARBOR_USERNAME:-}" ] && [ -n "${HARBOR_PASSWORD:-}" ]; then + echo "${HARBOR_PASSWORD}" | docker login "${REGISTRY}" -u "${HARBOR_USERNAME}" --password-stdin +else + echo "HARBOR_USERNAME or HARBOR_PASSWORD not set in harbor/.env" + exit 1 +fi + +echo "" +echo "=== Build jar ===" +./mvnw -DskipTests clean package -q + +echo "" +echo "=== Build image (linux/amd64) ===" +docker buildx build --platform linux/amd64 \ + -t "${IMAGE}" \ + -f Dockerfile . \ + --load + +echo "" +echo "=== Push image ===" +docker push "${IMAGE}" + +echo "" +echo "Done: ${IMAGE}" diff --git a/kubernetes/controller/project-controller/templates/mutating-webhook-user-v2.yaml b/kubernetes/controller/project-controller/templates/mutating-webhook-user-v2.yaml new file mode 100644 index 00000000..b5ba0f26 --- /dev/null +++ b/kubernetes/controller/project-controller/templates/mutating-webhook-user-v2.yaml @@ -0,0 +1,66 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: userrelationship-v2.project-controller.project.aipub.ten1010.io +webhooks: + - name: userrelationship-v2.project-controller.project.aipub.ten1010.io + admissionReviewVersions: [ "v1" ] + clientConfig: + caBundle: + service: + namespace: project-controller + name: project-controller + path: /api/v1/userrelationship/mutate + port: 8080 + failurePolicy: Ignore + matchPolicy: Equivalent + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - kube-system + - kube-public + - kube-node-lease + - kube-flannel + - aipub + - coaster + - harbor + - ingress-nginx + - keycloak + - linkerd + - metallb + - project-controller + - aipub-promstack + - trident + - aipub-efk + objectSelector: { } + reinvocationPolicy: IfNeeded + rules: + - apiGroups: [ "" ] + apiVersions: [ "*" ] + operations: [ "CREATE" ] + resources: [ "pods", "replicationcontrollers", "services", "configmaps", "secrets", "persistentvolumeclaims" ] + scope: "Namespaced" + - apiGroups: [ "networking.k8s.io" ] + apiVersions: [ "*" ] + operations: [ "CREATE" ] + resources: [ "ingresses" ] + scope: "Namespaced" + - apiGroups: [ "batch" ] + apiVersions: [ "*" ] + operations: [ "CREATE" ] + resources: [ "*" ] + scope: "Namespaced" + - apiGroups: [ "apps" ] + apiVersions: [ "*" ] + operations: [ "CREATE" ] + resources: [ "*" ] + scope: "Namespaced" + - apiGroups: [ "aipub.ten1010.io" ] + apiVersions: [ "*" ] + operations: [ "CREATE" ] + resources: [ "*" ] + scope: "Namespaced" + sideEffects: None + timeoutSeconds: 10 diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubProperties.java b/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubProperties.java index 8fa6190b..9faf3251 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubProperties.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubProperties.java @@ -21,5 +21,6 @@ public class AipubProperties { @Nullable private String password; private List reservedNamespace = new ArrayList<>(); + private List addOwnerExceptGvkList = new ArrayList<>(); } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/configuration/MutatingConfiguration.java b/src/main/java/io/ten1010/aipub/projectcontroller/configuration/MutatingConfiguration.java index 6a76f61d..3271694a 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/configuration/MutatingConfiguration.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/configuration/MutatingConfiguration.java @@ -1,6 +1,7 @@ package io.ten1010.aipub.projectcontroller.configuration; import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.ApiClient; import io.ten1010.aipub.projectcontroller.controller.workload.PodNodesResolver; import io.ten1010.aipub.projectcontroller.controller.workload.WorkloadControllerNodesResolver; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ArtifactService; @@ -18,7 +19,14 @@ import io.ten1010.aipub.projectcontroller.mutating.service.PodReviewHandler; import io.ten1010.aipub.projectcontroller.mutating.service.ProjectReviewHandler; import io.ten1010.aipub.projectcontroller.mutating.service.ReviewHandler; +import io.ten1010.aipub.projectcontroller.mutating.service.UserInfoAnalyzer; +import io.ten1010.aipub.projectcontroller.mutating.service.ApiResourceDiscovery; +import io.ten1010.aipub.projectcontroller.mutating.service.UserLabelReviewHandler; +import io.ten1010.aipub.projectcontroller.mutating.service.UserOwnerReviewHandler; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -76,4 +84,22 @@ public ImageReviewReviewHandler imageReviewReviewHandler( sharedInformerFactory); } + @Bean + @Qualifier("aipubReviewHandlers") + public List aipubReviewHandlers( + SharedInformerFactory sharedInformerFactory, AipubProperties aipubProperties, + ApiClient apiClient) { + UserInfoAnalyzer userInfoAnalyzer = new UserInfoAnalyzer(sharedInformerFactory); + Set exceptGvkSet = aipubProperties.getAddOwnerExceptGvkList().stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + UserOwnerReviewHandler userOwnerReviewHandler = new UserOwnerReviewHandler( + userInfoAnalyzer, exceptGvkSet); + ApiResourceDiscovery apiResourceDiscovery = new ApiResourceDiscovery(apiClient); + UserLabelReviewHandler userLabelReviewHandler = new UserLabelReviewHandler( + userInfoAnalyzer, apiResourceDiscovery, apiClient); + return List.of(userOwnerReviewHandler, userLabelReviewHandler); + } + } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/LabelConstants.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/LabelConstants.java index aa8ce071..f54c85f9 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/LabelConstants.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/LabelConstants.java @@ -8,6 +8,8 @@ public final class LabelConstants { ProjectApiConstants.PROJECT_GROUP + "/" + "isolation-mode"; public static final String OBJECT_OWN_USERNAME_KEY = ProjectApiConstants.AIPUB_GROUP + "/" + "username"; + public static final String OBJECT_OWN_USERID_KEY = + ProjectApiConstants.AIPUB_GROUP + "/" + "userid"; public static final String PROJECT_LABEL_KEY = ProjectApiConstants.PROJECT_GROUP + "/" + "project"; diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/AipubAdmissionReviewController.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/AipubAdmissionReviewController.java new file mode 100644 index 00000000..54ff7a2f --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/AipubAdmissionReviewController.java @@ -0,0 +1,59 @@ +package io.ten1010.aipub.projectcontroller.mutating; + +import static io.ten1010.aipub.projectcontroller.mutating.AipubAdmissionReviewController.PATH; + +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; +import io.ten1010.aipub.projectcontroller.mutating.service.ReviewHandler; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(PATH) +public class AipubAdmissionReviewController { + + public static final String PATH = "/api/v1/userrelationship/mutate"; + + private final List reviewHandlers; + + public AipubAdmissionReviewController( + @Qualifier("aipubReviewHandlers") List reviewHandlers) { + this.reviewHandlers = reviewHandlers; + } + + @PostMapping(value = "", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create(@RequestBody V1AdmissionReview review) { + log.debug("Aipub admission review request received"); + V1AdmissionReview clone = V1AdmissionReviewUtils.clone(review); + + if (clone.getRequest() == null) { + throw new IllegalPropertyException(AdmissionReviewConstants.REVIEW_OBJECT_NAME, + "/request", "request is null"); + } + + boolean handled = false; + for (ReviewHandler handler : this.reviewHandlers) { + if (handler.canHandle(clone)) { + handler.handle(clone); + handled = true; + if (clone.getResponse() != null && !Boolean.TRUE.equals(clone.getResponse().getAllowed())) { + break; + } + } + } + + if (!handled) { + V1AdmissionReviewUtils.allow(clone); + } + + return ResponseEntity.ok(clone); + } + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/V1AdmissionReviewUtils.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/V1AdmissionReviewUtils.java index 85ade8d6..449e5b63 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/V1AdmissionReviewUtils.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/V1AdmissionReviewUtils.java @@ -7,6 +7,12 @@ import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReviewResponse; import io.ten1010.aipub.projectcontroller.mutating.dto.V1Status; import io.ten1010.common.jsonpatch.dto.JsonPatch; +import io.ten1010.common.jsonpatch.dto.JsonPatchOperation; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; import java.util.Objects; public abstract class V1AdmissionReviewUtils { @@ -26,19 +32,35 @@ public static V1AdmissionReview clone(V1AdmissionReview review) { public static void allow(V1AdmissionReview review) { Objects.requireNonNull(review.getRequest()); - V1AdmissionReviewResponse response = new V1AdmissionReviewResponse(); - response.setUid(review.getRequest().getUid()); - response.setAllowed(true); - - review.setResponse(response); + if (review.getResponse() == null) { + V1AdmissionReviewResponse response = new V1AdmissionReviewResponse(); + response.setUid(review.getRequest().getUid()); + response.setAllowed(true); + review.setResponse(response); + } } public static void allow(V1AdmissionReview review, JsonPatch jsonPatch) { allow(review); - String patch = new JsonPatchHelper(MAPPER).buildPatchString(jsonPatch); - Objects.requireNonNull(review.getResponse()); + + List allOps = new ArrayList<>(jsonPatch.getOperations()); + String existingPatchStr = review.getResponse().getPatch(); + if (existingPatchStr != null) { + try { + byte[] decoded = Base64.getDecoder().decode(existingPatchStr); + JsonPatch existingPatch = MAPPER.readValue(decoded, JsonPatch.class); + allOps.addAll(0, existingPatch.getOperations()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + JsonPatch mergedPatch = new JsonPatch(); + mergedPatch.setOperations(allOps); + + String patch = new JsonPatchHelper(MAPPER).buildPatchString(mergedPatch); review.getResponse().setPatchType("JSONPatch"); review.getResponse().setPatch(patch); } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/ApiResourceDiscovery.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/ApiResourceDiscovery.java new file mode 100644 index 00000000..d13ef81c --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/ApiResourceDiscovery.java @@ -0,0 +1,175 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.ten1010.aipub.projectcontroller.domain.k8s.ObjectMapperFactory; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Call; +import okhttp3.Response; +import org.jspecify.annotations.Nullable; + +@Slf4j +public class ApiResourceDiscovery { + + // TODO check: Python(api_resource_manager.py)은 run() 메서드로 300초마다 주기적으로 리소스를 재탐색함. + // Java는 생성자에서 1회만 init(). 런타임에 새 CRD 추가 시 반영 안 됨. + + private final ApiClient apiClient; + private final ObjectMapper mapper; + private final Map plurals = new HashMap<>(); + private final Map namespacedInfo = new HashMap<>(); + private final Map groupVersions = new HashMap<>(); + private final Map> kindDict = new HashMap<>(); + // TODO check: List.contains()는 O(n). Python도 동일하지만, 리소스 수가 많을 경우 Set 고려. + private final List groupResources = new ArrayList<>(); + + public ApiResourceDiscovery(ApiClient apiClient) { + this.apiClient = apiClient; + this.mapper = new ObjectMapperFactory().createObjectMapper(); + init(); + } + + private void init() { + // Core API resources (/api/v1) + try { + JsonNode coreResources = fetchJson("/api/v1"); + if (coreResources != null) { + for (JsonNode resource : coreResources.path("resources")) { + String name = resource.path("name").asText(); + if (name.contains("/")) { + continue; + } + String kind = resource.path("kind").asText(); + boolean namespaced = resource.path("namespaced").asBoolean(); + + // TODO check: Python도 동일하지만, core API 리소스(Pod, Service 등)가 groupVersions에 저장되지 않음. + // getResourcesByKind("Pod"), getGroupVersion("/pods") 호출 시 null 반환됨. + String groupResource = "/" + name; + this.plurals.put("v1/" + kind, name); + this.namespacedInfo.put(groupResource, namespaced); + this.groupResources.add(groupResource); + this.kindDict.computeIfAbsent(kind, k -> new ArrayList<>()).add(groupResource); + } + } + } catch (Exception e) { + log.warn("Failed to discover core API resources", e); + } + + // Non-core API resources (/apis) + try { + JsonNode apiGroups = fetchJson("/apis"); + if (apiGroups != null) { + for (JsonNode group : apiGroups.path("groups")) { + String groupName = group.path("name").asText(); + for (JsonNode version : group.path("versions")) { + String groupVersion = version.path("groupVersion").asText(); + try { + JsonNode resources = fetchJson("/apis/" + groupVersion); + if (resources != null) { + for (JsonNode resource : resources.path("resources")) { + String name = resource.path("name").asText(); + if (name.contains("/")) { + continue; + } + String kind = resource.path("kind").asText(); + boolean namespaced = resource.path("namespaced").asBoolean(); + + String groupResource = groupName + "/" + name; + this.plurals.put(groupVersion + "/" + kind, name); + this.namespacedInfo.put(groupResource, namespaced); + this.groupVersions.put(groupResource, groupVersion); + this.groupResources.add(groupResource); + this.kindDict.computeIfAbsent(kind, k -> new ArrayList<>()).add(groupResource); + } + } + } catch (Exception e) { + log.warn("Failed to discover API resources for {}", groupVersion, e); + } + } + } + } + } catch (Exception e) { + log.warn("Failed to discover API groups", e); + } + + log.info("Discovered {} API resource plurals, {} namespaced info entries", this.plurals.size(), this.namespacedInfo.size()); + if (this.plurals.containsKey("apps/v1/Deployment")) { + log.info("Deployment plural: {}", this.plurals.get("apps/v1/Deployment")); + } else { + log.info("WARNING: apps/v1/Deployment NOT found in plurals map"); + } + } + + @Nullable + public String getPlural(String apiVersion, String kind) { + return this.plurals.get(apiVersion + "/" + kind); + } + + public boolean isNamespaced(String groupResource) { + Boolean result = this.namespacedInfo.get(groupResource); + if (result == null) { + throw new GroupResourceNotFoundException(groupResource); + } + return result; + } + + public boolean isExist(String groupResource) { + return this.groupResources.contains(groupResource); + } + + @Nullable + public String getGroupVersion(String groupResource) { + return this.groupVersions.get(groupResource); + } + + public List getResourcesByKind(String kind) { + List resources = new ArrayList<>(); + for (String groupResource : this.kindDict.getOrDefault(kind, List.of())) { + String groupVersion = this.groupVersions.get(groupResource); + // TODO check: groupVersion이 null일 때(core API 리소스) 아래 문자열 연결에서 "null/Pod" 됨. + // Python도 동일한 패턴이지만, null check를 먼저 해야 안전함. + String apiVersionKind = groupVersion + "/" + kind; + String plural = this.plurals.get(apiVersionKind); + if (groupVersion == null || plural == null) { + continue; + } + resources.add(new ResourceInfo(groupVersion, plural)); + } + return resources; + } + + public record ResourceInfo(String apiVersion, String plural) { + } + + @Nullable + private JsonNode fetchJson(String path) { + try { + Call call = this.apiClient.buildCall( + this.apiClient.getBasePath(), path, "GET", + List.of(), List.of(), + null, + Map.of(), Map.of(), Map.of(), + new String[]{"BearerToken"}, null); + try (Response response = call.execute()) { + if (!response.isSuccessful()) { + log.warn("Failed to fetch API resource: {} status={}", path, response.code()); + return null; + } + if (response.body() == null) { + return null; + } + return this.mapper.readTree(response.body().string()); + } + } catch (Exception e) { + log.warn("Failed to fetch API resource: {}", path, e); + return null; + } + } + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/CompositeReviewHandler.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/CompositeReviewHandler.java index 98117e02..d83cd09e 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/CompositeReviewHandler.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/CompositeReviewHandler.java @@ -2,7 +2,6 @@ import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; import java.util.List; -import java.util.Optional; import lombok.AllArgsConstructor; @AllArgsConstructor @@ -12,22 +11,21 @@ public class CompositeReviewHandler implements ReviewHandler { @Override public void handle(V1AdmissionReview review) { - ReviewHandler handler = getHandler(review).orElseThrow(); - handler.handle(review); - } - - @Override - public boolean canHandle(V1AdmissionReview review) { - return getHandler(review).isPresent(); - } - - private Optional getHandler(V1AdmissionReview review) { + boolean handled = false; for (ReviewHandler handler : this.handlers) { if (handler.canHandle(review)) { - return Optional.of(handler); + handler.handle(review); + handled = true; } } - return Optional.empty(); + if (!handled) { + throw new java.util.NoSuchElementException("No handler found for review"); + } + } + + @Override + public boolean canHandle(V1AdmissionReview review) { + return this.handlers.stream().anyMatch(h -> h.canHandle(review)); } } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/GroupResourceNotFoundException.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/GroupResourceNotFoundException.java new file mode 100644 index 00000000..1fe4740c --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/GroupResourceNotFoundException.java @@ -0,0 +1,16 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +public class GroupResourceNotFoundException extends RuntimeException { + + private final String groupResource; + + public GroupResourceNotFoundException(String groupResource) { + super("Not found group resource: " + groupResource); + this.groupResource = groupResource; + } + + public String getGroupResource() { + return this.groupResource; + } + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserInfoAnalyzer.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserInfoAnalyzer.java index 8dc353c3..2b5c71da 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserInfoAnalyzer.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserInfoAnalyzer.java @@ -5,7 +5,6 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.K8sGroupConstants; import io.ten1010.aipub.projectcontroller.domain.k8s.KeyResolver; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; -import io.ten1010.aipub.projectcontroller.domain.k8s.util.LabelUtils; import io.ten1010.aipub.projectcontroller.mutating.dto.V1UserInfo; import java.util.List; import java.util.Objects; @@ -48,12 +47,12 @@ public UserInfoAnalysis analyze(V1UserInfo userInfo) { V1alpha1AipubUser aipubUser = null; if (isAipubMember(userInfo.getGroups())) { - // todo-- - String aipubUserKey = this.keyResolver.resolveKey( - LabelUtils.getValueOfLabelString(userInfo.getUsername())); - // todo-- + String username = userInfo.getUsername(); + String aipubUserName = username.contains(":") + ? username.substring(username.lastIndexOf(":") + 1) + : username; + String aipubUserKey = this.keyResolver.resolveKey(aipubUserName); aipubUser = this.userIndexer.getByKey(aipubUserKey); - Objects.requireNonNull(aipubUser); } return new UserInfoAnalysis(userInfo.getUsername(), userInfo.getGroups(), aipubUser); diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandler.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandler.java new file mode 100644 index 00000000..20cd06ac --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandler.java @@ -0,0 +1,256 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kubernetes.client.openapi.ApiClient; +import io.ten1010.aipub.projectcontroller.domain.k8s.LabelConstants; +import io.ten1010.aipub.projectcontroller.domain.k8s.ObjectMapperFactory; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; +import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; +import io.ten1010.aipub.projectcontroller.mutating.V1AdmissionReviewUtils; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReviewRequest; +import io.ten1010.common.jsonpatch.JsonPatchBuilder; +import io.ten1010.common.jsonpatch.JsonPatchOperationBuilder; +import io.ten1010.common.jsonpatch.dto.JsonPatchOperation; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Call; +import okhttp3.Response; +import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpStatus; + +@Slf4j +public class UserLabelReviewHandler implements ReviewHandler { + + private static final String OPERATION_CREATE = "CREATE"; + + // v2 테스트용: Python 원본과 병행 운영하여 비교. 정식 전환 시 LabelConstants 원본 키로 복원. + private static final String USERNAME_LABEL_KEY_V2 = + LabelConstants.OBJECT_OWN_USERNAME_KEY + "-v2"; + private static final String USERID_LABEL_KEY_V2 = + LabelConstants.OBJECT_OWN_USERID_KEY + "-v2"; + + private final UserInfoAnalyzer userInfoAnalyzer; + private final ApiResourceDiscovery apiResourceDiscovery; + private final ApiClient k8sApiClient; + private final ObjectMapper mapper; + + public UserLabelReviewHandler(UserInfoAnalyzer userInfoAnalyzer, + ApiResourceDiscovery apiResourceDiscovery, ApiClient k8sApiClient) { + this.userInfoAnalyzer = userInfoAnalyzer; + this.apiResourceDiscovery = apiResourceDiscovery; + this.k8sApiClient = k8sApiClient; + this.mapper = new ObjectMapperFactory().createObjectMapper(); + } + + @Override + public boolean canHandle(V1AdmissionReview review) { + Objects.requireNonNull(review.getRequest()); + + V1AdmissionReviewRequest request = review.getRequest(); + if (!OPERATION_CREATE.equals(request.getOperation())) { + return false; + } + return request.getNamespace() != null && !request.getNamespace().isEmpty(); + } + + @Override + public void handle(V1AdmissionReview review) { + Objects.requireNonNull(review.getRequest()); + + V1AdmissionReviewRequest request = review.getRequest(); + Objects.requireNonNull(request.getUserInfo()); + Objects.requireNonNull(request.getObject()); + Objects.requireNonNull(request.getNamespace()); + + log.debug("UserLabel handle: user={}, namespace={}, operation={}", + request.getUserInfo().getUsername(), request.getNamespace(), request.getOperation()); + + UserInfoAnalysis analysis; + try { + analysis = this.userInfoAnalyzer.analyze(request.getUserInfo()); + } catch (Exception e) { + // Python: get_aipub_user non-404 ApiException → 500 + log.warn("Failed to analyze user info", e); + V1AdmissionReviewUtils.reject(review, 500, + "Failed to get aipub user with following error. " + e.getMessage()); + return; + } + + String username; + String userid; + + if (analysis.isAipubMember() && analysis.getAipubUser().isPresent()) { + V1alpha1AipubUser aipubUser = analysis.getAipubUser().get(); + if (aipubUser.getSpec() == null || aipubUser.getSpec().getId() == null) { + V1AdmissionReviewUtils.reject(review, HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Not found user id of aipub user: " + K8sObjectUtils.getName(aipubUser)); + return; + } + username = K8sObjectUtils.getName(aipubUser); + userid = aipubUser.getSpec().getId(); + log.debug("UserLabel: direct aipub member, username={}, userid={}", username, userid); + } else if (analysis.isAipubMember()) { + V1AdmissionReviewUtils.reject(review, 400, + "Not found aipub user: " + analysis.getUsername()); + return; + } else { + log.debug("UserLabel: not aipub member, looking up owner labels"); + String[] ownerLabels; + try { + ownerLabels = getLabelsFromOwner(request.getObject(), request.getNamespace()); + } catch (Exception e) { + // Python: owner_service.get_owner_object non-404 ApiException → 500 + log.warn("Failed to get owner object", e); + V1AdmissionReviewUtils.reject(review, 500, e.getMessage()); + return; + } + if (ownerLabels == null) { + log.debug("UserLabel: no owner labels found, allowing without mutation"); + V1AdmissionReviewUtils.allow(review); + return; + } + username = ownerLabels[0]; + userid = ownerLabels[1]; + log.debug("UserLabel: propagated from owner, username={}, userid={}", username, userid); + } + + JsonNode objectNode = request.getObject(); + JsonNode existingLabels = objectNode.path("metadata").path("labels"); + + JsonPatchBuilder jsonPatchBuilder = new JsonPatchBuilder(); + + if (!existingLabels.isObject()) { + JsonPatchOperation initLabelsOp = new JsonPatchOperationBuilder() + .add() + .setPath("/metadata/labels") + .setValue(this.mapper.createObjectNode()) + .build(); + jsonPatchBuilder.addToOperations(initLabelsOp); + } + + String usernameLabelPath = "/metadata/labels/" + + USERNAME_LABEL_KEY_V2.replace("/", "~1"); + JsonPatchOperation usernamePatchOp = new JsonPatchOperationBuilder() + .add() + .setPath(usernameLabelPath) + .setValue(this.mapper.getNodeFactory().textNode(username)) + .build(); + jsonPatchBuilder.addToOperations(usernamePatchOp); + + String useridLabelPath = "/metadata/labels/" + + USERID_LABEL_KEY_V2.replace("/", "~1"); + JsonPatchOperation useridPatchOp = new JsonPatchOperationBuilder() + .add() + .setPath(useridLabelPath) + .setValue(this.mapper.getNodeFactory().textNode(userid)) + .build(); + jsonPatchBuilder.addToOperations(useridPatchOp); + + V1AdmissionReviewUtils.allow(review, jsonPatchBuilder.build()); + } + + @Nullable + private String[] getLabelsFromOwner(JsonNode objectNode, String namespace) { + JsonNode ownerRefs = objectNode.path("metadata").path("ownerReferences"); + if (!ownerRefs.isArray()) { + return null; + } + + JsonNode controllerRef = null; + for (JsonNode ref : ownerRefs) { + if (ref.path("controller").asBoolean(false)) { + controllerRef = ref; + break; + } + } + if (controllerRef == null) { + log.debug("getLabelsFromOwner: no controller ref found"); + return null; + } + + String apiVersion = controllerRef.path("apiVersion").asText(); + String kind = controllerRef.path("kind").asText(); + String name = controllerRef.path("name").asText(); + log.debug("getLabelsFromOwner: controller ref apiVersion={}, kind={}, name={}", apiVersion, kind, name); + + String plural = this.apiResourceDiscovery.getPlural(apiVersion, kind); + if (plural == null) { + log.debug("getLabelsFromOwner: unknown plural for {}/{}", apiVersion, kind); + return null; + } + log.debug("getLabelsFromOwner: plural={}", plural); + + String group = apiVersion.contains("/") ? apiVersion.split("/")[0] : ""; + String groupResource = group + "/" + plural; + // Python: is_namespaced에서 Exception 발생 시 catch 없이 상위로 전파 → 500 + if (!this.apiResourceDiscovery.isNamespaced(groupResource)) { + log.debug("getLabelsFromOwner: owner not namespaced: {}", groupResource); + return null; + } + + JsonNode ownerObject = fetchObject(apiVersion, namespace, plural, name); + if (ownerObject == null) { + log.debug("getLabelsFromOwner: failed to fetch owner object"); + return null; + } + + JsonNode ownerLabels = ownerObject.path("metadata").path("labels"); + if (!ownerLabels.isObject()) { + log.debug("getLabelsFromOwner: owner has no labels"); + return null; + } + + JsonNode usernameNode = ownerLabels.path(USERNAME_LABEL_KEY_V2); + JsonNode useridNode = ownerLabels.path(USERID_LABEL_KEY_V2); + if (usernameNode.isMissingNode() || useridNode.isMissingNode()) { + log.debug("getLabelsFromOwner: owner missing username/userid labels. labels={}", ownerLabels); + return null; + } + + return new String[]{usernameNode.asText(), useridNode.asText()}; + } + + @Nullable + private JsonNode fetchObject(String apiVersion, String namespace, String plural, String name) { + String path; + if (apiVersion.contains("/")) { + path = "/apis/" + apiVersion + "/namespaces/" + namespace + "/" + plural + "/" + name; + } else { + path = "/api/" + apiVersion + "/namespaces/" + namespace + "/" + plural + "/" + name; + } + + try { + Call call = this.k8sApiClient.buildCall( + this.k8sApiClient.getBasePath(), path, "GET", + List.of(), List.of(), + null, + Map.of(), Map.of(), Map.of(), + new String[]{"BearerToken"}, null); + try (Response response = call.execute()) { + if (!response.isSuccessful()) { + if (response.code() == 404) { + log.debug("Owner object not found: {}", path); + return null; + } + // Python: ApiException non-404 → output.to_not_allowed(500) + throw new RuntimeException( + "Failed to get owner object with APIException. status code: " + response.code()); + } + if (response.body() == null) { + return null; + } + return this.mapper.readTree(response.body().string()); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + // Python: bare except → output.to_not_allowed(500, "undefined error") + throw new RuntimeException("Failed to get owner object with undefined error", e); + } + } + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandlerV2.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandlerV2.java new file mode 100644 index 00000000..631075d9 --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandlerV2.java @@ -0,0 +1,101 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.ten1010.aipub.projectcontroller.domain.k8s.LabelConstants; +import io.ten1010.aipub.projectcontroller.domain.k8s.ObjectMapperFactory; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; +import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; +import io.ten1010.aipub.projectcontroller.mutating.V1AdmissionReviewUtils; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1UserInfo; +import io.ten1010.common.jsonpatch.JsonPatchBuilder; +import io.ten1010.common.jsonpatch.JsonPatchOperationBuilder; +import io.ten1010.common.jsonpatch.dto.JsonPatchOperation; +import java.util.Objects; + +public class UserLabelReviewHandlerV2 implements ReviewHandler { + + private static final String USERNAME_LABEL_PATH = + "/metadata/labels/" + LabelConstants.OBJECT_OWN_USERNAME_KEY.replace("/", "~1"); + private static final String USERID_LABEL_PATH = + "/metadata/labels/" + LabelConstants.OBJECT_OWN_USERID_KEY.replace("/", "~1"); + + private final UserInfoAnalyzer userInfoAnalyzer; + private final ObjectMapper mapper; + + public UserLabelReviewHandlerV2(UserInfoAnalyzer userInfoAnalyzer) { + this.userInfoAnalyzer = userInfoAnalyzer; + this.mapper = new ObjectMapperFactory().createObjectMapper(); + } + + @Override + public boolean canHandle(V1AdmissionReview review) { + Objects.requireNonNull(review.getRequest()); + return "CREATE".equals(review.getRequest().getOperation()) + && review.getRequest().getNamespace() != null; + } + + @Override + public void handle(V1AdmissionReview review) { + Objects.requireNonNull(review.getRequest()); + Objects.requireNonNull(review.getRequest().getUserInfo()); + + V1UserInfo userInfo = review.getRequest().getUserInfo(); + UserInfoAnalysis analysis = this.userInfoAnalyzer.analyze(userInfo); + + if (!analysis.isAipubMember()) { + V1AdmissionReviewUtils.allow(review); + return; + } + + V1alpha1AipubUser aipubUser = analysis.getAipubUser().orElse(null); + if (aipubUser == null) { + V1AdmissionReviewUtils.allow(review); + return; + } + + String userId = aipubUser.getSpec() != null ? aipubUser.getSpec().getId() : null; + if (userId == null) { + V1AdmissionReviewUtils.reject(review, 500, "user id not found"); + return; + } + + String username = K8sObjectUtils.getName(aipubUser); + + JsonPatchBuilder jsonPatchBuilder = new JsonPatchBuilder(); + + JsonNode objectNode = review.getRequest().getObject(); + JsonNode labelsNode = objectNode != null + ? objectNode.path("metadata").path("labels") + : null; + boolean hasLabels = labelsNode != null && !labelsNode.isMissingNode() && !labelsNode.isNull(); + + if (!hasLabels) { + JsonPatchOperation initLabelsOp = new JsonPatchOperationBuilder() + .add() + .setPath("/metadata/labels") + .setValue(mapper.createObjectNode()) + .build(); + jsonPatchBuilder.addToOperations(initLabelsOp); + } + + JsonPatchOperation usernameLabelOp = new JsonPatchOperationBuilder() + .add() + .setPath(USERNAME_LABEL_PATH) + .setValue(mapper.getNodeFactory().textNode(username)) + .build(); + jsonPatchBuilder.addToOperations(usernameLabelOp); + + JsonPatchOperation useridLabelOp = new JsonPatchOperationBuilder() + .add() + .setPath(USERID_LABEL_PATH) + .setValue(mapper.getNodeFactory().textNode(userId)) + .build(); + jsonPatchBuilder.addToOperations(useridLabelOp); + + V1AdmissionReviewUtils.allow(review, jsonPatchBuilder.build()); + } + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserOwnerReviewHandler.java b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserOwnerReviewHandler.java new file mode 100644 index 00000000..28702987 --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/mutating/service/UserOwnerReviewHandler.java @@ -0,0 +1,125 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kubernetes.client.openapi.models.V1OwnerReference; +import io.ten1010.aipub.projectcontroller.domain.k8s.ObjectMapperFactory; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; +import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; +import io.ten1010.aipub.projectcontroller.mutating.V1AdmissionReviewUtils; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReviewRequest; +import io.ten1010.common.jsonpatch.JsonPatchBuilder; +import io.ten1010.common.jsonpatch.JsonPatchOperationBuilder; +import io.ten1010.common.jsonpatch.dto.JsonPatchOperation; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class UserOwnerReviewHandler implements ReviewHandler { + + private static final String OPERATION_CREATE = "CREATE"; + + // v2 테스트용: ownerReference 대신 annotation으로 기록. 정식 전환 시 ownerReference 방식으로 복원. + private static final String OWNER_REF_ANNOTATION_KEY_V2 = + "aipub.ten1010.io/owner-reference-v2"; + + private final UserInfoAnalyzer userInfoAnalyzer; + private final Set exceptGvkSet; + private final ObjectMapper mapper; + + public UserOwnerReviewHandler(UserInfoAnalyzer userInfoAnalyzer, Set exceptGvkSet) { + this.userInfoAnalyzer = userInfoAnalyzer; + this.exceptGvkSet = exceptGvkSet; + this.mapper = new ObjectMapperFactory().createObjectMapper(); + } + + @Override + public boolean canHandle(V1AdmissionReview review) { + Objects.requireNonNull(review.getRequest()); + + V1AdmissionReviewRequest request = review.getRequest(); + if (!OPERATION_CREATE.equals(request.getOperation())) { + return false; + } + return request.getNamespace() != null && !request.getNamespace().isEmpty(); + } + + @Override + public void handle(V1AdmissionReview review) { + Objects.requireNonNull(review.getRequest()); + + V1AdmissionReviewRequest request = review.getRequest(); + Objects.requireNonNull(request.getKind()); + Objects.requireNonNull(request.getKind().getGroup()); + Objects.requireNonNull(request.getKind().getVersion()); + Objects.requireNonNull(request.getKind().getKind()); + Objects.requireNonNull(request.getUserInfo()); + Objects.requireNonNull(request.getObject()); + + String gvk = request.getKind().getGroup() + "/" + + request.getKind().getVersion() + "/" + + request.getKind().getKind(); + if (this.exceptGvkSet.contains(gvk)) { + V1AdmissionReviewUtils.allow(review); + return; + } + + UserInfoAnalysis analysis; + try { + analysis = this.userInfoAnalyzer.analyze(request.getUserInfo()); + } catch (Exception e) { + // Python: get_aipub_user non-404 ApiException → 500 + log.warn("Failed to analyze user info", e); + V1AdmissionReviewUtils.reject(review, 500, + "Failed to get aipub user with following error. " + e.getMessage()); + return; + } + + if (!analysis.isAipubMember()) { + V1AdmissionReviewUtils.allow(review); + return; + } + + if (analysis.getAipubUser().isEmpty()) { + V1AdmissionReviewUtils.reject(review, 400, + "Not found aipub user: " + analysis.getUsername()); + return; + } + + // v2 테스트용: ownerReference를 annotation JSON으로 기록하여 Python 원본과 비교. + // 정식 전환 시 이 블록을 ownerReference 패치 방식으로 복원할 것. + V1alpha1AipubUser aipubUser = analysis.getAipubUser().get(); + V1OwnerReference ownerRef = K8sObjectUtils.buildV1OwnerReference(aipubUser, false, false); + String ownerRefJson = this.mapper.valueToTree(ownerRef).toString(); + + JsonNode objectNode = request.getObject(); + JsonNode existingAnnotations = objectNode.path("metadata").path("annotations"); + + JsonPatchBuilder jsonPatchBuilder = new JsonPatchBuilder(); + + if (!existingAnnotations.isObject()) { + JsonPatchOperation initAnnotationsOp = new JsonPatchOperationBuilder() + .add() + .setPath("/metadata/annotations") + .setValue(this.mapper.createObjectNode()) + .build(); + jsonPatchBuilder.addToOperations(initAnnotationsOp); + } + + String annotationPath = "/metadata/annotations/" + + OWNER_REF_ANNOTATION_KEY_V2.replace("/", "~1"); + JsonPatchOperation patchOp = new JsonPatchOperationBuilder() + .add() + .setPath(annotationPath) + .setValue(this.mapper.getNodeFactory().textNode(ownerRefJson)) + .build(); + jsonPatchBuilder.addToOperations(patchOp); + + V1AdmissionReviewUtils.allow(review, jsonPatchBuilder.build()); + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2619234f..da970721 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -29,3 +29,6 @@ app: username: "testuser" password: "asdasdasd" reserved-namespace: "aipub, project-controller" + add-owner-except-gvk-list: + - "aipub.ten1010.io/v1alpha1/Commit" + - "aipub.ten1010.io/v1/Commit" diff --git a/src/test/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandlerTest.java b/src/test/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandlerTest.java new file mode 100644 index 00000000..61e692bd --- /dev/null +++ b/src/test/java/io/ten1010/aipub/projectcontroller/mutating/service/UserLabelReviewHandlerTest.java @@ -0,0 +1,167 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.ten1010.aipub.projectcontroller.domain.k8s.ObjectMapperFactory; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUserSpec; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReviewRequest; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1Kind; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1UserInfo; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class UserLabelReviewHandlerTest { + + private UserLabelReviewHandler handler; + private UserInfoAnalyzer mockAnalyzer; + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + this.mockAnalyzer = mock(UserInfoAnalyzer.class); + ApiResourceDiscovery mockDiscovery = mock(ApiResourceDiscovery.class); + ApiClient mockApiClient = mock(ApiClient.class); + this.handler = new UserLabelReviewHandler(this.mockAnalyzer, mockDiscovery, mockApiClient); + this.mapper = new ObjectMapperFactory().createObjectMapper(); + } + + private V1AdmissionReview createReview(String operation, String namespace) { + V1Kind v1Kind = new V1Kind(); + v1Kind.setGroup("apps"); + v1Kind.setVersion("v1"); + v1Kind.setKind("Deployment"); + + V1UserInfo userInfo = new V1UserInfo(); + userInfo.setUsername("oidc:testuser"); + userInfo.setGroups(List.of("oidc:aipub-member", "system:authenticated")); + + ObjectNode objNode = this.mapper.createObjectNode(); + objNode.putObject("metadata"); + + V1AdmissionReviewRequest request = new V1AdmissionReviewRequest(); + request.setUid("test-uid"); + request.setOperation(operation); + request.setNamespace(namespace); + request.setKind(v1Kind); + request.setUserInfo(userInfo); + request.setObject(objNode); + + V1AdmissionReview review = new V1AdmissionReview(); + review.setApiVersion("admission.k8s.io/v1"); + review.setKind("AdmissionReview"); + review.setRequest(request); + + return review; + } + + private V1alpha1AipubUser createAipubUser(String name, String uid, String userId) { + V1alpha1AipubUser user = new V1alpha1AipubUser(); + user.setApiVersion("project.aipub.ten1010.io/v1alpha1"); + user.setKind("AipubUser"); + V1ObjectMeta meta = new V1ObjectMeta(); + meta.setName(name); + meta.setUid(uid); + user.setMetadata(meta); + V1alpha1AipubUserSpec spec = new V1alpha1AipubUserSpec(); + spec.setId(userId); + user.setSpec(spec); + return user; + } + + @Test + void canHandle_createNamespaced_returnsTrue() { + V1AdmissionReview review = createReview("CREATE", "default"); + assertThat(this.handler.canHandle(review)).isTrue(); + } + + @Test + void canHandle_updateOperation_returnsFalse() { + V1AdmissionReview review = createReview("UPDATE", "default"); + assertThat(this.handler.canHandle(review)).isFalse(); + } + + @Test + void canHandle_noNamespace_returnsFalse() { + V1AdmissionReview review = createReview("CREATE", null); + assertThat(this.handler.canHandle(review)).isFalse(); + } + + @Test + void handle_memberUser_addsLabels() { + V1AdmissionReview review = createReview("CREATE", "default"); + + V1alpha1AipubUser aipubUser = createAipubUser("testuser", "uid-123", "user-id-456"); + UserInfoAnalysis analysis = new UserInfoAnalysis( + "oidc:testuser", + List.of("oidc:aipub-member", "system:authenticated"), + aipubUser); + when(this.mockAnalyzer.analyze(any())).thenReturn(analysis); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isTrue(); + assertThat(review.getResponse().getPatch()).isNotNull(); + assertThat(review.getResponse().getPatchType()).isEqualTo("JSONPatch"); + } + + @Test + void handle_memberUserNoUserId_rejects() { + V1AdmissionReview review = createReview("CREATE", "default"); + + V1alpha1AipubUser aipubUser = createAipubUser("testuser", "uid-123", null); + aipubUser.getSpec().setId(null); + UserInfoAnalysis analysis = new UserInfoAnalysis( + "oidc:testuser", + List.of("oidc:aipub-member", "system:authenticated"), + aipubUser); + when(this.mockAnalyzer.analyze(any())).thenReturn(analysis); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isFalse(); + assertThat(review.getResponse().getStatus().getCode()).isEqualTo(500); + } + + @Test + void handle_nonMemberNoOwner_allowsWithoutPatch() { + V1AdmissionReview review = createReview("CREATE", "default"); + + UserInfoAnalysis analysis = new UserInfoAnalysis( + "system:serviceaccount:kube-system:replicaset-controller", + List.of("system:serviceaccounts", "system:authenticated"), + null); + when(this.mockAnalyzer.analyze(any())).thenReturn(analysis); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isTrue(); + assertThat(review.getResponse().getPatch()).isNull(); + } + + @Test + void handle_analyzerThrows_rejects() { + V1AdmissionReview review = createReview("CREATE", "default"); + + when(this.mockAnalyzer.analyze(any())).thenThrow(new RuntimeException("test error")); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isFalse(); + assertThat(review.getResponse().getStatus().getCode()).isEqualTo(500); + } + +} diff --git a/src/test/java/io/ten1010/aipub/projectcontroller/mutating/service/UserOwnerReviewHandlerTest.java b/src/test/java/io/ten1010/aipub/projectcontroller/mutating/service/UserOwnerReviewHandlerTest.java new file mode 100644 index 00000000..a5b0d822 --- /dev/null +++ b/src/test/java/io/ten1010/aipub/projectcontroller/mutating/service/UserOwnerReviewHandlerTest.java @@ -0,0 +1,171 @@ +package io.ten1010.aipub.projectcontroller.mutating.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.ten1010.aipub.projectcontroller.domain.k8s.ObjectMapperFactory; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1AipubUser; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReview; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1AdmissionReviewRequest; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1Kind; +import io.ten1010.aipub.projectcontroller.mutating.dto.V1UserInfo; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class UserOwnerReviewHandlerTest { + + private UserOwnerReviewHandler handler; + private UserInfoAnalyzer mockAnalyzer; + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + this.mockAnalyzer = mock(UserInfoAnalyzer.class); + this.handler = new UserOwnerReviewHandler( + this.mockAnalyzer, + Set.of("aipub.ten1010.io/v1alpha1/Commit")); + this.mapper = new ObjectMapperFactory().createObjectMapper(); + } + + private V1AdmissionReview createReview(String operation, String namespace, String group, + String version, String kind) { + V1Kind v1Kind = new V1Kind(); + v1Kind.setGroup(group); + v1Kind.setVersion(version); + v1Kind.setKind(kind); + + V1UserInfo userInfo = new V1UserInfo(); + userInfo.setUsername("oidc:testuser"); + userInfo.setGroups(List.of("oidc:aipub-member", "system:authenticated")); + + V1AdmissionReviewRequest request = new V1AdmissionReviewRequest(); + request.setUid("test-uid"); + request.setOperation(operation); + request.setNamespace(namespace); + request.setKind(v1Kind); + request.setUserInfo(userInfo); + request.setObject(this.mapper.createObjectNode() + .putObject("metadata") + .putObject("labels").objectNode()); + + JsonNode objNode = this.mapper.createObjectNode(); + ((com.fasterxml.jackson.databind.node.ObjectNode) objNode) + .putObject("metadata"); + request.setObject(objNode); + + V1AdmissionReview review = new V1AdmissionReview(); + review.setApiVersion("admission.k8s.io/v1"); + review.setKind("AdmissionReview"); + review.setRequest(request); + + return review; + } + + private V1alpha1AipubUser createAipubUser(String name, String uid) { + V1alpha1AipubUser user = new V1alpha1AipubUser(); + user.setApiVersion("project.aipub.ten1010.io/v1alpha1"); + user.setKind("AipubUser"); + V1ObjectMeta meta = new V1ObjectMeta(); + meta.setName(name); + meta.setUid(uid); + user.setMetadata(meta); + return user; + } + + @Test + void canHandle_createNamespaced_returnsTrue() { + V1AdmissionReview review = createReview("CREATE", "default", "apps", "v1", "Deployment"); + assertThat(this.handler.canHandle(review)).isTrue(); + } + + @Test + void canHandle_updateOperation_returnsFalse() { + V1AdmissionReview review = createReview("UPDATE", "default", "apps", "v1", "Deployment"); + assertThat(this.handler.canHandle(review)).isFalse(); + } + + @Test + void canHandle_deleteOperation_returnsFalse() { + V1AdmissionReview review = createReview("DELETE", "default", "apps", "v1", "Deployment"); + assertThat(this.handler.canHandle(review)).isFalse(); + } + + @Test + void canHandle_noNamespace_returnsFalse() { + V1AdmissionReview review = createReview("CREATE", null, "", "v1", "Namespace"); + assertThat(this.handler.canHandle(review)).isFalse(); + } + + @Test + void canHandle_emptyNamespace_returnsFalse() { + V1AdmissionReview review = createReview("CREATE", "", "", "v1", "Namespace"); + assertThat(this.handler.canHandle(review)).isFalse(); + } + + @Test + void handle_exceptedGvk_allowsWithoutPatch() { + V1AdmissionReview review = createReview( + "CREATE", "default", "aipub.ten1010.io", "v1alpha1", "Commit"); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isTrue(); + assertThat(review.getResponse().getPatch()).isNull(); + } + + @Test + void handle_nonMemberUser_allowsWithoutPatch() { + V1AdmissionReview review = createReview("CREATE", "default", "apps", "v1", "Deployment"); + + UserInfoAnalysis analysis = new UserInfoAnalysis( + "testuser", List.of("system:authenticated"), null); + when(this.mockAnalyzer.analyze(any())).thenReturn(analysis); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isTrue(); + assertThat(review.getResponse().getPatch()).isNull(); + } + + @Test + void handle_memberUser_addsOwnerReference() { + V1AdmissionReview review = createReview("CREATE", "default", "apps", "v1", "Deployment"); + + V1alpha1AipubUser aipubUser = createAipubUser("testuser", "user-uid-123"); + UserInfoAnalysis analysis = new UserInfoAnalysis( + "oidc:testuser", + List.of("oidc:aipub-member", "system:authenticated"), + aipubUser); + when(this.mockAnalyzer.analyze(any())).thenReturn(analysis); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isTrue(); + assertThat(review.getResponse().getPatch()).isNotNull(); + assertThat(review.getResponse().getPatchType()).isEqualTo("JSONPatch"); + } + + @Test + void handle_analyzerThrows_rejectsWithServerError() { + V1AdmissionReview review = createReview("CREATE", "default", "apps", "v1", "Deployment"); + + when(this.mockAnalyzer.analyze(any())).thenThrow(new RuntimeException("test error")); + + this.handler.handle(review); + + assertThat(review.getResponse()).isNotNull(); + assertThat(review.getResponse().getAllowed()).isFalse(); + assertThat(review.getResponse().getStatus().getCode()).isEqualTo(500); + } + +}