From 3f294876f6f113d756b48e54d0645ed980d7ee4a Mon Sep 17 00:00:00 2001 From: daekwonpark Date: Wed, 29 Jul 2026 22:16:44 +0900 Subject: [PATCH 1/2] fix(imagehub): stop re-creating an existing image registry robot after updating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageRegistryRobotReconciler.reconcileInternal fell through from the "robot already exists" branch into the creation branch. After successfully updating an existing robot's permissions, it immediately tried to create another robot with the same username. Harbor rejects the duplicate, so the reconcile threw, requeued, and only settled on the next pass (where the permissions now matched and the early return kicked in). Net effect: every permission change on an existing robot — i.e. every edit to a project's spec.binding.imageHubs — logged a spurious failure and burned a requeue cycle. It self-healed, which is why it went unnoticed. A robot is one-per-username, so an update completes the reconcile. Return right after it instead of falling through. Co-Authored-By: Claude Opus 5 (1M context) --- .../controller/ImageRegistryRobotReconciler.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java index 9cbbf5a6..ee93d6eb 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java @@ -119,6 +119,9 @@ protected Result reconcileInternal(Request request) throws ApiException { } throw e; } + // robot 은 username 당 하나다. 이미 있는 robot 을 갱신했으면 이번 reconcile 은 여기서 끝난다. + // 아래 생성 분기로 흘러가면 같은 username 으로 robot 을 또 만들려 해서 Harbor 가 거부한다. + return new Result(false); } if (!reconciledPermissions.isEmpty()) { From 876c1cbf110337c1d5fb503ddb9866dd904fe06c Mon Sep 17 00:00:00 2001 From: daekwonpark Date: Wed, 29 Jul 2026 22:17:01 +0900 Subject: [PATCH 2/2] feat(imagehub): reuse the create-time robot secret instead of refreshing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIP-2424 aipub-backend now propagates Harbor's plaintext robot secret and robot id all the way to the REST create response (aipub-backend c456b007). Consume it so first-time robot creation stops making a second Harbor round-trip just to obtain a password it was already handed. Previously createImageRegistryRobot was void: the secret Harbor returns at creation was discarded, and ImageRegistrySecretReconciler had to call PUT /imageregistryrobots/{id}/refreshsecret afterwards to get a usable password for the .dockerconfigjson Secret. Two Harbor round-trips for one robot, purely because nothing carried the first one's secret forward. The robot is created by ImageRegistryRobotReconciler but the K8s Secret is written by ImageRegistrySecretReconciler, so there is no call path between them to hand the secret over. ImageRegistryRobotSecretStore bridges the gap: the create path deposits the secret keyed by robot id, and AipubDockerConfigJsonResolver.getPassword takes it from there, falling back to refreshsecret when it comes up empty. This is purely an optimization — every path stays correct with an empty store. A restart between create and Secret reconciliation just means the old two-round-trip behavior for that project. The store deliberately does not hold secrets for long: - take() consumes the entry; a create-time secret is used at most once. - entries expire after 5 minutes. An out-of-band refreshsecret (web UI, direct API) silently invalidates what we hold, and writing a stale password into the Secret is exactly the failure mode fe84b5b fixed. Note AipubConfiguration builds ImageRegistryRobotServiceImpl twice, once per consumer, so the store must be a shared singleton bean — putting it inside the service would split it across two instances and the handoff would never connect. Co-Authored-By: Claude Opus 5 (1M context) --- .../configuration/AipubConfiguration.java | 23 ++- .../ImageRegistryRobotControllerFactory.java | 6 +- .../ImageRegistryRobotReconciler.java | 24 +++- .../AipubDockerConfigJsonResolver.java | 20 ++- .../ImageRegistryRobotSecretStore.java | 79 +++++++++++ .../ImageRegistryRobotService.java | 11 +- .../dto/ImageRegistryRobotCreated.java | 20 +++ .../impl/ImageRegistryRobotServiceImpl.java | 10 +- .../AipubDockerConfigJsonResolverTest.java | 119 ++++++++++++++++ .../ImageRegistryRobotSecretStoreTest.java | 134 ++++++++++++++++++ 10 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStore.java create mode 100644 src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/dto/ImageRegistryRobotCreated.java create mode 100644 src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolverTest.java create mode 100644 src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStoreTest.java diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubConfiguration.java b/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubConfiguration.java index b1e51b94..14c208df 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubConfiguration.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/configuration/AipubConfiguration.java @@ -7,6 +7,7 @@ import io.ten1010.aipub.projectcontroller.domain.aipubbackend.AipubSubjectResolver; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ArtifactService; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageHubService; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotSecretStore; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotService; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotUsernameResolver; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.RepositoryService; @@ -75,15 +76,28 @@ public SubjectResolver subjectResolver() { return new DefaultSubjectResolver(); } + /** + * robot 생성 응답의 평문 secret 을 {@code ImageRegistryRobotReconciler}(생성 측)에서 + * {@link AipubDockerConfigJsonResolver}(K8s Secret 반영 측)로 넘기기 위한 저장소. + * + *

