Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e6a9268
feat(calm-hub): add ResourceVersion to own the snapshot suffix format
willosborne Sep 18, 2026
e47cefe
feat(calm-hub): fold snapshot version spellings onto one document key
willosborne Sep 18, 2026
86b7a65
feat(calm-hub): parse and order snapshot versions by their release ve…
willosborne Sep 18, 2026
acc49e3
feat(calm-hub): accept snapshot versions on namespace resource endpoints
willosborne Sep 18, 2026
30f6f2e
feat(calm-hub): add single-version delete to both version document st…
willosborne Sep 18, 2026
c41fa82
test(calm-hub): strengthen deleteVersion tests to assert filter/updat…
willosborne Sep 18, 2026
fd8c293
feat(calm-hub): add version update to standard and interface stores
willosborne Sep 18, 2026
79b97ec
test(calm-hub): pin version-before-header write order for update paths
willosborne Sep 18, 2026
b3a3018
feat(calm-hub): make snapshot creation idempotent and refuse shadowed…
willosborne Sep 18, 2026
b290810
test(calm-hub): cover snapshot-overwrite for standard and interface t…
willosborne Sep 18, 2026
ab3d364
feat(calm-hub): delete a snapshot when its release version is published
willosborne Sep 18, 2026
6f27da8
fix(calm-hub): canonicalise the release spelling before deleting its …
willosborne Sep 18, 2026
d8e7987
test(calm-hub): cover the ten store deleteXVersion delegates directly
willosborne Sep 18, 2026
a086b5a
feat(calm-hub): allow a new resource to start at a snapshot version
willosborne Sep 18, 2026
e085d60
feat(calm-hub): audit snapshot deletion as a delete, pin timeline sna…
willosborne Sep 18, 2026
22a7acd
fix(calm-hub): accept snapshot ids on POST /calm, keep the release ro…
willosborne Sep 18, 2026
61fb164
test(calm-hub): cover the snapshot lifecycle end to end
willosborne Sep 18, 2026
3ba7b7d
fix(calm-hub): scope snapshot versions back out of the numeric-ID API
willosborne Sep 19, 2026
1438ad1
fix(calm-hub): canonicalise the overwrite check in addNewVersion
willosborne Sep 19, 2026
b5da4ba
test(calm-hub): assert Timeline and Control reject snapshot versions
willosborne Sep 19, 2026
ab80e10
refactor(calm-hub): derive SNAPSHOT_VERSION_REGEX from ResourceVersion
willosborne Sep 19, 2026
d48fe9c
Merge branch 'main' into feat/calm-hub-snapshots
willosborne Sep 21, 2026
8a7cb28
fix(calm-hub): cleanup excessive coments
willosborne Sep 21, 2026
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,196 @@
package integration;

import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoDatabase;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import org.eclipse.microprofile.config.ConfigProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static io.restassured.RestAssured.given;
import static integration.MongoSetup.counterSetup;
import static integration.MongoSetup.namespaceSetup;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;

