From fe84b5bfff502707d5a87cd0cb03b455343c56cd Mon Sep 17 00:00:00 2001 From: "daekwon.park" Date: Wed, 29 Jul 2026 19:10:47 +0900 Subject: [PATCH] fix(imagehub): stop rotating image registry robot secrets on every reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageRegistrySecretReconciler called AipubDockerConfigJsonResolver's refreshSecret() unconditionally every time it reconciled a project's .dockerconfigjson Secret — including on every project-controller restart (informer resync re-lists all Projects) and any unrelated change to spec.binding.imageHubs (e.g. adding a second, unrelated ImageHub). Harbor robot permissions are updated independently of the robot's secret, so this rotation was never actually necessary once a working Secret already existed; it only invalidated whatever was previously working. Confirmed live on cluster12: adding a second ImageHub binding to an already-connected project immediately broke docker login with the previously-valid robot password, even with zero restarts/crashes. Fix: track which robot's secret is currently reflected in the K8s Secret via a new project.aipub.ten1010.io/image-registry-robot-id annotation. Only call refreshSecret() when the project's current robot id differs from what's recorded (i.e. the robot was actually recreated) or when the Secret doesn't exist yet. Otherwise reuse the existing Secret data as-is. Note: pre-existing Secrets created before this change won't have the annotation yet, so they'll get one extra (harmless) rotation the first time they're reconciled after this fix ships, after which they settle into the new behavior permanently. Co-Authored-By: Claude Sonnet 5 --- .../ImageRegistrySecretReconciler.java | 53 +++++++++++++++---- .../AipubDockerConfigJsonResolver.java | 7 +++ .../k8s/DefaultDockerConfigJsonResolver.java | 6 +++ .../domain/k8s/DockerConfigJsonResolver.java | 7 +++ .../domain/k8s/LabelConstants.java | 2 + .../domain/k8s/ReconciliationService.java | 9 ++++ 6 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/namespaced/ImageRegistrySecretReconciler.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/namespaced/ImageRegistrySecretReconciler.java index 77ce4c11..e62dbdbf 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/namespaced/ImageRegistrySecretReconciler.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/namespaced/ImageRegistrySecretReconciler.java @@ -16,10 +16,12 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.ImageRegistrySecretNameResolver; import io.ten1010.aipub.projectcontroller.domain.k8s.K8sApiProvider; import io.ten1010.aipub.projectcontroller.domain.k8s.KeyResolver; +import io.ten1010.aipub.projectcontroller.domain.k8s.LabelConstants; import io.ten1010.aipub.projectcontroller.domain.k8s.NamespaceNameResolver; import io.ten1010.aipub.projectcontroller.domain.k8s.ReconciliationService; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import io.ten1010.aipub.projectcontroller.domain.k8s.util.K8sObjectUtils; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -103,15 +105,36 @@ protected Result reconcileInternal(Request request) throws ApiException { deleteSecret(secretOpt.get()); return new Result(false); } - Map reconciledData; - try { - reconciledData = this.reconciliationService.reconcileImageRegistrySecretData( - projectOpt.get()); - } catch (ImageHubNotConnectedException e) { + + Optional currentRobotIdOpt = this.reconciliationService.resolveImageRegistryRobotId( + projectOpt.get()); + if (currentRobotIdOpt.isEmpty()) { return logImageHubNotConnectedAndRequeue(projName); } + String currentRobotId = currentRobotIdOpt.get(); + String existingRobotId = K8sObjectUtils.getAnnotations(secretOpt.get()) + .get(LabelConstants.IMAGE_REGISTRY_ROBOT_ID_KEY); + + Map reconciledData; + if (currentRobotId.equals(existingRobotId)) { + // robot 이 그대로면 비밀번호도 여전히 유효하다 — refreshSecret 을 또 부를 필요 없이 + // 기존 Secret 데이터를 그대로 재사용한다. permission(bindingImageHubs) 변경은 robot + // 을 재생성하지 않으므로, 이 비교만으로 "재발급이 필요한가"를 판단할 수 있다. + reconciledData = secretOpt.get().getData(); + } else { + try { + reconciledData = this.reconciliationService.reconcileImageRegistrySecretData( + projectOpt.get()); + } catch (ImageHubNotConnectedException e) { + return logImageHubNotConnectedAndRequeue(projName); + } + } + Map reconciledAnnotations = new HashMap<>( + K8sObjectUtils.getAnnotations(secretOpt.get())); + reconciledAnnotations.put(LabelConstants.IMAGE_REGISTRY_ROBOT_ID_KEY, currentRobotId); + return reconcileExistingSecret(secretOpt.get(), reconciledReferences, reconciledType, - reconciledData, reconciledLabels); + reconciledData, reconciledLabels, reconciledAnnotations); } if (!K8sObjectUtils.isTerminating(projectOpt.get())) { @@ -124,8 +147,13 @@ protected Result reconcileInternal(Request request) throws ApiException { } Map reconciledLabels = this.reconciliationService.reconcileSecretLabels( namespaceOpt.get(), projectOpt.get()); + Map reconciledAnnotations = this.reconciliationService + .resolveImageRegistryRobotId(projectOpt.get()) + .map(id -> Map.of(LabelConstants.IMAGE_REGISTRY_ROBOT_ID_KEY, id)) + .orElse(Map.of()); return reconcileNoExistingSecret(request.getNamespace(), request.getName(), - reconciledReferences, reconciledType, reconciledData, reconciledLabels); + reconciledReferences, reconciledType, reconciledData, reconciledLabels, + reconciledAnnotations); } return new Result(false); @@ -143,13 +171,15 @@ private Result reconcileNoExistingSecret( List reconciledReferences, String reconciledType, Map reconciledData, - Map reconciledLabels) throws ApiException { + Map reconciledLabels, + Map reconciledAnnotations) throws ApiException { V1Secret secret = new V1SecretBuilder() .withNewMetadata() .withNamespace(namespace) .withName(objName) .withOwnerReferences(reconciledReferences) .withLabels(reconciledLabels) + .withAnnotations(reconciledAnnotations) .endMetadata() .withType(reconciledType) .withData(reconciledData) @@ -164,17 +194,20 @@ private Result reconcileExistingSecret( List reconciledReferences, String reconciledType, Map reconciledData, - Map reconciledLabels) throws ApiException { + Map reconciledLabels, + Map reconciledAnnotations) throws ApiException { if (Set.copyOf(K8sObjectUtils.getOwnerReferences(existing)) .equals(Set.copyOf(reconciledReferences)) && Objects.equals(existing.getType(), reconciledType) && - Objects.equals(existing.getData(), reconciledData)) { + Objects.equals(existing.getData(), reconciledData) && + Objects.equals(K8sObjectUtils.getAnnotations(existing), reconciledAnnotations)) { return new Result(false); } V1Secret edited = new V1SecretBuilder(existing) .editMetadata() .withOwnerReferences(reconciledReferences) .withLabels(reconciledLabels) + .withAnnotations(reconciledAnnotations) .endMetadata() .withType(reconciledType) .withData(reconciledData) diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolver.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolver.java index 90998d7c..64399586 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolver.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolver.java @@ -65,6 +65,13 @@ public Map resolve(V1alpha1Project project) { return json; } + @Override + public Optional resolveImageRegistryRobotId(V1alpha1Project project) { + String username = this.imageRegistryRobotUsernameResolver.resolve( + K8sObjectUtils.getName(project)); + return findByUsername(username).map(ImageRegistryRobot::getId); + } + private String getPassword(ImageRegistryRobot robot) { Objects.requireNonNull(robot.getId()); ImageRegistryRobotSecret secret = this.imageRegistryRobotService.refreshSecret(robot.getId()); diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DefaultDockerConfigJsonResolver.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DefaultDockerConfigJsonResolver.java index 8622c6f0..3ad2841d 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DefaultDockerConfigJsonResolver.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DefaultDockerConfigJsonResolver.java @@ -2,6 +2,7 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import java.util.Map; +import java.util.Optional; public class DefaultDockerConfigJsonResolver implements DockerConfigJsonResolver { @@ -10,4 +11,9 @@ public Map resolve(V1alpha1Project project) { return Map.of(); } + @Override + public Optional resolveImageRegistryRobotId(V1alpha1Project project) { + return Optional.empty(); + } + } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DockerConfigJsonResolver.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DockerConfigJsonResolver.java index 730c6422..85a4b44f 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DockerConfigJsonResolver.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/k8s/DockerConfigJsonResolver.java @@ -2,9 +2,16 @@ import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; import java.util.Map; +import java.util.Optional; public interface DockerConfigJsonResolver { Map resolve(V1alpha1Project project); + /** + * project 에 연결된 image registry robot 의 id 를 조회한다. secret 을 새로 발급받지 않고 + * "지금 이 프로젝트의 robot 이 마지막으로 발급했던 robot 과 같은가"만 값싸게 확인하기 위한 용도. + */ + Optional resolveImageRegistryRobotId(V1alpha1Project project); + } 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 cde63ae4..c956b398 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 @@ -16,6 +16,8 @@ public final class LabelConstants { ProjectApiConstants.AIPUB_GROUP + "/" + "workload-name"; public static final String WORKLOAD_KIND_KEY = ProjectApiConstants.AIPUB_GROUP + "/" + "workload-kind"; + public static final String IMAGE_REGISTRY_ROBOT_ID_KEY = + ProjectApiConstants.PROJECT_GROUP + "/" + "image-registry-robot-id"; private LabelConstants() { } 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 aae21b57..d9274a3e 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 @@ -914,6 +914,15 @@ public Map reconcileImageRegistrySecretData(V1alpha1Project proj } } + /** + * project 에 연결된 image registry robot 의 id 를 조회한다(secret 재발급 없이). 이미 반영된 + * Secret 의 {@link LabelConstants#IMAGE_REGISTRY_ROBOT_ID_KEY} annotation 과 비교해서, robot + * 이 그대로인지(=비밀번호도 그대로 유효한지) 판단하는 용도로 쓰인다. + */ + public Optional resolveImageRegistryRobotId(V1alpha1Project project) { + return this.dockerConfigJsonResolver.resolveImageRegistryRobotId(project); + } + public Map reconcileNodeLabels(V1Node existing) { Map existingLabels = K8sObjectUtils.getLabels(existing); Map reconciled = new HashMap<>(existingLabels);