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 9cbbf5a6..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();
@@ -119,6 +124,9 @@ protected Result reconcileInternal(Request request) throws ApiException {
}
throw e;
}
+ // robot 은 username 당 하나다. 이미 있는 robot 을 갱신했으면 이번 reconcile 은 여기서 끝난다.
+ // 아래 생성 분기로 흘러가면 같은 username 으로 robot 을 또 만들려 해서 Harbor 가 거부한다.
+ return new Result(false);
}
if (!reconciledPermissions.isEmpty()) {
@@ -126,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);
@@ -139,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;
+ }
+
+ }
+
+}