/**
* End-to-end lifecycle for a {@code -SNAPSHOT} version, exercised against a real MongoDB
* (TestContainers) rather than mocked stores. Promotion is a create-then-delete sequence
* across two separate store operations with no transaction, and {@code versionCount}
* bookkeeping spans a header document and version documents β€” a mocked test cannot catch a
* failure in either half of that sequence.
*
* <p>Uses the shared Mongo container and the "finos" namespace already seeded by
* {@link MongoSetup#namespaceSetup}, like every other Mongo*Integration test β€” the
* "lifecycle" custom ID is unique to this class, so it does not collide with resources
* created by the other suites sharing that container.</p>
*/
@QuarkusTest
@TestProfile(IntegrationTestProfile.class)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class SnapshotLifecycleIntegration {

private static final Logger logger = LoggerFactory.getLogger(SnapshotLifecycleIntegration.class);

// MappingControllerResource is mounted at "/calm" with no "/api" prefix (that prefix
// belongs to the separate numeric-ID resources such as ArchitectureResource).
private static final String PATH_BASE = "/calm/namespaces/finos/architectures/lifecycle";

// The $id must equal the full canonical URL, including the base URL that
// IntegrationTestProfile configures via calm.hub.base-url β€” otherwise
// CalmDocumentParser.resolveAndVerify rejects the request with 400.
private static final String ID_BASE = "http://localhost:8080" + PATH_BASE;

// Same idempotent setup as every other Mongo*Integration class sharing this container β€”
// required because test-class execution order across the suite is not guaranteed, and
// this class does not otherwise touch the counters/namespaces collections that other
// classes' @BeforeEach primes.
@BeforeEach
void setup() {
String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class);
String mongoDatabase = ConfigProvider.getConfig().getValue("quarkus.mongodb.database", String.class);

if (mongoUri == null || mongoUri.isBlank()) {
logger.error("MongoDB URI is not set. Check the EndToEndResource configuration.");
throw new IllegalStateException("MongoDB URI is not set. Check the EndToEndResource configuration.");
}

try (MongoClient mongoClient = MongoClients.create(mongoUri)) {
MongoDatabase database = mongoClient.getDatabase(mongoDatabase);
counterSetup(database);
namespaceSetup(database);
}
}

