Skip to content
Open
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
@@ -0,0 +1,72 @@
package com.epam.aidial.core.server.config;

import com.epam.aidial.core.metaschemas.CatalogMetaSchemaHolder;
import com.epam.aidial.core.metaschemas.MetaSchemaHolder;
import com.epam.aidial.core.storage.resource.ResourceTypes;
import com.fasterxml.jackson.databind.JsonNode;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.regex.Pattern;

/**
* Mints a canonical blob name for a file-sourced app-type/catalog schema being migrated into the
* {@code platform} bucket. Canonical id for schemas is decoupled from {@code $id}, so migration is
* free to invent any valid, unique, deterministic name — deterministic so a dry-run preview matches
* the real run and re-runs don't mint a second name for the same schema. Idempotency itself does not
* depend on this name being reproduced: the caller decides "already migrated" via the {@code $id}
* alias index, not by recomputing this name.
*/
public final class SchemaMigrationNaming {

private static final int MAX_SLUG_LENGTH = 40;
private static final Pattern DISALLOWED_CHARS = Pattern.compile("[^a-z0-9._-]+");
private static final Pattern REPEATED_DASHES = Pattern.compile("-{2,}");

private SchemaMigrationNaming() {
}

public static String mintName(ResourceTypes type, JsonNode body) {
String displayNameField = switch (type) {
case APP_TYPE_SCHEMA -> MetaSchemaHolder.APPLICATION_TYPE_DISPLAY_NAME;
case CATALOG_SCHEMA -> CatalogMetaSchemaHolder.CATALOG_DISPLAY_NAME;
default -> throw new IllegalArgumentException("Not a schema resource type: " + type);
};
String id = body.path("$id").asText(null);
String displayName = body.path(displayNameField).asText(null);
// The full digest (not a truncated prefix) is used so two different $id values cannot
// collide on name — a truncated hash would only be probabilistically unique.
String hash = sha256Hex(id);
String slug = sanitize(displayName);
return slug.isEmpty() ? hash : slug + "-" + hash;
}

private static String sanitize(String displayName) {
if (displayName == null) {
return "";
}
String lower = displayName.toLowerCase();
String replaced = DISALLOWED_CHARS.matcher(lower).replaceAll("-");
String collapsed = REPEATED_DASHES.matcher(replaced).replaceAll("-");
// Without this, a display name that starts/ends with a disallowed character (e.g. a leading
// space) leaves a leading/trailing '-' from the replacement above, producing an ugly
// double-dash once the hash suffix is appended (e.g. "-foo-" + "-" + hash).
String trimmed = collapsed.replaceAll("^-+|-+$", "");
return trimmed.length() > MAX_SLUG_LENGTH ? trimmed.substring(0, MAX_SLUG_LENGTH) : trimmed;
}

private static String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ public class AdminApplyController {
"ToolSet", 7,
"Application", 8);

/**
* Sorts a manifest list so a kind that other kinds can reference (e.g. {@code Interceptor})
* is always applied before the kind referencing it (e.g. {@code Model}) — required whenever
* more than one kind in the same batch/call can cross-reference another by name. Shared by
* {@link #applyBatch} and {@link ConfigFileMigrateController}, so the one ordering definition
* governs both.
*/
static final Comparator<AdminManifest> DEPENDENCY_ORDER_COMPARATOR =
Comparator.comparingInt(entry -> DEPENDENCY_ORDER.getOrDefault(entry.kind(), 99));

static final Map<String, String> KIND_URL_SEGMENT = Map.of(
"Settings", "settings",
"Schema", "schemas",
Expand Down Expand Up @@ -198,40 +208,51 @@ private void process(Buffer body) {

private ApplyResponse applyBatch(boolean precheck, List<AdminManifest> rawEntries) {
List<AdminManifest> entries = new ArrayList<>(rawEntries);
entries.sort(Comparator.comparingInt(e -> DEPENDENCY_ORDER.getOrDefault(e.kind(), 99)));
entries.sort(DEPENDENCY_ORDER_COMPARATOR);

Config scratch = newScratch(mergedConfigStore);
List<EntityResult> results = new ArrayList<>();

if (precheck) {
List<EntityResult> precheckResults = new ArrayList<>();
boolean anyFailure = false;
for (AdminManifest entry : entries) {
ValidationResult result = validateOnly(entry, scratch, softValidation);
if (!ValidationStatus.VALID.equals(result.status())) {
anyFailure = true;
// Mirror /v1/admin/validate: the offending entry stays FAILED (carrying its
// error); only the valid siblings collapse to "skipped" below.
results.add(new EntityResult(result.entityId(), AdminApplyStatus.FAILED, result.error()));
precheckResults.add(new EntityResult(result.entityId(), AdminApplyStatus.FAILED, result.error()));
} else {
// Mutate scratch so subsequent precheck entries see prior ones — even though we
// aren't writing yet, reference resolution depends on the cumulative scratch.
mutateScratch(scratch, entry);
results.add(new EntityResult(result.entityId(), AdminApplyStatus.SKIPPED, null));
precheckResults.add(new EntityResult(result.entityId(), AdminApplyStatus.SKIPPED, null));
}
}
if (anyFailure) {
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, results);
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, precheckResults);
}
// Precheck passed — wipe and re-run as real writes.
scratch = newScratch(mergedConfigStore);
results.clear();
}

boolean anyApplied = false;
return buildResponse(HttpStatus.OK, applyEntries(entries, scratch));
}

/**
* Applies each entry via {@link #applySingle}, mutating {@code scratch} after every success so
* later entries in the same list see earlier ones, then flushes every in-memory change as a
* single partial-update swap. Shared by {@link #applyBatch}'s real-apply phase and
* {@link ConfigFileMigrateController}, which builds its own entry list and scratch (with any
* shadowed file entries already removed) before calling this.
*/
List<EntityResult> applyEntries(List<AdminManifest> entries, Config scratch) {
List<EntityResult> results = new ArrayList<>();
// Slice 4S.4: collect partial-update changes per applied entity; flush as one applyBatch
// after the apply loop so the merged Config swap happens once, after all blobs are written.
List<EntityChange> pendingChanges = new ArrayList<>();
GlobalSettings pendingSettings = null;
boolean anyApplied = false;
for (AdminManifest entry : entries) {
EntityResult result;
try {
Expand Down Expand Up @@ -261,7 +282,7 @@ private ApplyResponse applyBatch(boolean precheck, List<AdminManifest> rawEntrie
mergedConfigStore.applySettingsWrite(pendingSettings);
}
}
return buildResponse(HttpStatus.OK, results);
return results;
}

static Config newScratch(MergedConfigStore mergedConfigStore) {
Expand Down Expand Up @@ -753,7 +774,7 @@ private ApplyResponse buildResponse(HttpStatus status, List<EntityResult> result
return new ApplyResponse(status, new AdminApplyResponse(applied, failed, responseResults));
}

private record EntityResult(String entityId, AdminApplyStatus status, String error) {}
record EntityResult(String entityId, AdminApplyStatus status, String error) {}

@JsonInclude(JsonInclude.Include.NON_NULL)
private record ApplyResponse(HttpStatus status, AdminApplyResponse body) {}
Expand Down
Loading