두 reconciler 가 같은 인스턴스를 봐야 전달이 성립하므로 반드시 단일 빈으로 공유한다. + */ @Bean - public DockerConfigJsonResolver dockerConfigJsonResolver() { + public ImageRegistryRobotSecretStore imageRegistryRobotSecretStore() { + return new ImageRegistryRobotSecretStore(); + } + + @Bean + public DockerConfigJsonResolver dockerConfigJsonResolver( + ImageRegistryRobotSecretStore imageRegistryRobotSecretStore) { if (this.aipubEnabled) { Objects.requireNonNull(this.aipubBackendClient); Objects.requireNonNull(this.harborExternalUrl); ImageRegistryRobotService robotService = new ImageRegistryRobotServiceImpl( this.aipubBackendClient); ImageRegistryRobotUsernameResolver usernameResolver = new ImageRegistryRobotUsernameResolverImpl(); - return new AipubDockerConfigJsonResolver(this.harborExternalUrl, robotService, usernameResolver); + return new AipubDockerConfigJsonResolver(this.harborExternalUrl, robotService, usernameResolver, + imageRegistryRobotSecretStore); } return new DefaultDockerConfigJsonResolver(); } @@ -116,14 +130,15 @@ public ArtifactService artifactService() { } @Bean - public Controller imageRegistryRobotController(SharedInformerFactory sharedInformerFactory) { + public Controller imageRegistryRobotController(SharedInformerFactory sharedInformerFactory, + ImageRegistryRobotSecretStore imageRegistryRobotSecretStore) { if (this.aipubEnabled) { Objects.requireNonNull(this.aipubBackendClient); ImageRegistryRobotService robotService = new ImageRegistryRobotServiceImpl( this.aipubBackendClient); ImageRegistryRobotUsernameResolver usernameResolver = new ImageRegistryRobotUsernameResolverImpl(); return new ImageRegistryRobotControllerFactory(robotService, usernameResolver, - sharedInformerFactory) + imageRegistryRobotSecretStore, sharedInformerFactory) .createController(); } return new Controller() { diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotControllerFactory.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotControllerFactory.java index 008680f1..67bc6f4d 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotControllerFactory.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotControllerFactory.java @@ -9,6 +9,7 @@ import io.ten1010.aipub.projectcontroller.controller.watch.DefaultControllerWatch; import io.ten1010.aipub.projectcontroller.controller.watch.OnUpdateFilterFactory; import io.ten1010.aipub.projectcontroller.controller.watch.RequestBuilderFactory; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotSecretStore; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotService; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotUsernameResolver; import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1ImageHub; @@ -18,6 +19,7 @@ public class ImageRegistryRobotControllerFactory implements ControllerFactory { private final ImageRegistryRobotService robotService; private final ImageRegistryRobotUsernameResolver usernameResolver; + private final ImageRegistryRobotSecretStore secretStore; private final SharedInformerFactory sharedInformerFactory; private final OnUpdateFilterFactory onUpdateFilterFactory; private final RequestBuilderFactory requestBuilderFactory; @@ -25,9 +27,11 @@ public class ImageRegistryRobotControllerFactory implements ControllerFactory { public ImageRegistryRobotControllerFactory( ImageRegistryRobotService robotService, ImageRegistryRobotUsernameResolver usernameResolver, + ImageRegistryRobotSecretStore secretStore, SharedInformerFactory sharedInformerFactory) { this.robotService = robotService; this.usernameResolver = usernameResolver; + this.secretStore = secretStore; this.sharedInformerFactory = sharedInformerFactory; this.onUpdateFilterFactory = new OnUpdateFilterFactory(); this.requestBuilderFactory = new RequestBuilderFactory(sharedInformerFactory); @@ -45,7 +49,7 @@ public Controller createController() { .watch(this::createProjectWatch) .watch(this::createImageHubWatch) .withReconciler(new ImageRegistryRobotReconciler(this.robotService, this.usernameResolver, - this.sharedInformerFactory)) + this.secretStore, this.sharedInformerFactory)) .build(); } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java b/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java index ee93d6eb..1109059c 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/controller/ImageRegistryRobotReconciler.java @@ -5,10 +5,12 @@ import io.kubernetes.client.informer.SharedInformerFactory; import io.kubernetes.client.informer.cache.Indexer; import io.kubernetes.client.openapi.ApiException; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotSecretStore; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotService; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotUsernameResolver; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryAccess; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobot; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotCreated; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotListOptions; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotPermission; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.impl.AipubBackendResponseException; @@ -42,15 +44,18 @@ public class ImageRegistryRobotReconciler extends AbstractReconciler { private final ImageRegistryRobotService robotService; private final ImageRegistryRobotUsernameResolver usernameResolver; + private final ImageRegistryRobotSecretStore secretStore; private final Indexer projectIndexer; private final Indexer imageHubIndexer; private final KeyResolver keyResolver; public ImageRegistryRobotReconciler( ImageRegistryRobotService robotService, ImageRegistryRobotUsernameResolver usernameResolver, + ImageRegistryRobotSecretStore secretStore, SharedInformerFactory sharedInformerFactory) { this.robotService = robotService; this.usernameResolver = usernameResolver; + this.secretStore = secretStore; this.projectIndexer = sharedInformerFactory .getExistingSharedIndexInformer(V1alpha1Project.class) .getIndexer(); @@ -129,7 +134,8 @@ protected Result reconcileInternal(Request request) throws ApiException { newRobot.setUsername(username); newRobot.setPermissions(reconciledPermissions); try { - this.robotService.createImageRegistryRobot(newRobot); + this.robotService.createImageRegistryRobot(newRobot) + .ifPresent(created -> storeCreatedSecret(username, created)); } catch (AipubBackendResponseException e) { if (isImageHubNotFound(e)) { return logImageHubNotFoundAndRequeue(e, request.getName(), boundImageHubs); @@ -142,6 +148,22 @@ protected Result reconcileInternal(Request request) throws ApiException { return new Result(false); } + /** + * 생성 응답의 평문 secret 을 {@link ImageRegistryRobotSecretStore} 에 넘긴다. 곧 이어 도는 + * {@code ImageRegistrySecretReconciler} 가 이 값을 꺼내 쓰면 refreshsecret 왕복을 한 번 아낄 수 있다. + * + *

백엔드가 secret 이나 robotId 를 주지 않으면(예: Harbor 응답 파싱 실패) 그냥 넘긴다. 넘기지 못해도 + * 동작에는 문제가 없고, 예전처럼 refreshsecret 으로 발급받게 된다. + */ + private void storeCreatedSecret(String username, ImageRegistryRobotCreated created) { + if (created.getRobotId() == null || created.getSecret() == null) { + log.debug("robot 생성 응답에 robotId·secret 이 없어 create 시점 secret 전달을 건너뜀 " + + "[username={}]. image registry secret 은 refreshsecret 으로 발급된다.", username); + return; + } + this.secretStore.put(created.getRobotId(), created.getSecret()); + } + private Optional findByUsername(String username) { ImageRegistryRobotListOptions options = new ImageRegistryRobotListOptions(); options.setPageOffset(0); 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 64399586..456f04ba 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 @@ -20,15 +20,18 @@ public class AipubDockerConfigJsonResolver implements DockerConfigJsonResolver { private final String registryDomain; private final ImageRegistryRobotService imageRegistryRobotService; private final ImageRegistryRobotUsernameResolver imageRegistryRobotUsernameResolver; + private final ImageRegistryRobotSecretStore secretStore; public AipubDockerConfigJsonResolver( String harborExternalUrl, ImageRegistryRobotService imageRegistryRobotService, - ImageRegistryRobotUsernameResolver imageRegistryRobotUsernameResolver) { + ImageRegistryRobotUsernameResolver imageRegistryRobotUsernameResolver, + ImageRegistryRobotSecretStore secretStore) { Objects.requireNonNull(harborExternalUrl); this.registryDomain = removeHttpProtocolPrefix(harborExternalUrl); this.imageRegistryRobotService = imageRegistryRobotService; this.imageRegistryRobotUsernameResolver = imageRegistryRobotUsernameResolver; + this.secretStore = Objects.requireNonNull(secretStore); } private static String removeHttpProtocolPrefix(String input) { @@ -72,9 +75,20 @@ public Optional resolveImageRegistryRobotId(V1alpha1Project project) { return findByUsername(username).map(ImageRegistryRobot::getId); } + /** + * robot 의 평문 비밀번호를 구한다. + * + *

robot 이 방금 생성된 경우엔 생성 응답의 secret 이 {@link ImageRegistryRobotSecretStore} 에 들어 + * 있으므로 그것을 쓴다. 없으면 refreshsecret 으로 새로 발급받는다. 재발급은 기존 비밀번호를 무효화하므로, + * 이미 쓸 수 있는 secret 이 있을 때 굳이 부르지 않는 편이 낫다. + */ private String getPassword(ImageRegistryRobot robot) { - Objects.requireNonNull(robot.getId()); - ImageRegistryRobotSecret secret = this.imageRegistryRobotService.refreshSecret(robot.getId()); + String robotId = Objects.requireNonNull(robot.getId()); + Optional storedSecret = this.secretStore.take(robotId); + if (storedSecret.isPresent()) { + return storedSecret.get(); + } + ImageRegistryRobotSecret secret = this.imageRegistryRobotService.refreshSecret(robotId); Objects.requireNonNull(secret.getSecret()); return secret.getSecret(); } diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStore.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStore.java new file mode 100644 index 00000000..9ac50702 --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStore.java @@ -0,0 +1,79 @@ +package io.ten1010.aipub.projectcontroller.domain.aipubbackend; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * robot 생성 응답에만 담겨 오는 평문 secret 을, 생성 직후 K8s Secret 에 반영될 때까지 잠시 들고 있는 저장소. + * + *

robot 을 만드는 주체({@code ImageRegistryRobotReconciler})와 그 secret 을 K8s Secret 에 쓰는 주체 + * ({@code ImageRegistrySecretReconciler} → {@link AipubDockerConfigJsonResolver})가 서로 다른 + * reconciler 라서, 생성 응답의 secret 을 그대로 넘겨줄 호출 경로가 없다. 이 저장소가 그 사이를 잇는다. + * 덕분에 robot 최초 생성 시 Harbor 왕복이 2회(create → refreshsecret)에서 1회로 줄어든다. + * + *

순수 최적화이며 저장소가 비어 있어도 동작은 정상이다. {@link #take} 가 비면 호출자는 기존대로 + * refreshsecret 으로 되돌아간다. 컨트롤러가 create 와 Secret 반영 사이에 재시작하면 저장소는 비고, 그때는 + * 예전과 같은 2회 왕복으로 처리된다. + * + *

보관 정책이 두 가지다. 첫째, {@link #take} 는 꺼내면서 지운다(consume-once). 같은 secret 을 두 번 + * 쓸 일이 없고, 남겨둘 이유도 없다. 둘째, {@link #DEFAULT_TTL} 이 지난 항목은 버린다. 외부(웹 UI 등)에서 + * refreshsecret 이 호출되면 여기 든 secret 은 조용히 무효가 되므로, 오래된 값을 K8s Secret 에 쓰는 것보다 + * refreshsecret 으로 새로 받는 편이 안전하다. + */ +public class ImageRegistryRobotSecretStore { + + private static final Duration DEFAULT_TTL = Duration.ofMinutes(5); + + private final Map entries; + private final Duration ttl; + private final Clock clock; + + public ImageRegistryRobotSecretStore() { + this(DEFAULT_TTL, Clock.systemUTC()); + } + + public ImageRegistryRobotSecretStore(Duration ttl, Clock clock) { + this.entries = new ConcurrentHashMap<>(); + this.ttl = Objects.requireNonNull(ttl); + this.clock = Objects.requireNonNull(clock); + } + + /** + * robot 생성 응답으로 받은 secret 을 보관한다. 같은 robotId 에 대한 기존 값은 덮어쓴다. + */ + public void put(String robotId, String secret) { + Objects.requireNonNull(robotId); + Objects.requireNonNull(secret); + purgeExpired(); + this.entries.put(robotId, new Entry(secret, this.clock.instant())); + } + + /** + * 보관된 secret 을 꺼내면서 지운다. 없거나 TTL 이 지났으면 빈 값을 준다. + */ + public Optional take(String robotId) { + Objects.requireNonNull(robotId); + Entry entry = this.entries.remove(robotId); + if (entry == null || isExpired(entry)) { + return Optional.empty(); + } + return Optional.of(entry.secret()); + } + + private void purgeExpired() { + this.entries.values().removeIf(this::isExpired); + } + + private boolean isExpired(Entry entry) { + return Duration.between(entry.storedAt(), this.clock.instant()).compareTo(this.ttl) > 0; + } + + private record Entry(String secret, Instant storedAt) { + } + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotService.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotService.java index b1ee3fed..fa36fce4 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotService.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotService.java @@ -1,13 +1,22 @@ package io.ten1010.aipub.projectcontroller.domain.aipubbackend; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobot; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotCreated; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotListOptions; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotSecret; import java.util.List; +import java.util.Optional; public interface ImageRegistryRobotService { - void createImageRegistryRobot(ImageRegistryRobot imageRegistryRobot); + /** + * robot 을 생성하고 생성 응답을 돌려준다. 응답에는 Harbor 가 생성 시점에만 반환하는 평문 secret 이 담겨 + * 있어, {@link #refreshSecret} 를 다시 부르지 않고 그대로 쓸 수 있다. + * + *

백엔드가 본문 없이 응답하면 빈 값이 된다(유효한 permission 이 없어 생성을 건너뛴 경우 등). + */ + Optional createImageRegistryRobot( + ImageRegistryRobot imageRegistryRobot); List listImageRegistryRobots(ImageRegistryRobotListOptions options); diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/dto/ImageRegistryRobotCreated.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/dto/ImageRegistryRobotCreated.java new file mode 100644 index 00000000..9aa037aa --- /dev/null +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/dto/ImageRegistryRobotCreated.java @@ -0,0 +1,20 @@ +package io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto; + +import lombok.Data; +import org.jspecify.annotations.Nullable; + +/** + * robot 생성 응답. Harbor 가 생성 시점에 단 한 번만 반환하는 평문 secret 이 담겨 온다. + * + *

두 필드 모두 nullable 이다. aipub 백엔드는 유효한 permission 이 없어 생성을 건너뛴 경우와 Harbor 응답 + * 본문을 파싱하지 못한 경우 secret·robotId 없이 응답한다. + */ +@Data +public class ImageRegistryRobotCreated { + + @Nullable + private String robotId; + @Nullable + private String secret; + +} diff --git a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/impl/ImageRegistryRobotServiceImpl.java b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/impl/ImageRegistryRobotServiceImpl.java index 77516afc..f3793b4e 100644 --- a/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/impl/ImageRegistryRobotServiceImpl.java +++ b/src/main/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/impl/ImageRegistryRobotServiceImpl.java @@ -3,12 +3,14 @@ import com.google.gson.reflect.TypeToken; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.ImageRegistryRobotService; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobot; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotCreated; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotListOptions; import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotSecret; import io.ten1010.common.apiclient.ApiClient; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import okhttp3.Call; public class ImageRegistryRobotServiceImpl implements ImageRegistryRobotService { @@ -22,6 +24,9 @@ public class ImageRegistryRobotServiceImpl implements ImageRegistryRobotService private static final TypeToken IMAGE_REGISTRY_ROBOT_SECRET_TYPE_TOKEN = new TypeToken<>() { }; + private static final TypeToken IMAGE_REGISTRY_ROBOT_CREATED_TYPE_TOKEN = new TypeToken<>() { + }; + private final ApiClient aipubBackendClient; private final CallHelper callHelper; @@ -31,13 +36,14 @@ public ImageRegistryRobotServiceImpl(ApiClient aipubBackendClient) { } @Override - public void createImageRegistryRobot(ImageRegistryRobot imageRegistryRobot) { + public Optional createImageRegistryRobot( + ImageRegistryRobot imageRegistryRobot) { Call call = this.aipubBackendClient.buildCall( "/imageregistryrobots", "POST", "application/json", imageRegistryRobot); - this.callHelper.executeCall(call); + return this.callHelper.executeCall(call, IMAGE_REGISTRY_ROBOT_CREATED_TYPE_TOKEN); } @Override diff --git a/src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolverTest.java b/src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolverTest.java new file mode 100644 index 00000000..e1d8d955 --- /dev/null +++ b/src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/AipubDockerConfigJsonResolverTest.java @@ -0,0 +1,119 @@ +package io.ten1010.aipub.projectcontroller.domain.aipubbackend; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobot; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotListOptions; +import io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto.ImageRegistryRobotSecret; +import io.ten1010.aipub.projectcontroller.domain.k8s.dto.V1alpha1Project; +import java.util.Base64; +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; + +class AipubDockerConfigJsonResolverTest { + + private static final String HARBOR_URL = "https://harbor.example.com"; + private static final String REGISTRY_DOMAIN = "harbor.example.com"; + private static final String PROJECT_NAME = "proj-1"; + private static final String ROBOT_USERNAME = "robot$proj-1"; + private static final String ROBOT_ID = "42"; + + private ImageRegistryRobotService robotService; + private ImageRegistryRobotSecretStore secretStore; + private AipubDockerConfigJsonResolver resolver; + + private static V1alpha1Project project() { + V1alpha1Project project = new V1alpha1Project(); + project.setMetadata(new V1ObjectMeta().name(PROJECT_NAME)); + return project; + } + + @SuppressWarnings("unchecked") + private static String passwordOf(Map dockerConfigJson) { + Map auths = (Map) dockerConfigJson.get("auths"); + Map registry = (Map) auths.get(REGISTRY_DOMAIN); + return registry.get("password"); + } + + @SuppressWarnings("unchecked") + private static String authOf(Map dockerConfigJson) { + Map auths = (Map) dockerConfigJson.get("auths"); + Map registry = (Map) auths.get(REGISTRY_DOMAIN); + return registry.get("auth"); + } + + @BeforeEach + void setUp() { + this.robotService = mock(ImageRegistryRobotService.class); + this.secretStore = new ImageRegistryRobotSecretStore(); + + ImageRegistryRobot robot = new ImageRegistryRobot(); + robot.setId(ROBOT_ID); + robot.setUsername(ROBOT_USERNAME); + when(this.robotService.listImageRegistryRobots(any(ImageRegistryRobotListOptions.class))) + .thenReturn(List.of(robot)); + + ImageRegistryRobotUsernameResolver usernameResolver = projectName -> ROBOT_USERNAME; + this.resolver = new AipubDockerConfigJsonResolver(HARBOR_URL, this.robotService, + usernameResolver, this.secretStore); + } + + @Test + @DisplayName("생성 시점 secret 이 보관돼 있으면 그것을 쓰고 refreshsecret 을 부르지 않는다") + void resolve_usesStoredSecretWithoutRefreshing() { + this.secretStore.put(ROBOT_ID, "secret-from-create"); + + Map dockerConfigJson = this.resolver.resolve(project()); + + assertThat(passwordOf(dockerConfigJson)).isEqualTo("secret-from-create"); + verify(this.robotService, never()).refreshSecret(anyString()); + } + + @Test + @DisplayName("보관된 secret 이 없으면 refreshsecret 으로 발급받는다") + void resolve_fallsBackToRefreshSecret() { + ImageRegistryRobotSecret refreshed = new ImageRegistryRobotSecret(); + refreshed.setSecret("secret-from-refresh"); + when(this.robotService.refreshSecret(ROBOT_ID)).thenReturn(refreshed); + + Map dockerConfigJson = this.resolver.resolve(project()); + + assertThat(passwordOf(dockerConfigJson)).isEqualTo("secret-from-refresh"); + verify(this.robotService).refreshSecret(ROBOT_ID); + } + + @Test + @DisplayName("보관된 secret 은 한 번 쓰이고 소비된다 - 다음 resolve 는 refreshsecret 을 탄다") + void resolve_consumesStoredSecret() { + this.secretStore.put(ROBOT_ID, "secret-from-create"); + ImageRegistryRobotSecret refreshed = new ImageRegistryRobotSecret(); + refreshed.setSecret("secret-from-refresh"); + when(this.robotService.refreshSecret(ROBOT_ID)).thenReturn(refreshed); + + assertThat(passwordOf(this.resolver.resolve(project()))).isEqualTo("secret-from-create"); + assertThat(passwordOf(this.resolver.resolve(project()))).isEqualTo("secret-from-refresh"); + } + + @Test + @DisplayName("보관된 secret 은 auth 필드에도 반영된다") + void resolve_encodesStoredSecretIntoAuth() { + this.secretStore.put(ROBOT_ID, "secret-from-create"); + + Map dockerConfigJson = this.resolver.resolve(project()); + + String expected = Base64.getEncoder() + .encodeToString((ROBOT_USERNAME + ":secret-from-create").getBytes()); + assertThat(authOf(dockerConfigJson)).isEqualTo(expected); + } + +} diff --git a/src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStoreTest.java b/src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStoreTest.java new file mode 100644 index 00000000..404f0df5 --- /dev/null +++ b/src/test/java/io/ten1010/aipub/projectcontroller/domain/aipubbackend/ImageRegistryRobotSecretStoreTest.java @@ -0,0 +1,134 @@ +package io.ten1010.aipub.projectcontroller.domain.aipubbackend; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ImageRegistryRobotSecretStoreTest { + + private static final Duration TTL = Duration.ofMinutes(5); + + private static MutableClock clock() { + return new MutableClock(Instant.parse("2026-07-29T12:00:00Z")); + } + + @Test + @DisplayName("보관한 secret 을 robotId 로 꺼낼 수 있다") + void take_returnsStoredSecret() { + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock()); + store.put("42", "harbor-secret"); + + assertThat(store.take("42")).contains("harbor-secret"); + } + + @Test + @DisplayName("secret 은 한 번만 꺼내진다 - 두 번째 take 는 비어 있다") + void take_consumesEntry() { + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock()); + store.put("42", "harbor-secret"); + + assertThat(store.take("42")).contains("harbor-secret"); + assertThat(store.take("42")).isEmpty(); + } + + @Test + @DisplayName("보관되지 않은 robotId 는 빈 값이다 - 호출자는 refreshsecret 으로 되돌아간다") + void take_returnsEmptyForUnknownRobotId() { + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock()); + + assertThat(store.take("42")).isEmpty(); + } + + @Test + @DisplayName("다른 robot 이 재생성돼 id 가 바뀌면 이전 robot 의 secret 은 쓰이지 않는다") + void take_isKeyedByRobotId() { + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock()); + store.put("42", "old-robot-secret"); + + assertThat(store.take("43")).isEmpty(); + } + + @Test + @DisplayName("같은 robotId 로 다시 넣으면 최신 secret 으로 덮어쓴다") + void put_overwritesPreviousSecret() { + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock()); + store.put("42", "first"); + store.put("42", "second"); + + assertThat(store.take("42")).contains("second"); + } + + @Test + @DisplayName("TTL 이 지난 secret 은 꺼내지지 않는다 - 외부에서 재발급됐을 수 있어 신뢰하지 않는다") + void take_returnsEmptyForExpiredEntry() { + MutableClock clock = clock(); + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock); + store.put("42", "harbor-secret"); + + clock.advance(TTL.plusSeconds(1)); + + assertThat(store.take("42")).isEmpty(); + } + + @Test + @DisplayName("TTL 이내라면 시간이 조금 흘러도 꺼내진다") + void take_returnsSecretWithinTtl() { + MutableClock clock = clock(); + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock); + store.put("42", "harbor-secret"); + + clock.advance(TTL.minusSeconds(1)); + + assertThat(store.take("42")).contains("harbor-secret"); + } + + @Test + @DisplayName("put 시점에 만료된 다른 항목들이 정리된다") + void put_purgesExpiredEntries() { + MutableClock clock = clock(); + ImageRegistryRobotSecretStore store = new ImageRegistryRobotSecretStore(TTL, clock); + store.put("42", "stale"); + + clock.advance(TTL.plusSeconds(1)); + store.put("43", "fresh"); + + assertThat(store.take("42")).isEmpty(); + assertThat(store.take("43")).contains("fresh"); + } + + private static final class MutableClock extends Clock { + + private Instant now; + + private MutableClock(Instant now) { + this.now = now; + } + + private void advance(Duration amount) { + this.now = this.now.plus(amount); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return this.now; + } + + } + +}