Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,15 +76,28 @@ public SubjectResolver subjectResolver() {
return new DefaultSubjectResolver();
}

/**
* robot 생성 응답의 평문 secret 을 {@code ImageRegistryRobotReconciler}(생성 측)에서
* {@link AipubDockerConfigJsonResolver}(K8s Secret 반영 측)로 넘기기 위한 저장소.
*
* <p>두 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();
}
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,16 +19,19 @@ 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;

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);
Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,15 +44,18 @@ public class ImageRegistryRobotReconciler extends AbstractReconciler {

private final ImageRegistryRobotService robotService;
private final ImageRegistryRobotUsernameResolver usernameResolver;
private final ImageRegistryRobotSecretStore secretStore;
private final Indexer<V1alpha1Project> projectIndexer;
private final Indexer<V1alpha1ImageHub> 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();
Expand Down Expand Up @@ -119,14 +124,18 @@ protected Result reconcileInternal(Request request) throws ApiException {
}
throw e;
}
// robot 은 username 당 하나다. 이미 있는 robot 을 갱신했으면 이번 reconcile 은 여기서 끝난다.
// 아래 생성 분기로 흘러가면 같은 username 으로 robot 을 또 만들려 해서 Harbor 가 거부한다.
return new Result(false);
}

if (!reconciledPermissions.isEmpty()) {
ImageRegistryRobot newRobot = new ImageRegistryRobot();
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);
Expand All @@ -139,6 +148,22 @@ protected Result reconcileInternal(Request request) throws ApiException {
return new Result(false);
}

/**
* 생성 응답의 평문 secret 을 {@link ImageRegistryRobotSecretStore} 에 넘긴다. 곧 이어 도는
* {@code ImageRegistrySecretReconciler} 가 이 값을 꺼내 쓰면 refreshsecret 왕복을 한 번 아낄 수 있다.
*
* <p>백엔드가 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<ImageRegistryRobot> findByUsername(String username) {
ImageRegistryRobotListOptions options = new ImageRegistryRobotListOptions();
options.setPageOffset(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -72,9 +75,20 @@ public Optional<String> resolveImageRegistryRobotId(V1alpha1Project project) {
return findByUsername(username).map(ImageRegistryRobot::getId);
}

/**
* robot 의 평문 비밀번호를 구한다.
*
* <p>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<String> storedSecret = this.secretStore.take(robotId);
if (storedSecret.isPresent()) {
return storedSecret.get();
}
ImageRegistryRobotSecret secret = this.imageRegistryRobotService.refreshSecret(robotId);
Objects.requireNonNull(secret.getSecret());
return secret.getSecret();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 에 반영될 때까지 잠시 들고 있는 저장소.
*
* <p>robot 을 만드는 주체({@code ImageRegistryRobotReconciler})와 그 secret 을 K8s Secret 에 쓰는 주체
* ({@code ImageRegistrySecretReconciler} → {@link AipubDockerConfigJsonResolver})가 서로 다른
* reconciler 라서, 생성 응답의 secret 을 그대로 넘겨줄 호출 경로가 없다. 이 저장소가 그 사이를 잇는다.
* 덕분에 robot 최초 생성 시 Harbor 왕복이 2회(create → refreshsecret)에서 1회로 줄어든다.
*
* <p>순수 최적화이며 저장소가 비어 있어도 동작은 정상이다. {@link #take} 가 비면 호출자는 기존대로
* refreshsecret 으로 되돌아간다. 컨트롤러가 create 와 Secret 반영 사이에 재시작하면 저장소는 비고, 그때는
* 예전과 같은 2회 왕복으로 처리된다.
*
* <p>보관 정책이 두 가지다. 첫째, {@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<String, Entry> 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<String> 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) {
}

}
Original file line number Diff line number Diff line change
@@ -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} 를 다시 부르지 않고 그대로 쓸 수 있다.
*
* <p>백엔드가 본문 없이 응답하면 빈 값이 된다(유효한 permission 이 없어 생성을 건너뛴 경우 등).
*/
Optional<ImageRegistryRobotCreated> createImageRegistryRobot(
ImageRegistryRobot imageRegistryRobot);

List<ImageRegistryRobot> listImageRegistryRobots(ImageRegistryRobotListOptions options);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.ten1010.aipub.projectcontroller.domain.aipubbackend.dto;

import lombok.Data;
import org.jspecify.annotations.Nullable;

/**
* robot 생성 응답. Harbor 가 생성 시점에 단 한 번만 반환하는 평문 secret 이 담겨 온다.
*
* <p>두 필드 모두 nullable 이다. aipub 백엔드는 유효한 permission 이 없어 생성을 건너뛴 경우와 Harbor 응답
* 본문을 파싱하지 못한 경우 secret·robotId 없이 응답한다.
*/
@Data
public class ImageRegistryRobotCreated {

@Nullable
private String robotId;
@Nullable
private String secret;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,6 +24,9 @@ public class ImageRegistryRobotServiceImpl implements ImageRegistryRobotService
private static final TypeToken<ImageRegistryRobotSecret> IMAGE_REGISTRY_ROBOT_SECRET_TYPE_TOKEN = new TypeToken<>() {
};

private static final TypeToken<ImageRegistryRobotCreated> IMAGE_REGISTRY_ROBOT_CREATED_TYPE_TOKEN = new TypeToken<>() {
};

private final ApiClient aipubBackendClient;
private final CallHelper callHelper;

Expand All @@ -31,13 +36,14 @@ public ImageRegistryRobotServiceImpl(ApiClient aipubBackendClient) {
}

@Override
public void createImageRegistryRobot(ImageRegistryRobot imageRegistryRobot) {
public Optional<ImageRegistryRobotCreated> 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
Expand Down
Loading