Задача Егора
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -8,6 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -19,19 +23,29 @@ import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Обновляет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
|
||||
* Атомарно изменяет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
|
||||
*
|
||||
* <p>Работает только внутри Kubernetes pod и использует service-account token и
|
||||
* service-account CA. В локальной разработке персистенция пропускается.</p>
|
||||
* <p>Каждая мутация сначала читает актуальный Secret, применяет изменение к полученному
|
||||
* списку и отправляет полный объект обратно условным {@code PUT} с
|
||||
* {@code metadata.resourceVersion}. При конфликте операция повторно читает актуальное
|
||||
* состояние и заново применяет только свою мутацию.</p>
|
||||
*/
|
||||
@Service
|
||||
public class KubernetesTenantSecretUpdater {
|
||||
public class KubernetesTenantSecretUpdater implements TenantConfigStore {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
|
||||
|
||||
@@ -43,6 +57,15 @@ public class KubernetesTenantSecretUpdater {
|
||||
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
|
||||
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
|
||||
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
|
||||
private static final int DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private static final Duration DEFAULT_RETRY_DELAY = Duration.ofMillis(50);
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
|
||||
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
);
|
||||
private static final TypeReference<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Path tokenPath;
|
||||
@@ -51,6 +74,10 @@ public class KubernetesTenantSecretUpdater {
|
||||
private final String apiBase;
|
||||
private final String secretName;
|
||||
private final boolean runningInKubernetes;
|
||||
private final int maxAttempts;
|
||||
private final Duration retryDelay;
|
||||
|
||||
private volatile HttpClient httpClient;
|
||||
|
||||
public KubernetesTenantSecretUpdater() {
|
||||
this(
|
||||
@@ -69,79 +96,572 @@ public class KubernetesTenantSecretUpdater {
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper) {
|
||||
this.tokenPath = tokenPath;
|
||||
this.namespacePath = namespacePath;
|
||||
this.caPath = caPath;
|
||||
this.apiBase = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
|
||||
this.secretName = secretName;
|
||||
this.objectMapper = objectMapper;
|
||||
this.runningInKubernetes = Files.exists(tokenPath);
|
||||
this(
|
||||
tokenPath,
|
||||
namespacePath,
|
||||
caPath,
|
||||
apiBase,
|
||||
secretName,
|
||||
objectMapper,
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
DEFAULT_RETRY_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper,
|
||||
int maxAttempts,
|
||||
Duration retryDelay) {
|
||||
this(
|
||||
tokenPath,
|
||||
namespacePath,
|
||||
caPath,
|
||||
apiBase,
|
||||
secretName,
|
||||
objectMapper,
|
||||
maxAttempts,
|
||||
retryDelay,
|
||||
detectKubernetesEnvironment(tokenPath)
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper,
|
||||
int maxAttempts,
|
||||
Duration retryDelay,
|
||||
boolean kubernetesEnvironment) {
|
||||
this.tokenPath = Objects.requireNonNull(tokenPath, "Путь к ServiceAccount token не задан");
|
||||
this.namespacePath = Objects.requireNonNull(namespacePath, "Путь к namespace не задан");
|
||||
this.caPath = Objects.requireNonNull(caPath, "Путь к ServiceAccount CA не задан");
|
||||
this.apiBase = normalizeApiBase(apiBase);
|
||||
this.secretName = requireText(secretName, "Имя tenant Secret не задано");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "ObjectMapper не задан");
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("Количество попыток должно быть положительным");
|
||||
}
|
||||
if (retryDelay == null || retryDelay.isNegative()) {
|
||||
throw new IllegalArgumentException("Задержка повтора не может быть отрицательной");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.retryDelay = retryDelay;
|
||||
this.runningInKubernetes = kubernetesEnvironment || Files.exists(tokenPath);
|
||||
|
||||
if (!runningInKubernetes) {
|
||||
log.info("Приложение запущено вне Kubernetes — обновление tenant Secret будет пропущено");
|
||||
log.info("Приложение запущено вне Kubernetes — постоянное изменение tenant Secret отключено");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет полный список тенантов в ключе {@code tenants.json} Kubernetes Secret.
|
||||
*
|
||||
* @return {@code true}, если Secret обновлён или приложение запущено вне Kubernetes
|
||||
*/
|
||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||
@Override
|
||||
public TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant) {
|
||||
TenantConfig requested = TenantSecretUpdateReceipt.copyOf(
|
||||
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")
|
||||
);
|
||||
String domain = normalizeDomain(requested.getDomain());
|
||||
requested.setDomain(domain);
|
||||
|
||||
if (!runningInKubernetes) {
|
||||
log.warn("Приложение запущено вне Kubernetes, персистенция tenant Secret пропущена");
|
||||
return true;
|
||||
log.warn("Приложение запущено вне Kubernetes, постоянное добавление тенанта пропущено");
|
||||
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of(requested));
|
||||
}
|
||||
|
||||
try {
|
||||
String token = Files.readString(tokenPath).trim();
|
||||
String namespace = Files.readString(namespacePath).trim();
|
||||
if (token.isBlank() || namespace.isBlank()) {
|
||||
throw new IllegalStateException("ServiceAccount token или namespace не настроены");
|
||||
return mutate(tenants -> {
|
||||
List<TenantConfig> updated = new ArrayList<>(tenants);
|
||||
updated.removeIf(existing -> domain.equals(existing.getDomain()));
|
||||
updated.add(requested);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public TenantSecretUpdateReceipt removeTenant(String domain) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
if (!runningInKubernetes) {
|
||||
log.warn("Приложение запущено вне Kubernetes, постоянное удаление тенанта пропущено");
|
||||
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of());
|
||||
}
|
||||
|
||||
return mutate(tenants -> {
|
||||
List<TenantConfig> updated = new ArrayList<>(tenants);
|
||||
updated.removeIf(existing -> normalizedDomain.equals(existing.getDomain()));
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
|
||||
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||
if (!receipt.persisted() || !receipt.changed()) {
|
||||
return TenantSecretCompensationResult.NOT_REQUIRED;
|
||||
}
|
||||
if (!runningInKubernetes) {
|
||||
return TenantSecretCompensationResult.NOT_REQUIRED;
|
||||
}
|
||||
|
||||
String committedVersion = requireText(
|
||||
receipt.committedResourceVersion(),
|
||||
"В квитанции отсутствует resourceVersion сохранённого tenant Secret"
|
||||
);
|
||||
RequestContext context = requestContext();
|
||||
TenantConfigPersistenceException lastFailure = null;
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
CurrentSecret current;
|
||||
try {
|
||||
current = readCurrent(context);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
lastFailure = persistenceFailure("Не удалось прочитать tenant Secret для компенсации", e);
|
||||
if (!prepareRetry(attempt, "чтение перед компенсацией")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
|
||||
String encodedTenants = Base64.getEncoder()
|
||||
.encodeToString(tenantsJson.getBytes(StandardCharsets.UTF_8));
|
||||
String patchBody = objectMapper.writeValueAsString(Map.of(
|
||||
"data", Map.of("tenants.json", encodedTenants)
|
||||
));
|
||||
if (!committedVersion.equals(current.resourceVersion())) {
|
||||
log.warn("Компенсация tenant Secret пропущена: после исходной операции Secret уже изменён");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
if (!sameTenants(current.tenants(), receipt.committedTenants())) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Содержимое tenant Secret не соответствует resourceVersion из квитанции"
|
||||
);
|
||||
}
|
||||
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
response = putSecret(context, current, receipt.previousTenants());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = persistenceFailure("Не удалось подтвердить компенсацию tenant Secret", e);
|
||||
if (!prepareRetry(attempt, "компенсация после сетевой ошибки")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSuccess(response.statusCode())) {
|
||||
TenantSecretCompensationResult confirmed = confirmCompensation(
|
||||
context,
|
||||
receipt,
|
||||
response.body()
|
||||
);
|
||||
if (confirmed != null) {
|
||||
if (confirmed == TenantSecretCompensationResult.RESTORED) {
|
||||
log.info("Конфигурация tenant Secret восстановлена: tenantCount={}",
|
||||
receipt.previousTenants().size());
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Kubernetes API не подтвердил содержимое после компенсации tenant Secret"
|
||||
);
|
||||
if (!prepareRetry(attempt, "проверка компенсации tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (response.statusCode() == 409) {
|
||||
log.warn("Компенсация tenant Secret пропущена: обнаружено конкурентное изменение");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
if (isRetryable(response.statusCode())) {
|
||||
TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Kubernetes API временно отклонил компенсацию tenant Secret: HTTP "
|
||||
+ response.statusCode()
|
||||
);
|
||||
if (!prepareRetry(attempt, "компенсация tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API отклонил компенсацию tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
|
||||
throw lastFailure == null
|
||||
? new TenantConfigPersistenceException("Не удалось компенсировать tenant Secret")
|
||||
: lastFailure;
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt mutate(Function<List<TenantConfig>, List<TenantConfig>> mutation) {
|
||||
RequestContext context = requestContext();
|
||||
TenantConfigPersistenceException lastFailure = null;
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
CurrentSecret current;
|
||||
try {
|
||||
current = readCurrent(context);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
|
||||
} catch (IOException e) {
|
||||
lastFailure = persistenceFailure("Не удалось прочитать актуальный tenant Secret", e);
|
||||
if (!prepareRetry(attempt, "чтение tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
List<TenantConfig> previous = normalizeTenants(current.tenants());
|
||||
List<TenantConfig> committed = normalizeTenants(mutation.apply(previous));
|
||||
if (sameTenants(previous, committed)) {
|
||||
return new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
false,
|
||||
current.resourceVersion(),
|
||||
previous,
|
||||
committed
|
||||
);
|
||||
}
|
||||
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
response = putSecret(context, current, committed);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
|
||||
} catch (IOException e) {
|
||||
TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = persistenceFailure("Не удалось подтвердить изменение tenant Secret", e);
|
||||
if (!prepareRetry(attempt, "изменение после сетевой ошибки")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSuccess(response.statusCode())) {
|
||||
TenantSecretUpdateReceipt confirmed = confirmMutation(
|
||||
context,
|
||||
current,
|
||||
previous,
|
||||
committed,
|
||||
response.body()
|
||||
);
|
||||
if (confirmed != null) {
|
||||
log.info("Tenant Secret атомарно изменён: tenantCount={}", committed.size());
|
||||
return confirmed;
|
||||
}
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API не подтвердил сохранённое содержимое tenant Secret"
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode() == 409) {
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Исчерпаны попытки изменения tenant Secret из-за конкурентных обновлений"
|
||||
);
|
||||
if (!prepareRetry(attempt, "конфликт resourceVersion")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRetryable(response.statusCode())) {
|
||||
TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
|
||||
if (reconciled != null) {
|
||||
return reconciled;
|
||||
}
|
||||
lastFailure = new TenantConfigPersistenceException(
|
||||
"Kubernetes API временно отклонил изменение tenant Secret: HTTP "
|
||||
+ response.statusCode()
|
||||
);
|
||||
if (!prepareRetry(attempt, "изменение tenant Secret")) {
|
||||
throw lastFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API отклонил изменение tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
|
||||
throw lastFailure == null
|
||||
? new TenantConfigPersistenceException("Не удалось изменить tenant Secret")
|
||||
: lastFailure;
|
||||
}
|
||||
|
||||
private CurrentSecret readCurrent(RequestContext context) throws IOException, InterruptedException {
|
||||
HttpRequest request = requestBuilder(context)
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = secureClient().send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (response.statusCode() == 200) {
|
||||
return parseSecret(response.body());
|
||||
}
|
||||
if (isRetryable(response.statusCode())) {
|
||||
throw new RetryableApiException(
|
||||
"Kubernetes API временно не вернул tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Kubernetes API не вернул tenant Secret: HTTP " + response.statusCode()
|
||||
);
|
||||
}
|
||||
|
||||
private HttpResponse<String> putSecret(RequestContext context,
|
||||
CurrentSecret current,
|
||||
List<TenantConfig> tenants)
|
||||
throws IOException, InterruptedException {
|
||||
ObjectNode body = current.secret().deepCopy();
|
||||
ObjectNode metadata = objectNode(body, "metadata");
|
||||
metadata.put("resourceVersion", current.resourceVersion());
|
||||
ObjectNode data = objectNode(body, "data");
|
||||
data.put("tenants.json", encodeTenants(tenants));
|
||||
|
||||
HttpRequest request = requestBuilder(context)
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
.build();
|
||||
return secureClient().send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt reconcileMutation(RequestContext context,
|
||||
List<TenantConfig> previous,
|
||||
List<TenantConfig> committed) {
|
||||
try {
|
||||
CurrentSecret observed = readCurrent(context);
|
||||
if (sameTenants(observed.tenants(), committed)) {
|
||||
log.info("Изменение tenant Secret подтверждено повторным чтением: tenantCount={}",
|
||||
committed.size());
|
||||
return new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
false,
|
||||
observed.resourceVersion(),
|
||||
previous,
|
||||
observed.tenants()
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Проверка изменения tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
log.warn("Не удалось подтвердить состояние tenant Secret повторным чтением");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt confirmMutation(RequestContext context,
|
||||
CurrentSecret previousSecret,
|
||||
List<TenantConfig> previous,
|
||||
List<TenantConfig> committed,
|
||||
String responseBody) {
|
||||
try {
|
||||
CurrentSecret confirmed = parseSecret(responseBody);
|
||||
if (sameTenants(confirmed.tenants(), committed)
|
||||
&& !Objects.equals(confirmed.resourceVersion(), previousSecret.resourceVersion())) {
|
||||
return new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
true,
|
||||
confirmed.resourceVersion(),
|
||||
previous,
|
||||
confirmed.tenants()
|
||||
);
|
||||
}
|
||||
log.warn("Ответ Kubernetes API после изменения tenant Secret не совпал с ожидаемым состоянием");
|
||||
} catch (TenantConfigPersistenceException invalidResponse) {
|
||||
log.warn("Kubernetes API вернул неполный ответ после изменения tenant Secret, выполняется повторное чтение");
|
||||
log.debug("Технические детали неполного ответа tenant Secret", invalidResponse);
|
||||
}
|
||||
return reconcileMutation(context, previous, committed);
|
||||
}
|
||||
|
||||
private TenantSecretCompensationResult confirmCompensation(RequestContext context,
|
||||
TenantSecretUpdateReceipt receipt,
|
||||
String responseBody) {
|
||||
try {
|
||||
CurrentSecret confirmed = parseSecret(responseBody);
|
||||
if (sameTenants(confirmed.tenants(), receipt.previousTenants())) {
|
||||
return TenantSecretCompensationResult.RESTORED;
|
||||
}
|
||||
log.warn("Ответ Kubernetes API после компенсации tenant Secret не совпал с ожидаемым состоянием");
|
||||
} catch (TenantConfigPersistenceException invalidResponse) {
|
||||
log.warn("Kubernetes API вернул неполный ответ после компенсации tenant Secret, выполняется повторное чтение");
|
||||
log.debug("Технические детали неполного ответа компенсации tenant Secret", invalidResponse);
|
||||
}
|
||||
return reconcileCompensation(context, receipt);
|
||||
}
|
||||
|
||||
private TenantSecretCompensationResult reconcileCompensation(RequestContext context,
|
||||
TenantSecretUpdateReceipt receipt) {
|
||||
try {
|
||||
CurrentSecret observed = readCurrent(context);
|
||||
if (sameTenants(observed.tenants(), receipt.previousTenants())) {
|
||||
log.info("Компенсация tenant Secret подтверждена повторным чтением");
|
||||
return TenantSecretCompensationResult.RESTORED;
|
||||
}
|
||||
if (!Objects.equals(observed.resourceVersion(), receipt.committedResourceVersion())) {
|
||||
log.warn("Компенсация tenant Secret остановлена из-за конкурентного изменения");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
return null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Проверка компенсации tenant Secret прервана", e);
|
||||
} catch (IOException e) {
|
||||
log.warn("Не удалось подтвердить компенсацию tenant Secret повторным чтением");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CurrentSecret parseSecret(String responseBody) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(responseBody);
|
||||
if (!(root instanceof ObjectNode secret)) {
|
||||
throw new TenantConfigPersistenceException("Kubernetes API вернул некорректный tenant Secret");
|
||||
}
|
||||
String resourceVersion = requireText(
|
||||
root.path("metadata").path("resourceVersion").asText(null),
|
||||
"В tenant Secret отсутствует metadata.resourceVersion"
|
||||
);
|
||||
JsonNode data = root.path("data");
|
||||
if (!data.isObject() || !data.hasNonNull("tenants.json")) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"В tenant Secret отсутствует обязательный ключ data.tenants.json"
|
||||
);
|
||||
}
|
||||
String encoded = requireText(
|
||||
data.path("tenants.json").asText(null),
|
||||
"В tenant Secret ключ data.tenants.json не содержит конфигурацию"
|
||||
);
|
||||
byte[] decoded = Base64.getDecoder().decode(encoded);
|
||||
List<TenantConfig> tenants = objectMapper.readValue(decoded, TENANT_LIST_TYPE);
|
||||
return new CurrentSecret(secret, resourceVersion, normalizeTenants(tenants));
|
||||
} catch (TenantConfigPersistenceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось разобрать конфигурацию из tenant Secret",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<TenantConfig> normalizeTenants(List<TenantConfig> tenants) {
|
||||
if (tenants == null) {
|
||||
throw new TenantConfigPersistenceException("Список конфигураций в tenant Secret не задан");
|
||||
}
|
||||
List<TenantConfig> normalized = new ArrayList<>(tenants.size());
|
||||
Set<String> domains = new HashSet<>();
|
||||
for (TenantConfig source : tenants) {
|
||||
TenantConfig tenant = TenantSecretUpdateReceipt.copyOf(
|
||||
Objects.requireNonNull(source, "В tenant Secret обнаружена пустая конфигурация")
|
||||
);
|
||||
String domain = normalizeDomain(tenant.getDomain());
|
||||
tenant.setDomain(domain);
|
||||
if (!domains.add(domain)) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"В tenant Secret обнаружены повторяющиеся домены"
|
||||
);
|
||||
}
|
||||
normalized.add(tenant);
|
||||
}
|
||||
normalized.sort(Comparator.comparing(TenantConfig::getDomain));
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private boolean sameTenants(List<TenantConfig> first, List<TenantConfig> second) {
|
||||
List<TenantConfig> left = normalizeTenants(first);
|
||||
List<TenantConfig> right = normalizeTenants(second);
|
||||
if (left.size() != right.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < left.size(); i++) {
|
||||
if (!sameTenant(left.get(i), right.get(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean sameTenant(TenantConfig first, TenantConfig second) {
|
||||
return Objects.equals(first.getName(), second.getName())
|
||||
&& Objects.equals(first.getDomain(), second.getDomain())
|
||||
&& Objects.equals(first.getUrl(), second.getUrl())
|
||||
&& Objects.equals(first.getUsername(), second.getUsername())
|
||||
&& Objects.equals(first.getPassword(), second.getPassword());
|
||||
}
|
||||
|
||||
private RequestContext requestContext() {
|
||||
try {
|
||||
String token = requireText(
|
||||
Files.readString(tokenPath).trim(),
|
||||
"ServiceAccount token не настроен"
|
||||
);
|
||||
String namespace = requireText(
|
||||
Files.readString(namespacePath).trim(),
|
||||
"ServiceAccount namespace не настроен"
|
||||
);
|
||||
URI uri = URI.create(String.format(
|
||||
"%s/api/v1/namespaces/%s/secrets/%s",
|
||||
apiBase,
|
||||
namespace,
|
||||
secretName
|
||||
));
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/strategic-merge-patch+json")
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<Void> response = createSecureClient(caPath)
|
||||
.send(request, HttpResponse.BodyHandlers.discarding());
|
||||
if (response.statusCode() == 200) {
|
||||
log.info("Tenant Secret успешно обновлён: tenantCount={}", tenants.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
log.error("Kubernetes API отклонил обновление tenant Secret: httpStatus={}", response.statusCode());
|
||||
return false;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Обновление tenant Secret прервано");
|
||||
log.debug("Технические детали прерывания tenant Secret", e);
|
||||
return false;
|
||||
return new RequestContext(token, uri);
|
||||
} catch (TenantConfigPersistenceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось безопасно обновить tenant Secret: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали ошибки tenant Secret", e);
|
||||
return false;
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось прочитать параметры ServiceAccount для tenant Secret",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequest.Builder requestBuilder(RequestContext context) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(context.uri())
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Authorization", "Bearer " + context.token())
|
||||
.header("Accept", "application/json");
|
||||
}
|
||||
|
||||
private synchronized HttpClient secureClient() {
|
||||
if (httpClient == null) {
|
||||
try {
|
||||
httpClient = createSecureClient(caPath);
|
||||
} catch (Exception e) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось настроить защищённое соединение с Kubernetes API",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
|
||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certificates;
|
||||
@@ -171,8 +691,102 @@ public class KubernetesTenantSecretUpdater {
|
||||
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
|
||||
return HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.sslContext(sslContext)
|
||||
.sslParameters(sslParameters)
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean prepareRetry(int attempt, String operation) {
|
||||
if (attempt >= maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
log.warn("Временная ошибка tenant Secret, операция будет повторена: операция={}, попытка={}/{}",
|
||||
operation,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
try {
|
||||
long delayMillis = Math.multiplyExact(retryDelay.toMillis(), attempt);
|
||||
if (delayMillis > 0) {
|
||||
Thread.sleep(delayMillis);
|
||||
}
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Ожидание повтора tenant Secret прервано", e);
|
||||
} catch (ArithmeticException e) {
|
||||
throw new TenantConfigPersistenceException("Некорректная задержка повтора tenant Secret", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String encodeTenants(List<TenantConfig> tenants) throws IOException {
|
||||
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
|
||||
return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private ObjectNode objectNode(ObjectNode parent, String fieldName) {
|
||||
JsonNode existing = parent.get(fieldName);
|
||||
if (existing instanceof ObjectNode objectNode) {
|
||||
return objectNode;
|
||||
}
|
||||
ObjectNode created = objectMapper.createObjectNode();
|
||||
parent.set(fieldName, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private static boolean isSuccess(int statusCode) {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
private static boolean isRetryable(int statusCode) {
|
||||
return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500;
|
||||
}
|
||||
|
||||
private static boolean detectKubernetesEnvironment(Path tokenPath) {
|
||||
String kubernetesServiceHost = System.getenv("KUBERNETES_SERVICE_HOST");
|
||||
return (tokenPath != null && Files.exists(tokenPath))
|
||||
|| (kubernetesServiceHost != null && !kubernetesServiceHost.isBlank());
|
||||
}
|
||||
|
||||
private static String requireText(String value, String message) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new TenantConfigPersistenceException(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeDomain(String value) {
|
||||
String domain = requireText(value, "Домен тенанта не задан").toLowerCase(Locale.ROOT);
|
||||
if (!DOMAIN_PATTERN.matcher(domain).matches()) {
|
||||
throw new TenantConfigPersistenceException("Домен тенанта содержит недопустимые символы");
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
private static String normalizeApiBase(String apiBase) {
|
||||
String normalized = requireText(apiBase, "Адрес Kubernetes API не задан");
|
||||
return normalized.endsWith("/")
|
||||
? normalized.substring(0, normalized.length() - 1)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static TenantConfigPersistenceException persistenceFailure(String message, Exception cause) {
|
||||
return cause instanceof TenantConfigPersistenceException persistenceException
|
||||
? persistenceException
|
||||
: new TenantConfigPersistenceException(message, cause);
|
||||
}
|
||||
|
||||
private record RequestContext(String token, URI uri) {
|
||||
}
|
||||
|
||||
private record CurrentSecret(ObjectNode secret,
|
||||
String resourceVersion,
|
||||
List<TenantConfig> tenants) {
|
||||
}
|
||||
|
||||
private static final class RetryableApiException extends IOException {
|
||||
private RetryableApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
/**
|
||||
* Ошибка безопасного чтения или изменения постоянной конфигурации тенантов.
|
||||
*/
|
||||
public class TenantConfigPersistenceException extends RuntimeException {
|
||||
|
||||
public TenantConfigPersistenceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TenantConfigPersistenceException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
/**
|
||||
* Хранилище конфигурации тенантов с защитой от потерянных конкурентных обновлений.
|
||||
*/
|
||||
public interface TenantConfigStore {
|
||||
|
||||
TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant);
|
||||
|
||||
TenantSecretUpdateReceipt removeTenant(String domain);
|
||||
|
||||
TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -29,16 +30,23 @@ public class TenantConfigWatcher {
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||
|
||||
private final TenantLifecycleService tenantLifecycleService;
|
||||
private final TenantReadinessRegistry readinessRegistry;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||
private String lastConfigHash = "";
|
||||
private boolean configurationUnavailable;
|
||||
|
||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService) {
|
||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
this.tenantLifecycleService = tenantLifecycleService;
|
||||
this.readinessRegistry = readinessRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,12 +60,21 @@ public class TenantConfigWatcher {
|
||||
private void watchForChangesSerialized() {
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) return;
|
||||
if (!file.exists()) {
|
||||
if (tenantsConfigRequired) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
if (!configurationUnavailable) {
|
||||
log.error("Обязательный файл конфигурации тенантов недоступен");
|
||||
}
|
||||
configurationUnavailable = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
|
||||
if (hash.equals(lastConfigHash)) {
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
return; // Ничего не изменилось
|
||||
}
|
||||
|
||||
@@ -65,8 +82,11 @@ public class TenantConfigWatcher {
|
||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||
syncTenants(newTenants);
|
||||
lastConfigHash = hash;
|
||||
configurationUnavailable = false;
|
||||
|
||||
} catch (Exception e) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
|
||||
log.debug("Технические детали синхронизации конфига тенантов", e);
|
||||
}
|
||||
@@ -85,8 +105,13 @@ public class TenantConfigWatcher {
|
||||
if (file.exists()) {
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
lastConfigHash = configHash(content);
|
||||
} else if (tenantsConfigRequired) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали обновления хеша тенантов", e);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
@@ -18,13 +19,12 @@ import org.springframework.transaction.PlatformTransactionManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Конфигурация мультитенантного DataSource.
|
||||
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
||||
* Загружает тенанты из JSON-файла (смонтированный Kubernetes Secret).
|
||||
*
|
||||
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||
@@ -37,6 +37,9 @@ public class TenantDataSourceConfig {
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
@Value("${spring.datasource.url:}")
|
||||
private String defaultDbUrl;
|
||||
|
||||
@@ -48,11 +51,13 @@ public class TenantDataSourceConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public DataSource dataSource(TenantDatabaseMigrationService migrationService) {
|
||||
public DataSource dataSource(TenantDatabaseMigrationService migrationService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||
|
||||
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
||||
List<TenantConfig> tenants = loadTenantsFromFile();
|
||||
// Загружаем тенантов из JSON (read-only Secret mount)
|
||||
TenantConfigLoadResult loadResult = loadTenantsFromFile();
|
||||
List<TenantConfig> tenants = new ArrayList<>(loadResult.tenants());
|
||||
|
||||
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||
@@ -63,9 +68,15 @@ public class TenantDataSourceConfig {
|
||||
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
||||
}
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(tenants);
|
||||
if (loadResult.failed() || (tenantsConfigRequired && loadResult.tenants().isEmpty())) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
}
|
||||
|
||||
// Регистрируем тенантов
|
||||
for (TenantConfig tenant : tenants) {
|
||||
registerPreparedTenant(routingDataSource, migrationService, tenant);
|
||||
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||
}
|
||||
|
||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||
@@ -79,7 +90,13 @@ public class TenantDataSourceConfig {
|
||||
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||
"sa", ""
|
||||
);
|
||||
if (!registerPreparedTenant(routingDataSource, migrationService, h2Fallback)) {
|
||||
if (!registerPreparedTenant(
|
||||
routingDataSource,
|
||||
migrationService,
|
||||
readinessRegistry,
|
||||
h2Fallback,
|
||||
false
|
||||
)) {
|
||||
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
|
||||
}
|
||||
}
|
||||
@@ -118,31 +135,43 @@ public class TenantDataSourceConfig {
|
||||
return new JpaTransactionManager(emf);
|
||||
}
|
||||
|
||||
private List<TenantConfig> loadTenantsFromFile() {
|
||||
private TenantConfigLoadResult loadTenantsFromFile() {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) {
|
||||
log.info("Файл конфигурации тенантов не найден");
|
||||
return new ArrayList<>();
|
||||
if (tenantsConfigRequired) {
|
||||
log.error("Обязательный файл конфигурации тенантов не найден");
|
||||
} else {
|
||||
log.info("Файл конфигурации тенантов не найден");
|
||||
}
|
||||
return new TenantConfigLoadResult(List.of(), tenantsConfigRequired);
|
||||
}
|
||||
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||
if (list == null) {
|
||||
throw new IllegalArgumentException("Корневое значение конфигурации тенантов должно быть массивом");
|
||||
}
|
||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||
return list;
|
||||
} catch (IOException e) {
|
||||
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||
return new ArrayList<>();
|
||||
return new TenantConfigLoadResult(List.of(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
|
||||
TenantDatabaseMigrationService migrationService,
|
||||
TenantConfig tenant) {
|
||||
TenantReadinessRegistry readinessRegistry,
|
||||
TenantConfig tenant,
|
||||
boolean requiredForReadiness) {
|
||||
HikariDataSource candidate = null;
|
||||
boolean activated = false;
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationStarted(tenant);
|
||||
}
|
||||
try {
|
||||
candidate = routingDataSource.prepareTenantDataSource(tenant);
|
||||
try (Connection connection = candidate.getConnection()) {
|
||||
@@ -155,10 +184,17 @@ public class TenantDataSourceConfig {
|
||||
}
|
||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
|
||||
activated = true;
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationSucceeded(tenant);
|
||||
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||
}
|
||||
closeDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
||||
return true;
|
||||
} catch (Exception startupFailure) {
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationFailed(tenant);
|
||||
}
|
||||
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
||||
@@ -181,4 +217,7 @@ public class TenantDataSourceConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record TenantConfigLoadResult(List<TenantConfig> tenants, boolean failed) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
/**
|
||||
* Результат компенсирующего изменения конфигурации тенантов.
|
||||
*/
|
||||
public enum TenantSecretCompensationResult {
|
||||
RESTORED,
|
||||
NOT_REQUIRED,
|
||||
SKIPPED_CONCURRENT_CHANGE
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Квитанция об атомарном изменении tenant Secret.
|
||||
*
|
||||
* <p>Снимки конфигурации копируются глубоко и сортируются по домену. Это позволяет
|
||||
* безопасно использовать квитанцию для компенсации и проверки ожидаемого состояния.</p>
|
||||
*/
|
||||
public final class TenantSecretUpdateReceipt {
|
||||
|
||||
private static final Comparator<TenantConfig> BY_DOMAIN = Comparator.comparing(
|
||||
TenantConfig::getDomain,
|
||||
Comparator.nullsFirst(String::compareTo)
|
||||
);
|
||||
|
||||
private final boolean persisted;
|
||||
private final boolean changed;
|
||||
private final String committedResourceVersion;
|
||||
private final List<TenantConfig> previousTenants;
|
||||
private final List<TenantConfig> committedTenants;
|
||||
|
||||
public TenantSecretUpdateReceipt(boolean persisted,
|
||||
boolean changed,
|
||||
String committedResourceVersion,
|
||||
List<TenantConfig> previousTenants,
|
||||
List<TenantConfig> committedTenants) {
|
||||
this.persisted = persisted;
|
||||
this.changed = changed;
|
||||
this.committedResourceVersion = committedResourceVersion;
|
||||
this.previousTenants = copyAndSort(previousTenants);
|
||||
this.committedTenants = copyAndSort(committedTenants);
|
||||
}
|
||||
|
||||
public boolean persisted() {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает {@code true}, только если хранилище доказало владение подтверждённой
|
||||
* записанной версией. После неоднозначного результата повторное чтение может подтвердить
|
||||
* целевое состояние, но оставляет этот признак {@code false}, чтобы запретить опасную
|
||||
* автоматическую компенсацию.
|
||||
*/
|
||||
public boolean changed() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
public String committedResourceVersion() {
|
||||
return committedResourceVersion;
|
||||
}
|
||||
|
||||
public List<TenantConfig> previousTenants() {
|
||||
return copyAndSort(previousTenants);
|
||||
}
|
||||
|
||||
public List<TenantConfig> committedTenants() {
|
||||
return copyAndSort(committedTenants);
|
||||
}
|
||||
|
||||
static List<TenantConfig> copyAndSort(List<TenantConfig> tenants) {
|
||||
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
|
||||
List<TenantConfig> result = new ArrayList<>(tenants.size());
|
||||
for (TenantConfig tenant : tenants) {
|
||||
result.add(copyOf(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")));
|
||||
}
|
||||
result.sort(BY_DOMAIN);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
static TenantConfig copyOf(TenantConfig source) {
|
||||
return new TenantConfig(
|
||||
source.getName(),
|
||||
source.getDomain(),
|
||||
source.getUrl(),
|
||||
source.getUsername(),
|
||||
source.getPassword()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TenantSecretUpdateReceipt{" +
|
||||
"persisted=" + persisted +
|
||||
", changed=" + changed +
|
||||
", committedResourceVersion='" + committedResourceVersion + '\'' +
|
||||
", previousTenantCount=" + previousTenants.size() +
|
||||
", committedTenantCount=" + committedTenants.size() +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ public class TenantWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||
registry.addInterceptor(tenantInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/actuator/**");
|
||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.magistr.app.config.tenant.health;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Фоновая проверка доступности tenant-БД с ограниченным числом параллельных задач.
|
||||
*/
|
||||
@Component
|
||||
public class TenantDatabaseHealthMonitor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseHealthMonitor.class);
|
||||
|
||||
private final TenantReadinessRegistry registry;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final int parallelism;
|
||||
private final Duration checkTimeout;
|
||||
private final Duration maxStaleness;
|
||||
private final Clock clock;
|
||||
private final ExecutorService executor;
|
||||
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
|
||||
|
||||
public TenantDatabaseHealthMonitor(
|
||||
TenantReadinessRegistry registry,
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
@Value("${app.health.tenant-database.parallelism:4}") int parallelism,
|
||||
@Value("${app.health.tenant-database.check-timeout-ms:6000}") long checkTimeoutMillis,
|
||||
@Value("${app.health.tenant-database.max-staleness-ms:30000}") long maxStalenessMillis) {
|
||||
this(registry, routingDataSource, parallelism,
|
||||
Duration.ofMillis(checkTimeoutMillis),
|
||||
Duration.ofMillis(maxStalenessMillis),
|
||||
Clock.systemUTC());
|
||||
}
|
||||
|
||||
public TenantDatabaseHealthMonitor(TenantReadinessRegistry registry,
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
int parallelism,
|
||||
Duration checkTimeout,
|
||||
Duration maxStaleness,
|
||||
Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
|
||||
this.routingDataSource = Objects.requireNonNull(routingDataSource, "Маршрутизатор tenant-БД обязателен");
|
||||
if (parallelism < 1 || parallelism > 32) {
|
||||
throw new IllegalArgumentException("Параллелизм проверки должен быть от 1 до 32");
|
||||
}
|
||||
this.parallelism = parallelism;
|
||||
this.checkTimeout = requirePositive(checkTimeout, "Таймаут проверки должен быть положительным");
|
||||
this.maxStaleness = requirePositive(maxStaleness, "Максимальный возраст проверки должен быть положительным");
|
||||
this.clock = Objects.requireNonNull(clock, "Часы монитора обязательны");
|
||||
this.executor = Executors.newFixedThreadPool(parallelism, new HealthThreadFactory());
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.health.tenant-database.interval-ms:10000}",
|
||||
initialDelayString = "${app.health.tenant-database.initial-delay-ms:1000}")
|
||||
public void refreshScheduled() {
|
||||
refreshNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет немедленный проход. Одновременно может выполняться только один проход.
|
||||
*/
|
||||
public RefreshSummary refreshNow() {
|
||||
if (!refreshInProgress.compareAndSet(false, true)) {
|
||||
return new RefreshSummary(0, 0, 0, true);
|
||||
}
|
||||
|
||||
try {
|
||||
List<TenantReadinessRegistry.ConnectivityTarget> targets = registry.connectivityTargets();
|
||||
int available = 0;
|
||||
int unavailable = 0;
|
||||
|
||||
for (int offset = 0; offset < targets.size(); offset += parallelism) {
|
||||
int end = Math.min(offset + parallelism, targets.size());
|
||||
List<TenantReadinessRegistry.ConnectivityTarget> batch = targets.subList(offset, end);
|
||||
List<Callable<CheckResult>> checks = new ArrayList<>(batch.size());
|
||||
|
||||
for (TenantReadinessRegistry.ConnectivityTarget target : batch) {
|
||||
checks.add(() -> check(target));
|
||||
}
|
||||
|
||||
List<Future<CheckResult>> futures;
|
||||
try {
|
||||
futures = executor.invokeAll(checks, checkTimeout.toNanos(), TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
Instant interruptedAt = clock.instant();
|
||||
log.warn("Пакетная проверка tenant-БД была прервана");
|
||||
for (int remaining = offset; remaining < targets.size(); remaining++) {
|
||||
TenantReadinessRegistry.ConnectivityTarget target = targets.get(remaining);
|
||||
if (registry.markConnectivity(
|
||||
target.domain(), target.fingerprint(), false, interruptedAt)) {
|
||||
unavailable++;
|
||||
}
|
||||
}
|
||||
return new RefreshSummary(targets.size(), available, unavailable, false);
|
||||
}
|
||||
|
||||
for (int index = 0; index < batch.size(); index++) {
|
||||
TenantReadinessRegistry.ConnectivityTarget target = batch.get(index);
|
||||
CheckResult result = completedResult(target, futures.get(index));
|
||||
boolean applied = registry.markConnectivity(
|
||||
target.domain(), target.fingerprint(), result.connected(), result.checkedAt());
|
||||
if (applied && result.connected()) {
|
||||
available++;
|
||||
} else if (applied) {
|
||||
unavailable++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new RefreshSummary(targets.size(), available, unavailable, false);
|
||||
} finally {
|
||||
refreshInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Duration maxStaleness() {
|
||||
return maxStaleness;
|
||||
}
|
||||
|
||||
public int parallelism() {
|
||||
return parallelism;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private CheckResult check(TenantReadinessRegistry.ConnectivityTarget target) {
|
||||
try {
|
||||
boolean connected = routingDataSource.testConnection(target.domain());
|
||||
return new CheckResult(connected, clock.instant());
|
||||
} catch (RuntimeException checkFailure) {
|
||||
log.warn("Проверка tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
target.domain(), checkFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали фоновой проверки tenant-БД", checkFailure);
|
||||
return new CheckResult(false, clock.instant());
|
||||
}
|
||||
}
|
||||
|
||||
private CheckResult completedResult(TenantReadinessRegistry.ConnectivityTarget target,
|
||||
Future<CheckResult> future) {
|
||||
if (future.isCancelled()) {
|
||||
log.warn("Проверка tenant-БД '{}' превысила допустимое время", target.domain());
|
||||
return new CheckResult(false, clock.instant());
|
||||
}
|
||||
|
||||
try {
|
||||
return future.get();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
future.cancel(true);
|
||||
log.warn("Проверка tenant-БД '{}' была прервана", target.domain());
|
||||
return new CheckResult(false, clock.instant());
|
||||
} catch (ExecutionException failure) {
|
||||
log.warn("Не удалось проверить tenant-БД '{}': errorType={}",
|
||||
target.domain(), failure.getCause() == null
|
||||
? failure.getClass().getSimpleName()
|
||||
: failure.getCause().getClass().getSimpleName());
|
||||
return new CheckResult(false, clock.instant());
|
||||
}
|
||||
}
|
||||
|
||||
private Duration requirePositive(Duration value, String message) {
|
||||
Objects.requireNonNull(value, message);
|
||||
if (value.isNegative() || value.isZero()) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public record RefreshSummary(int requested,
|
||||
int available,
|
||||
int unavailable,
|
||||
boolean skipped) {
|
||||
}
|
||||
|
||||
private record CheckResult(boolean connected, Instant checkedAt) {
|
||||
private CheckResult {
|
||||
Objects.requireNonNull(checkedAt, "Время завершения проверки tenant-БД обязательно");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HealthThreadFactory implements ThreadFactory {
|
||||
private final AtomicInteger sequence = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public Thread newThread(Runnable task) {
|
||||
Thread thread = new Thread(task,
|
||||
"проверка-готовности-tenant-бд-" + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.magistr.app.config.tenant.health;
|
||||
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Агрегированный индикатор readiness без раскрытия доменов и реквизитов tenant-БД.
|
||||
*/
|
||||
@Component("tenantReadiness")
|
||||
public class TenantReadinessHealthIndicator implements HealthIndicator {
|
||||
|
||||
private final TenantReadinessRegistry registry;
|
||||
private final TenantDatabaseHealthMonitor monitor;
|
||||
|
||||
public TenantReadinessHealthIndicator(TenantReadinessRegistry registry,
|
||||
TenantDatabaseHealthMonitor monitor) {
|
||||
this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
|
||||
this.monitor = Objects.requireNonNull(monitor, "Монитор tenant-БД обязателен");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return readiness().ready()
|
||||
? Health.up().build()
|
||||
: Health.down().build();
|
||||
}
|
||||
|
||||
public TenantReadinessRegistry.AggregateReadiness readiness() {
|
||||
return registry.aggregate(monitor.maxStaleness());
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return readiness().ready();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.magistr.app.config.tenant.health;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Потокобезопасный реестр готовности обязательных tenant-БД.
|
||||
*
|
||||
* <p>Каждое изменение публикуется единым неизменяемым снимком. Результат фоновой
|
||||
* проверки соединения привязан к непрозрачному fingerprint конфигурации, поэтому
|
||||
* запоздалый результат для старых реквизитов не изменит состояние новой конфигурации.</p>
|
||||
*/
|
||||
@Component
|
||||
public class TenantReadinessRegistry {
|
||||
|
||||
private static final String FINGERPRINT_ALGORITHM = "HmacSHA256";
|
||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
|
||||
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
);
|
||||
|
||||
private final AtomicReference<RegistryState> state = new AtomicReference<>(RegistryState.empty());
|
||||
private final Clock clock;
|
||||
private final byte[] fingerprintKey;
|
||||
|
||||
public TenantReadinessRegistry() {
|
||||
this(Clock.systemUTC(), randomFingerprintKey());
|
||||
}
|
||||
|
||||
TenantReadinessRegistry(Clock clock, byte[] fingerprintKey) {
|
||||
this.clock = Objects.requireNonNull(clock, "Часы реестра готовности обязательны");
|
||||
if (fingerprintKey == null || fingerprintKey.length < 16) {
|
||||
throw new IllegalArgumentException("Ключ fingerprint должен содержать не менее 16 байт");
|
||||
}
|
||||
this.fingerprintKey = fingerprintKey.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью заменяет список обязательных конфигураций.
|
||||
* Состояние неизменившегося tenant сохраняется, изменившегося — сбрасывается.
|
||||
*/
|
||||
public void replaceDesired(List<TenantConfig> desired) {
|
||||
boolean missingDesired = desired == null;
|
||||
List<TenantConfig> safeDesired = desired == null ? List.of() : new ArrayList<>(desired);
|
||||
mutate(current -> {
|
||||
Map<String, TenantStatus> replacement = new LinkedHashMap<>();
|
||||
boolean invalidConfiguration = missingDesired;
|
||||
|
||||
for (TenantConfig config : safeDesired) {
|
||||
NormalizedConfig normalized = normalize(config);
|
||||
if (normalized == null || replacement.containsKey(normalized.domain())) {
|
||||
invalidConfiguration = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
String fingerprint = fingerprint(normalized);
|
||||
TenantStatus previous = current.tenants().get(normalized.domain());
|
||||
if (previous != null && previous.fingerprint().equals(fingerprint)) {
|
||||
replacement.put(normalized.domain(), previous);
|
||||
} else {
|
||||
replacement.put(normalized.domain(), TenantStatus.pending(
|
||||
normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant()));
|
||||
}
|
||||
}
|
||||
|
||||
return new RegistryState(replacement,
|
||||
current.configurationFailure() || invalidConfiguration);
|
||||
});
|
||||
}
|
||||
|
||||
/** Удаляет tenant из обязательного набора. */
|
||||
public void removeDesired(String domain) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
mutate(current -> {
|
||||
if (!current.tenants().containsKey(normalizedDomain)) {
|
||||
return current;
|
||||
}
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.remove(normalizedDomain);
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
}
|
||||
|
||||
public void markMigrationStarted(TenantConfig config) {
|
||||
updateByConfig(config, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationSucceeded(TenantConfig config) {
|
||||
updateByConfig(config, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationFailed(TenantConfig config) {
|
||||
updateByConfig(config, MigrationState.FAILED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationStarted(String domain) {
|
||||
updateExisting(domain, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationSucceeded(String domain) {
|
||||
updateExisting(domain, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
public void markMigrationFailed(String domain) {
|
||||
updateExisting(domain, MigrationState.FAILED, ConnectivityState.UNKNOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет результат проверки текущей конфигурации tenant.
|
||||
*/
|
||||
public boolean markConnectivity(String domain, boolean connected) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
TenantStatus current = state.get().tenants().get(normalizedDomain);
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
return markConnectivity(normalizedDomain, current.fingerprint(), connected, clock.instant());
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет результат только если fingerprint всё ещё соответствует проверенной конфигурации.
|
||||
*/
|
||||
public boolean markConnectivity(String domain,
|
||||
String expectedFingerprint,
|
||||
boolean connected,
|
||||
Instant checkedAt) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
Objects.requireNonNull(expectedFingerprint, "Fingerprint проверяемой конфигурации обязателен");
|
||||
Objects.requireNonNull(checkedAt, "Время проверки соединения обязательно");
|
||||
|
||||
AtomicReference<Boolean> applied = new AtomicReference<>(false);
|
||||
mutate(current -> {
|
||||
applied.set(false);
|
||||
TenantStatus previous = current.tenants().get(normalizedDomain);
|
||||
if (previous == null || !previous.fingerprint().equals(expectedFingerprint)
|
||||
|| previous.migrationState() != MigrationState.SUCCEEDED
|
||||
|| previous.h2Placeholder()) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (previous.connectivityCheckedAt() != null
|
||||
&& checkedAt.isBefore(previous.connectivityCheckedAt())) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.put(normalizedDomain, previous.withConnectivity(
|
||||
connected ? ConnectivityState.AVAILABLE : ConnectivityState.UNAVAILABLE,
|
||||
checkedAt,
|
||||
clock.instant()));
|
||||
applied.set(true);
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
return applied.get();
|
||||
}
|
||||
|
||||
/** Отмечает ошибку чтения или разбора общей конфигурации без сохранения её текста. */
|
||||
public void markConfigurationFailure() {
|
||||
mutate(current -> new RegistryState(current.tenants(), true));
|
||||
}
|
||||
|
||||
public void clearConfigurationFailure() {
|
||||
mutate(current -> new RegistryState(current.tenants(), false));
|
||||
}
|
||||
|
||||
/** Возвращает безопасные задания для фоновой проверки соединений. */
|
||||
public List<ConnectivityTarget> connectivityTargets() {
|
||||
List<ConnectivityTarget> targets = new ArrayList<>();
|
||||
state.get().tenants().values().stream()
|
||||
.filter(status -> status.migrationState() == MigrationState.SUCCEEDED)
|
||||
.filter(status -> !status.h2Placeholder())
|
||||
.forEach(status -> targets.add(new ConnectivityTarget(status.domain(), status.fingerprint())));
|
||||
return List.copyOf(targets);
|
||||
}
|
||||
|
||||
/** Формирует агрегированную готовность на текущий момент. */
|
||||
public AggregateReadiness aggregate(Duration maxStaleness) {
|
||||
Objects.requireNonNull(maxStaleness, "Максимальный возраст проверки обязателен");
|
||||
if (maxStaleness.isNegative() || maxStaleness.isZero()) {
|
||||
throw new IllegalArgumentException("Максимальный возраст проверки должен быть положительным");
|
||||
}
|
||||
|
||||
RegistryState snapshot = state.get();
|
||||
Instant assessedAt = clock.instant();
|
||||
Instant oldestAllowed = assessedAt.minus(maxStaleness);
|
||||
int ready = 0;
|
||||
int migrationFailures = 0;
|
||||
int migrationPending = 0;
|
||||
int staleConnectivity = 0;
|
||||
int unavailableConnectivity = 0;
|
||||
int unsupported = 0;
|
||||
|
||||
for (TenantStatus status : snapshot.tenants().values()) {
|
||||
if (status.h2Placeholder()) {
|
||||
unsupported++;
|
||||
continue;
|
||||
}
|
||||
if (status.migrationState() == MigrationState.FAILED) {
|
||||
migrationFailures++;
|
||||
continue;
|
||||
}
|
||||
if (status.migrationState() != MigrationState.SUCCEEDED) {
|
||||
migrationPending++;
|
||||
continue;
|
||||
}
|
||||
if (status.connectivityCheckedAt() == null
|
||||
|| status.connectivityCheckedAt().isBefore(oldestAllowed)) {
|
||||
staleConnectivity++;
|
||||
continue;
|
||||
}
|
||||
if (status.connectivityState() != ConnectivityState.AVAILABLE) {
|
||||
unavailableConnectivity++;
|
||||
continue;
|
||||
}
|
||||
ready++;
|
||||
}
|
||||
|
||||
int desiredCount = snapshot.tenants().size();
|
||||
boolean isReady = !snapshot.configurationFailure()
|
||||
&& desiredCount > 0
|
||||
&& unsupported == 0
|
||||
&& ready == desiredCount;
|
||||
return new AggregateReadiness(
|
||||
isReady,
|
||||
isReady ? "ГОТОВ" : "НЕ ГОТОВ",
|
||||
desiredCount,
|
||||
ready,
|
||||
desiredCount - ready,
|
||||
migrationPending,
|
||||
migrationFailures,
|
||||
unavailableConnectivity,
|
||||
staleConnectivity,
|
||||
unsupported,
|
||||
snapshot.configurationFailure(),
|
||||
assessedAt
|
||||
);
|
||||
}
|
||||
|
||||
/** Неизменяемый диагностический снимок для внутренних тестов и интеграции. */
|
||||
public Map<String, TenantStatus> snapshot() {
|
||||
return state.get().tenants();
|
||||
}
|
||||
|
||||
private void updateByConfig(TenantConfig config,
|
||||
MigrationState migrationState,
|
||||
ConnectivityState connectivityState) {
|
||||
NormalizedConfig normalized = normalize(config);
|
||||
if (normalized == null) {
|
||||
markConfigurationFailure();
|
||||
return;
|
||||
}
|
||||
String fingerprint = fingerprint(normalized);
|
||||
mutate(current -> {
|
||||
TenantStatus previous = current.tenants().get(normalized.domain());
|
||||
TenantStatus base = previous != null && previous.fingerprint().equals(fingerprint)
|
||||
? previous
|
||||
: TenantStatus.pending(normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant());
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.put(normalized.domain(), base.withMigration(migrationState, connectivityState, clock.instant()));
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
}
|
||||
|
||||
private void updateExisting(String domain,
|
||||
MigrationState migrationState,
|
||||
ConnectivityState connectivityState) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
mutate(current -> {
|
||||
TenantStatus previous = current.tenants().get(normalizedDomain);
|
||||
if (previous == null) {
|
||||
return current;
|
||||
}
|
||||
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||
updated.put(normalizedDomain, previous.withMigration(migrationState, connectivityState, clock.instant()));
|
||||
return new RegistryState(updated, current.configurationFailure());
|
||||
});
|
||||
}
|
||||
|
||||
private void mutate(UnaryOperator<RegistryState> mutation) {
|
||||
RegistryState current;
|
||||
RegistryState updated;
|
||||
do {
|
||||
current = state.get();
|
||||
updated = Objects.requireNonNull(mutation.apply(current), "Новое состояние реестра обязательно");
|
||||
if (updated == current) {
|
||||
return;
|
||||
}
|
||||
} while (!state.compareAndSet(current, updated));
|
||||
}
|
||||
|
||||
private NormalizedConfig normalize(TenantConfig config) {
|
||||
if (config == null || config.getDomain() == null || config.getDomain().isBlank()
|
||||
|| config.getUrl() == null || config.getUrl().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String domain = config.getDomain().trim().toLowerCase(Locale.ROOT);
|
||||
String url = config.getUrl().trim();
|
||||
if (!DOMAIN_PATTERN.matcher(domain).matches()) {
|
||||
return null;
|
||||
}
|
||||
String name = config.getName() == null || config.getName().isBlank()
|
||||
? domain
|
||||
: config.getName().trim();
|
||||
return new NormalizedConfig(
|
||||
name,
|
||||
domain,
|
||||
url,
|
||||
config.getUsername(),
|
||||
config.getPassword(),
|
||||
url.startsWith("jdbc:h2:")
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeDomain(String domain) {
|
||||
if (domain == null || domain.isBlank()) {
|
||||
throw new IllegalArgumentException("Домен tenant не может быть пустым");
|
||||
}
|
||||
return domain.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String fingerprint(NormalizedConfig normalized) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(FINGERPRINT_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(fingerprintKey, FINGERPRINT_ALGORITHM));
|
||||
updateFingerprint(mac, normalized.name());
|
||||
updateFingerprint(mac, normalized.domain());
|
||||
updateFingerprint(mac, normalized.url());
|
||||
updateFingerprint(mac, normalized.username());
|
||||
updateFingerprint(mac, normalized.password());
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal());
|
||||
} catch (GeneralSecurityException cryptoFailure) {
|
||||
throw new IllegalStateException("Не удалось сформировать безопасный fingerprint конфигурации", cryptoFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFingerprint(Mac mac, String value) {
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
mac.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
mac.update(bytes);
|
||||
}
|
||||
|
||||
private static byte[] randomFingerprintKey() {
|
||||
byte[] key = new byte[32];
|
||||
new SecureRandom().nextBytes(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
public enum MigrationState {
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
public enum ConnectivityState {
|
||||
UNKNOWN,
|
||||
AVAILABLE,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public record ConnectivityTarget(String domain, String fingerprint) {
|
||||
public ConnectivityTarget {
|
||||
Objects.requireNonNull(domain, "Домен проверки обязателен");
|
||||
Objects.requireNonNull(fingerprint, "Fingerprint проверки обязателен");
|
||||
}
|
||||
}
|
||||
|
||||
public record TenantStatus(String domain,
|
||||
String fingerprint,
|
||||
MigrationState migrationState,
|
||||
ConnectivityState connectivityState,
|
||||
Instant connectivityCheckedAt,
|
||||
boolean h2Placeholder,
|
||||
Instant changedAt) {
|
||||
public TenantStatus {
|
||||
Objects.requireNonNull(domain, "Домен состояния обязателен");
|
||||
Objects.requireNonNull(fingerprint, "Fingerprint состояния обязателен");
|
||||
Objects.requireNonNull(migrationState, "Состояние миграции обязательно");
|
||||
Objects.requireNonNull(connectivityState, "Состояние соединения обязательно");
|
||||
Objects.requireNonNull(changedAt, "Время изменения состояния обязательно");
|
||||
}
|
||||
|
||||
private static TenantStatus pending(String domain,
|
||||
String fingerprint,
|
||||
boolean h2Placeholder,
|
||||
Instant changedAt) {
|
||||
return new TenantStatus(domain, fingerprint, MigrationState.PENDING,
|
||||
ConnectivityState.UNKNOWN, null, h2Placeholder, changedAt);
|
||||
}
|
||||
|
||||
private TenantStatus withMigration(MigrationState newMigrationState,
|
||||
ConnectivityState newConnectivityState,
|
||||
Instant now) {
|
||||
return new TenantStatus(domain, fingerprint, newMigrationState,
|
||||
newConnectivityState, null, h2Placeholder, now);
|
||||
}
|
||||
|
||||
private TenantStatus withConnectivity(ConnectivityState newConnectivityState,
|
||||
Instant checkedAt,
|
||||
Instant now) {
|
||||
return new TenantStatus(domain, fingerprint, migrationState,
|
||||
newConnectivityState, checkedAt, h2Placeholder, now);
|
||||
}
|
||||
}
|
||||
|
||||
public record AggregateReadiness(boolean ready,
|
||||
String status,
|
||||
int desiredTenants,
|
||||
int readyTenants,
|
||||
int unavailableTenants,
|
||||
int pendingMigrations,
|
||||
int failedMigrations,
|
||||
int unavailableConnections,
|
||||
int staleConnections,
|
||||
int unsupportedTenants,
|
||||
boolean configurationFailure,
|
||||
Instant assessedAt) {
|
||||
public AggregateReadiness {
|
||||
Objects.requireNonNull(status, "Текст готовности обязателен");
|
||||
Objects.requireNonNull(assessedAt, "Время оценки готовности обязательно");
|
||||
}
|
||||
}
|
||||
|
||||
private record NormalizedConfig(String name,
|
||||
String domain,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
boolean h2Placeholder) {
|
||||
}
|
||||
|
||||
private record RegistryState(Map<String, TenantStatus> tenants, boolean configurationFailure) {
|
||||
private RegistryState {
|
||||
tenants = Collections.unmodifiableMap(new LinkedHashMap<>(tenants));
|
||||
}
|
||||
|
||||
private static RegistryState empty() {
|
||||
return new RegistryState(Map.of(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigPersistenceException;
|
||||
import com.magistr.app.config.tenant.TenantConfigStore;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.config.tenant.TenantSecretCompensationResult;
|
||||
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,18 +35,21 @@ public class TenantLifecycleService {
|
||||
);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final KubernetesTenantSecretUpdater tenantSecretUpdater;
|
||||
private final TenantConfigStore tenantConfigStore;
|
||||
private final TenantDatabaseMigrationService migrationService;
|
||||
private final RetiredTenantPoolService retiredPoolService;
|
||||
private final TenantReadinessRegistry readinessRegistry;
|
||||
|
||||
public TenantLifecycleService(TenantRoutingDataSource routingDataSource,
|
||||
KubernetesTenantSecretUpdater tenantSecretUpdater,
|
||||
TenantConfigStore tenantConfigStore,
|
||||
TenantDatabaseMigrationService migrationService,
|
||||
RetiredTenantPoolService retiredPoolService) {
|
||||
RetiredTenantPoolService retiredPoolService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.tenantSecretUpdater = tenantSecretUpdater;
|
||||
this.tenantConfigStore = tenantConfigStore;
|
||||
this.migrationService = migrationService;
|
||||
this.retiredPoolService = retiredPoolService;
|
||||
this.readinessRegistry = readinessRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,16 +83,17 @@ public class TenantLifecycleService {
|
||||
candidate = prepareCandidate(config);
|
||||
verifyConnection(candidate);
|
||||
migrateCandidate(candidate);
|
||||
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
|
||||
TenantSecretUpdateReceipt receipt = persistUpsert(config);
|
||||
|
||||
TenantRoutingDataSource.TenantState previous;
|
||||
try {
|
||||
previous = routingDataSource.swapTenant(config, candidate);
|
||||
} catch (RuntimeException activationFailure) {
|
||||
throw activationFailureAfterCompensation(oldConfigs, activationFailure);
|
||||
throw activationFailureAfterCompensation(receipt, activationFailure);
|
||||
}
|
||||
|
||||
activated = true;
|
||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
||||
return copyConfig(config);
|
||||
@@ -109,7 +117,7 @@ public class TenantLifecycleService {
|
||||
|
||||
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
||||
desiredConfigs.remove(domain);
|
||||
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
|
||||
TenantSecretUpdateReceipt receipt = persistRemove(domain);
|
||||
|
||||
TenantRoutingDataSource.TenantState removed;
|
||||
try {
|
||||
@@ -118,9 +126,10 @@ public class TenantLifecycleService {
|
||||
throw new IllegalStateException("Локальное состояние тенанта отсутствует");
|
||||
}
|
||||
} catch (RuntimeException removalFailure) {
|
||||
throw removalFailureAfterCompensation(oldConfigs, removalFailure);
|
||||
throw removalFailureAfterCompensation(receipt, removalFailure);
|
||||
}
|
||||
|
||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
|
||||
retireDataSource(removed.dataSource());
|
||||
log.info("Lifecycle тенанта '{}' удалён", domain);
|
||||
return copyConfig(existing);
|
||||
@@ -142,6 +151,12 @@ public class TenantLifecycleService {
|
||||
.map(TenantConfig::getDomain)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(configs);
|
||||
if (desiredDomains.size() != configs.size()) {
|
||||
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
||||
}
|
||||
|
||||
for (TenantConfig config : configs) {
|
||||
if (!routingDataSource.hasTenant(config.getDomain())) {
|
||||
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||
@@ -162,13 +177,19 @@ public class TenantLifecycleService {
|
||||
private void activatePersistedConfig(TenantConfig config) {
|
||||
HikariDataSource candidate = null;
|
||||
boolean activated = false;
|
||||
readinessRegistry.markMigrationStarted(config);
|
||||
try {
|
||||
candidate = prepareCandidate(config);
|
||||
verifyConnection(candidate);
|
||||
migrateCandidate(candidate);
|
||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
|
||||
activated = true;
|
||||
readinessRegistry.markMigrationSucceeded(config);
|
||||
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
} catch (RuntimeException activationFailure) {
|
||||
readinessRegistry.markMigrationFailed(config);
|
||||
throw activationFailure;
|
||||
} finally {
|
||||
if (!activated) {
|
||||
closeCandidateDataSource(candidate);
|
||||
@@ -208,63 +229,117 @@ public class TenantLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDesired(Map<String, TenantConfig> desiredConfigs) {
|
||||
boolean persisted;
|
||||
private TenantSecretUpdateReceipt persistUpsert(TenantConfig config) {
|
||||
try {
|
||||
persisted = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(desiredConfigs));
|
||||
return requireReceipt(tenantConfigStore.upsertTenant(copyConfig(config)));
|
||||
} catch (TenantConfigPersistenceException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure);
|
||||
} catch (RuntimeException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
||||
}
|
||||
if (!persisted) {
|
||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов");
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDesiredWithCompensation(Map<String, TenantConfig> desiredConfigs,
|
||||
Map<String, TenantConfig> oldConfigs) {
|
||||
private TenantSecretUpdateReceipt persistRemove(String domain) {
|
||||
try {
|
||||
persistDesired(desiredConfigs);
|
||||
} catch (TenantLifecycleException persistenceFailure) {
|
||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
||||
String message = restored
|
||||
? "Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"
|
||||
: "Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret";
|
||||
throw new TenantLifecycleException(message, persistenceFailure);
|
||||
return requireReceipt(tenantConfigStore.removeTenant(domain));
|
||||
} catch (TenantConfigPersistenceException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure);
|
||||
} catch (RuntimeException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private TenantLifecycleException activationFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
|
||||
RuntimeException activationFailure) {
|
||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
||||
private TenantSecretUpdateReceipt requireReceipt(TenantSecretUpdateReceipt receipt) {
|
||||
if (receipt == null) {
|
||||
throw new TenantConfigPersistenceException("Хранилище конфигурации тенантов не подтвердило операцию");
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private TenantLifecycleException activationFailureAfterCompensation(TenantSecretUpdateReceipt receipt,
|
||||
RuntimeException activationFailure) {
|
||||
TenantSecretCompensationResult compensation = compensate(receipt);
|
||||
logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure);
|
||||
String message = restored
|
||||
? "Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена"
|
||||
: "Не удалось активировать подключение тенанта; требуется проверить tenant Secret";
|
||||
String message = compensationMessage(
|
||||
"Не удалось активировать подключение тенанта",
|
||||
compensation
|
||||
);
|
||||
return new TenantLifecycleException(message, activationFailure);
|
||||
}
|
||||
|
||||
private TenantLifecycleException removalFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
|
||||
RuntimeException removalFailure) {
|
||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
||||
private TenantLifecycleException removalFailureAfterCompensation(TenantSecretUpdateReceipt receipt,
|
||||
RuntimeException removalFailure) {
|
||||
TenantSecretCompensationResult compensation = compensate(receipt);
|
||||
logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure);
|
||||
String message = restored
|
||||
? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена"
|
||||
: "Не удалось отключить тенанта; требуется проверить tenant Secret";
|
||||
String message = compensationMessage("Не удалось отключить тенанта", compensation);
|
||||
return new TenantLifecycleException(message, removalFailure);
|
||||
}
|
||||
|
||||
private boolean restorePersistedSnapshot(Map<String, TenantConfig> oldConfigs) {
|
||||
private TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
|
||||
try {
|
||||
boolean restored = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(oldConfigs));
|
||||
if (!restored) {
|
||||
log.error("Не удалось компенсирующе восстановить tenant Secret");
|
||||
TenantSecretCompensationResult result = tenantConfigStore.compensate(receipt);
|
||||
if (result == null) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
log.error("Хранилище конфигурации не вернуло результат компенсации tenant Secret");
|
||||
return null;
|
||||
}
|
||||
return restored;
|
||||
if (result == TenantSecretCompensationResult.RESTORED && receipt.persisted()) {
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(receipt.previousTenants());
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.NOT_REQUIRED && receipt.persisted()) {
|
||||
readinessRegistry.replaceDesired(receipt.committedTenants());
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE
|
||||
|| (result == TenantSecretCompensationResult.NOT_REQUIRED && receipt.persisted())) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
}
|
||||
return result;
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой",
|
||||
compensationFailure);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String compensationMessage(String prefix, TenantSecretCompensationResult result) {
|
||||
if (result == TenantSecretCompensationResult.RESTORED) {
|
||||
return prefix + "; сохранённая конфигурация восстановлена";
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.NOT_REQUIRED) {
|
||||
return prefix + "; tenant Secret не требовал восстановления";
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE) {
|
||||
return prefix + "; tenant Secret уже изменён другим экземпляром, автоматический откат пропущен";
|
||||
}
|
||||
return prefix + "; требуется проверить tenant Secret";
|
||||
}
|
||||
|
||||
private void publishSuccessfulMutation(TenantSecretUpdateReceipt receipt,
|
||||
List<TenantConfig> localDesired,
|
||||
TenantConfig migratedConfig) {
|
||||
try {
|
||||
List<TenantConfig> canonicalDesired = receipt.persisted()
|
||||
? receipt.committedTenants()
|
||||
: localDesired.stream()
|
||||
.filter(config -> !isH2Placeholder(config))
|
||||
.toList();
|
||||
if (receipt.persisted()) {
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
}
|
||||
readinessRegistry.replaceDesired(canonicalDesired);
|
||||
if (migratedConfig != null) {
|
||||
readinessRegistry.markMigrationSucceeded(migratedConfig);
|
||||
readinessRegistry.markConnectivity(migratedConfig.getDomain(), true);
|
||||
}
|
||||
} catch (RuntimeException healthFailure) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
logTechnicalFailure("Не удалось обновить состояние readiness после tenant lifecycle", healthFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +352,13 @@ public class TenantLifecycleService {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private boolean isH2Placeholder(TenantConfig config) {
|
||||
return config != null
|
||||
&& "H2 Placeholder".equals(config.getName())
|
||||
&& config.getUrl() != null
|
||||
&& config.getUrl().trim().startsWith("jdbc:h2:mem:placeholder");
|
||||
}
|
||||
|
||||
private TenantConfig validateAndNormalize(TenantConfig config) {
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
|
||||
|
||||
Reference in New Issue
Block a user