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; + } + + } + +}