@Test
@Order(1)
void create_the_resource_at_a_snapshot() {
given()
.contentType("application/json")
.body(document("1.0.0-SNAPSHOT", "first"))
.when()
.post(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
.then()
.statusCode(201);
}

@Test
@Order(2)
void overwrite_the_snapshot_and_read_the_new_content_back() {
given()
.contentType("application/json")
.body(document("1.0.0-SNAPSHOT", "second"))
.when()
.post(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
.then()
.statusCode(200);

given()
.when()
.get(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
.then()
.statusCode(200)
.body("title", equalTo("second"));
}

@Test
@Order(3)
void list_the_snapshot_as_the_only_version() {
given()
.when()
.get(PATH_BASE + "/versions")
.then()
.statusCode(200)
.body("values", contains("1.0.0-SNAPSHOT"));
}

@Test
@Order(4)
void publish_the_release_and_lose_the_snapshot() {
given()
.contentType("application/json")
.body(document("1.0.0", "published"))
.when()
.post(PATH_BASE + "/versions/1.0.0")
.then()
.statusCode(201);

given()
.when()
.get(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
.then()
.statusCode(404);

given()
.when()
.get(PATH_BASE + "/versions")
.then()
.statusCode(200)
.body("values", hasItem("1.0.0"))
.body("values", not(hasItem("1.0.0-SNAPSHOT")));
}

@Test
@Order(5)
void refuse_a_snapshot_that_shadows_the_published_release() {
given()
.contentType("application/json")
.body(document("1.0.0-SNAPSHOT", "shadow"))
.when()
.post(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
.then()
.statusCode(409);
}

@Test
@Order(6)
void return_a_snapshot_from_search() {
// The spec decides snapshots are searchable: they are real documents in the
// namespace, and filtering work in progress out of results is a UI concern, not a
// storage one.
//
// GroupedSearchResults/SearchResult (org.finos.calm.domain.search) carry no version
// field at all β€” search matches and returns namespace/id/name/description, grouped
// by type ("architectures", "patterns", ... β€” not a flat "values" array), and the
// endpoint's query parameter is "q", not "query". MongoSearchStore matches against
// the resource's header, whose name/description are denormalized from the most
// recently written version's title/description (see
// MongoArchitectureStore#updateHeaderDetails) β€” so posting this snapshot makes the
// *resource* (not a specific version) discoverable by the snapshot's title.
given()
.contentType("application/json")
.body(document("2.0.0-SNAPSHOT", "searchable-snapshot"))
.when()
.post(PATH_BASE + "/versions/2.0.0-SNAPSHOT")
.then()
.statusCode(201);

given()
.queryParam("q", "searchable-snapshot")
.when()
.get("/calm/search")
.then()
.statusCode(200)
.body("architectures.name", hasItem("searchable-snapshot"));
}

private String document(String version, String title) {
return """
{
"$id": "%s/versions/%s",
"title": "%s",
"nodes": [],
"relationships": []
}
""".formatted(ID_BASE, version, title);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.finos.calm.domain;

/**
* The one place that knows how a snapshot version is spelled.
*
* <h2>Why this is a type and not a string check</h2>
* {@link org.finos.calm.store.util.VersionScheme} records why version spelling and version ordering became one type:
* left independent, they drifted, and an ADR revision was silently stored and read under the
* wrong version. Snapshot-ness is a third property of the same string. A call to
* {@code endsWith("-SNAPSHOT")} scattered through the resource, service and store layers is
* the same mistake in a new place β€” each copy free to disagree about case, about a bare
* suffix, or about null.
*
* <p>Canonicalisation stays in {@link org.finos.calm.store.util.CanonicalVersion} and ordering in
* {@link org.finos.calm.store.util.SemanticVersionOrder}. This class answers only "is it a snapshot, and what release
* does it belong to", and both of those delegate to it.</p>
*/
public final class ResourceVersion {

public static final String SNAPSHOT_SUFFIX = "-SNAPSHOT";

private ResourceVersion() {
}

/**
* @param version any version string, or {@code null}
* @return {@code true} only for a version with content before the suffix. {@code null},
* a lowercase suffix and a bare {@code "-SNAPSHOT"} are all {@code false}: the suffix is
* a fixed token, and accepting variants would give one logical version several documents.
*/
public static boolean isSnapshot(String version) {
return version != null
&& version.endsWith(SNAPSHOT_SUFFIX)
&& version.length() > SNAPSHOT_SUFFIX.length();
}

/**
* @return the version with any snapshot suffix removed, in its original spelling.
* Folding the spelling is {@link org.finos.calm.store.util.CanonicalVersion}'s job; doing it here as well would
* put one rule in two places.
*/
public static String releaseVersion(String version) {
if (!isSnapshot(version)) {
return version;
}
return version.substring(0, version.length() - SNAPSHOT_SUFFIX.length());
}

/** @return {@code version} unchanged if it is {@code null} or already a snapshot. */
public static String asSnapshot(String version) {
if (version == null || isSnapshot(version)) {
return version;
}
return version + SNAPSHOT_SUFFIX;
}
}
13 changes: 11 additions & 2 deletions calm-hub/src/main/java/org/finos/calm/domain/Semver.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@
*/
public record Semver(int major, int minor, int patch) implements Comparable<Semver> {

/**
* The snapshot suffix is removed <em>before</em> the '-' to '.' replacement below.
* Reversing that order turns "1.0.0-SNAPSHOT" into "1.0.0.SNAPSHOT" β€” four segments,
* which is treated as unparseable and sorts as 0.0.0.
*/
private static String[] segments(String version) {
return ResourceVersion.releaseVersion(version).replace('-', '.').split("\\.");
}

public static Semver parse(String version) {
String[] parts = version.replace('-', '.').split("\\.");
String[] parts = segments(version);
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid version format: " + version);
}
Expand All @@ -20,7 +29,7 @@ public static Semver parse(String version) {
}

public static Semver tryParse(String version) {
String[] parts = version.replace('-', '.').split("\\.");
String[] parts = segments(version);
if (parts.length != 3) {
return new Semver(0, 0, 0);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public ToolResponse createInterface(

try {
CreateInterfaceRequest request = new CreateInterfaceRequest(name, description, interfaceJson);
CalmInterface result = interfaceStore.createInterfaceForNamespace(request, namespace);
CalmInterface result = interfaceStore.createInterfaceForNamespace(request, namespace, "1.0.0");
logger.info("Interface created with ID [{}] in namespace [{}]", result.getId(), namespace);
return ToolResponse.success("Interface created successfully with ID: " + result.getId() + " (version " + result.getVersion() + ") in namespace '" + namespace + "'.");
} catch (NamespaceNotFoundException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public ToolResponse createPattern(

try {
CreatePatternRequest request = new CreatePatternRequest(name, description, patternJson);
Pattern result = patternStore.createPatternForNamespace(request, namespace);
Pattern result = patternStore.createPatternForNamespace(request, namespace, "1.0.0");
logger.info("Pattern created with ID [{}] in namespace [{}]", result.getId(), namespace);
return ToolResponse.success("Pattern created successfully with ID: " + result.getId() + " (version " + result.getDotVersion() + ") in namespace '" + namespace + "'.");
} catch (NamespaceNotFoundException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public ToolResponse createStandard(

try {
CreateStandardRequest request = new CreateStandardRequest(name, description, standardJson);
Standard result = standardStore.createStandardForNamespace(request, namespace);
Standard result = standardStore.createStandardForNamespace(request, namespace, "1.0.0");
logger.info("Standard created with ID [{}] in namespace [{}]", result.getId(), namespace);
return ToolResponse.success("Standard created successfully with ID: " + result.getId() + " (version " + result.getVersion() + ") in namespace '" + namespace + "'.");
} catch (NamespaceNotFoundException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;

Expand Down Expand Up @@ -174,7 +176,7 @@ public Response getArchitectureVersions(
public Response getArchitecture(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("architectureId") int architectureId,
@PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version) {
@PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version) {
Architecture architecture = new Architecture.ArchitectureBuilder()
.setNamespace(namespace)
.setId(architectureId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ public CanonicalId parseCanonicalId(String json) throws JsonProcessingException
throw new IllegalArgumentException(
"'versions' is a reserved path segment and cannot be used as a resource name");
}
if (!version.matches(VERSION_REGEX)) {
// Matches createResourceVersion's path-driven POST, which already accepts -SNAPSHOT;
// domain controls use validateVersion instead and deliberately stay strict.
if (!version.matches(SNAPSHOT_VERSION_REGEX)) {
throw new IllegalArgumentException("Invalid version in $id: " + version);
}
ResourceType resourceType = TYPE_MAP.get(type.toLowerCase());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;

Expand Down Expand Up @@ -91,7 +93,7 @@ public Response createFlowForNamespace(
@Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest
) throws URISyntaxException {
try {
Flow flowForNamespace = store.createFlowForNamespace(flowRequest, namespace);
Flow flowForNamespace = store.createFlowForNamespace(flowRequest, namespace, "1.0.0");
return flowWithLocationResponse(flowForNamespace);
} catch (NamespaceNotFoundException e) {
logger.error("Invalid namespace [{}] when creating flow", namespace, e);
Expand Down Expand Up @@ -172,7 +174,7 @@ public Response getFlowVersions(
public Response getFlow(
@PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("flowId") int flowId,
@PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
@PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
return getFlowInternal(namespace, flowId, version);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public Response createInterfaceForNamespace(
@Valid @NotNull(message = "Request must not be null") CreateInterfaceRequest interfaceRequest
) throws URISyntaxException {
try {
CalmInterface createdInterface = interfaceStore.createInterfaceForNamespace(interfaceRequest, namespace);
CalmInterface createdInterface = interfaceStore.createInterfaceForNamespace(interfaceRequest, namespace, "1.0.0");
return Response.created(new URI("/api/calm/namespaces/" + namespace + "/interfaces/" + createdInterface.getId() + "/versions/1.0.0")).build();
} catch (NamespaceNotFoundException e) {
logger.error("Invalid namespace [{}] when creating interface", namespace, e);
Expand Down Expand Up @@ -110,7 +110,7 @@ public Response getInterfaceVersions(
public Response getInterfaceForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("interfaceId") Integer interfaceId,
@PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
@PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
try {
return Response.ok(interfaceStore.getInterfaceForVersion(namespace, interfaceId, version)).build();
Expand Down
Loading
Loading