diff --git a/compatibility-tests/README.md b/compatibility-tests/README.md index 05a4cfcc..7b9e367f 100644 --- a/compatibility-tests/README.md +++ b/compatibility-tests/README.md @@ -49,7 +49,7 @@ just test-all-iac ## Test Coverage -### SDK tests — 256 tests total +### SDK tests — 257 tests total | Test class | GCP service | Java | Python | Node | Go | |---|---|:---:|:---:|:---:|:---:| @@ -62,13 +62,13 @@ just test-all-iac | `DatastoreTest` | Datastore | 5 | 5 | 5 | 5 | | `IamTest` | IAM | 7 | 5 | 7 | 7 | | `KafkaTest` | Managed Kafka | 11 | 9 | 11 | 11 | -| `GkeTest` | GKE (Kubernetes Engine) | 4 | 0 | 0 | 0 | +| `GkeTest` | GKE (Kubernetes Engine) | 5 | 0 | 0 | 0 | | `CloudSqlAdminTest` | Cloud SQL for PostgreSQL | 4 | 0 | 0 | 0 | | `SchedulerTest` | Cloud Scheduler | 7 | 0 | 0 | 0 | | `EventarcTest` | Eventarc | 7 | 0 | 0 | 0 | | `ServiceUsageTest` | Service Usage | 6 | 0 | 0 | 0 | | `FirebaseAuthTest` | Firebase Auth | 6 | 0 | 0 | 0 | -| **Total** | | **91** | **48** | **59** | **58** | +| **Total** | | **92** | **48** | **59** | **58** | GKE uses the HttpJson transport (the Cloud SDK defaults to gRPC, which the REST-only emulator does not serve for GKE) and reaches the service via host-based routing diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GkeTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GkeTest.java index e9787c90..ec1bc5f0 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GkeTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GkeTest.java @@ -7,6 +7,7 @@ import com.google.container.v1.GetClusterRequest; import com.google.container.v1.ListClustersRequest; import com.google.container.v1.Operation; +import com.google.container.v1.UpdateMasterRequest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; @@ -84,6 +85,29 @@ void listClustersContainsCreated() { @Test @Order(4) + void updateMasterUpgradesOnlyTheControlPlane() { + Cluster before = client.getCluster(GetClusterRequest.newBuilder() + .setName(CLUSTER_NAME) + .build()); + + Operation op = client.updateMaster(UpdateMasterRequest.newBuilder() + .setName(CLUSTER_NAME) + .setMasterVersion("1.31.5-gke.1") + .build()); + + assertThat(op.getOperationType()).isEqualTo(Operation.Type.UPGRADE_MASTER); + assertThat(op.getStatus()).isEqualTo(Operation.Status.DONE); + + Cluster after = client.getCluster(GetClusterRequest.newBuilder() + .setName(CLUSTER_NAME) + .build()); + assertThat(after.getCurrentMasterVersion()).isEqualTo("1.31.5-gke.1"); + // Real GKE upgrades the control plane independently of node pools. + assertThat(after.getCurrentNodeVersion()).isEqualTo(before.getCurrentNodeVersion()); + } + + @Test + @Order(5) void deleteCluster() { Operation op = client.deleteCluster(DeleteClusterRequest.newBuilder() .setName(CLUSTER_NAME) diff --git a/docs/services/gke.md b/docs/services/gke.md index a5e79f66..fdf9f158 100644 --- a/docs/services/gke.md +++ b/docs/services/gke.md @@ -139,7 +139,7 @@ Terraform's plan/refresh diff, without hand-modeling the full `NodeConfig` proto ## Supported Operations -- `CreateCluster`, `GetCluster`, `ListClusters`, `DeleteCluster`, `UpdateCluster` +- `CreateCluster`, `GetCluster`, `ListClusters`, `DeleteCluster`, `UpdateCluster`, `UpdateMaster` - `SetResourceLabels`, `SetMasterAuth`, `SetNetworkPolicy`, `SetAddonsConfig`, `SetLoggingService`, `SetMonitoringService`, `SetLocations`, `SetLegacyAbac`, `SetMaintenancePolicy`, `StartIPRotation`, `CompleteIPRotation` @@ -176,6 +176,14 @@ analysis, since floci-gcp has no real infrastructure behind them to inspect: omitted; with several it is required, and a request without it is rejected as `400 INVALID_ARGUMENT` rather than silently upgrading pools the caller did not name. +- `UpdateMaster` moves only the control plane: `currentMasterVersion` changes and + `currentNodeVersion` and every node pool's `version` stay as they were, as in real + GKE, where the master and node pools upgrade independently. `masterVersion` is + required (`400 INVALID_ARGUMENT` when missing). The aliases the API documents + resolve against this emulator's single advertised version: `latest`, `-`, and a + `1.X` / `1.X.Y` prefix of it all pick that version; any other explicit version is + stored verbatim, as `CreateCluster` and `UpdateCluster` already do. The operation + is reported as `UPGRADE_MASTER`, the `Operation.Type` real GKE uses. **Autopilot mode** (`autopilot.enabled`) and **Fleet/Anthos registration** (`fleet`) are not semantically modeled — floci-gcp does not run a real diff --git a/src/main/java/io/floci/gcp/services/gke/GkeService.java b/src/main/java/io/floci/gcp/services/gke/GkeService.java index ee707616..5f2d29ae 100644 --- a/src/main/java/io/floci/gcp/services/gke/GkeService.java +++ b/src/main/java/io/floci/gcp/services/gke/GkeService.java @@ -370,6 +370,27 @@ public StoredOperation updateCluster(String project, String location, String clu return operationService.createOperation(project, location, clusterId, OperationType.UPDATE_CLUSTER); } + /** {@code ClusterManager.UpdateMaster} ({@code POST .../clusters/{id}:updateMaster}). + * + *

Only the control plane moves: real GKE upgrades the master independently of node + * pools, so {@code currentNodeVersion} and every pool's own {@code version} are left + * untouched, unlike {@code UpdateCluster} with {@code desiredNodeVersion}. The proto marks + * {@code master_version} REQUIRED, so an absent or blank value is rejected before the + * cluster is touched. Reported as {@code UPGRADE_MASTER}, the {@code Operation.Type} real + * GKE uses for a master upgrade. */ + public StoredOperation updateMaster(String project, String location, String clusterId, + Map body) { + StoredCluster cluster = requireCluster(project, location, clusterId); + String masterVersion = body == null ? null : (String) body.get("masterVersion"); + if (masterVersion == null || masterVersion.isBlank()) { + throw GcpException.invalidArgument("masterVersion is required"); + } + cluster.setCurrentMasterVersion(resolveMasterVersion(masterVersion)); + touch(cluster); + clusterStore.put(clusterKey(project, location, clusterId), cluster); + return operationService.createOperation(project, location, clusterId, OperationType.UPGRADE_MASTER); + } + public StoredOperation setLabels(String project, String location, String clusterId, Map body) { StoredCluster cluster = requireCluster(project, location, clusterId); @@ -785,6 +806,22 @@ private StoredNodePool requireNodePool(String project, String location, String c .orElseThrow(() -> GcpException.notFound("Not found: nodePool " + nodePoolId)); } + /** Resolves the version aliases {@code master_version} accepts (cluster_service.proto, + * {@code UpdateMasterRequest}): {@code "latest"} and {@code "-"} pick the highest valid and + * the default version respectively, and {@code "1.X"} / {@code "1.X.Y"} pick the highest + * valid version under that prefix. {@link #getServerConfig()} advertises exactly one valid + * master version, so every alias that matches it resolves to it. An explicit version that is + * not the advertised one is kept verbatim, as {@code createCluster} and {@code UpdateCluster} + * already do, so clients pinning a specific version keep working against the emulator. */ + private static String resolveMasterVersion(String requested) { + if ("latest".equals(requested) || "-".equals(requested) + || DEFAULT_MASTER_VERSION.startsWith(requested + ".") + || DEFAULT_MASTER_VERSION.startsWith(requested + "-")) { + return DEFAULT_MASTER_VERSION; + } + return requested; + } + /** The node pools an {@code UpdateCluster} carrying {@code desiredNodeVersion} upgrades. * *

{@code desired_node_version} upgrades the single pool named by {@code diff --git a/src/main/java/io/floci/gcp/services/gke/KubernetesController.java b/src/main/java/io/floci/gcp/services/gke/KubernetesController.java index 3ed725a9..1af5d67e 100644 --- a/src/main/java/io/floci/gcp/services/gke/KubernetesController.java +++ b/src/main/java/io/floci/gcp/services/gke/KubernetesController.java @@ -202,6 +202,17 @@ public Response setMaintenancePolicy( return Response.ok(gkeService.setMaintenancePolicy(project, location, clusterId, body)).build(); } + @POST + @Path("/clusters/{clusterId: [^:/]+}:updateMaster") + public Response updateMaster( + @PathParam("project") String project, + @PathParam("location") String location, + @PathParam("clusterId") String clusterId, + Map body) { + + return Response.ok(gkeService.updateMaster(project, location, clusterId, body)).build(); + } + @POST @Path("/clusters/{clusterId: [^:/]+}:startIpRotation") public Response startIpRotation( diff --git a/src/main/java/io/floci/gcp/services/gke/operations/OperationType.java b/src/main/java/io/floci/gcp/services/gke/operations/OperationType.java index a5a07c26..36702652 100644 --- a/src/main/java/io/floci/gcp/services/gke/operations/OperationType.java +++ b/src/main/java/io/floci/gcp/services/gke/operations/OperationType.java @@ -4,6 +4,7 @@ public enum OperationType { CREATE_CLUSTER, DELETE_CLUSTER, UPDATE_CLUSTER, + UPGRADE_MASTER, CREATE_NODE_POOL, DELETE_NODE_POOL, UPDATE_NODE_POOL, diff --git a/src/test/java/io/floci/gcp/services/gke/GkeServiceTest.java b/src/test/java/io/floci/gcp/services/gke/GkeServiceTest.java index 079b6358..543fa8e9 100644 --- a/src/test/java/io/floci/gcp/services/gke/GkeServiceTest.java +++ b/src/test/java/io/floci/gcp/services/gke/GkeServiceTest.java @@ -303,6 +303,85 @@ void nodePoolUpgradeOperationsRequireAnExistingPool() { () -> service.completeNodePoolUpgrade(PROJECT, LOCATION, "upgrade-me", "no-such-pool")); } + @Test + void updateMasterMovesOnlyTheControlPlaneVersion() { + service.createCluster(PROJECT, LOCATION, Map.of("name", "upgrade-master", + "nodePools", List.of(Map.of("name", "pool-a"), Map.of("name", "pool-b")))); + StoredCluster before = service.getCluster(PROJECT, LOCATION, "upgrade-master"); + String nodeVersionBefore = before.getCurrentNodeVersion(); + String etagBefore = before.getEtag(); + + StoredOperation op = service.updateMaster(PROJECT, LOCATION, "upgrade-master", + Map.of("masterVersion", "1.31.5-gke.1")); + + assertEquals(OperationType.UPGRADE_MASTER, op.getOperationType()); + assertEquals("DONE", op.getStatus()); + assertTrue(op.getTargetLink().endsWith("/clusters/upgrade-master")); + + StoredCluster after = service.getCluster(PROJECT, LOCATION, "upgrade-master"); + assertEquals("1.31.5-gke.1", after.getCurrentMasterVersion()); + // Real GKE upgrades the control plane independently of node pools, so neither the + // cluster's node version aggregate nor any pool's own version moves with the master. + assertEquals(nodeVersionBefore, after.getCurrentNodeVersion()); + for (StoredNodePool pool : service.listNodePools(PROJECT, LOCATION, "upgrade-master")) { + assertEquals(nodeVersionBefore, pool.getVersion()); + } + assertEquals(before.getInitialClusterVersion(), after.getInitialClusterVersion()); + assertNotEquals(etagBefore, after.getEtag()); + } + + @Test + void updateMasterResolvesVersionAliasesToTheAdvertisedVersion() { + service.createCluster(PROJECT, LOCATION, Map.of("name", "alias-cluster", + "initialClusterVersion", "1.29.0-gke.1")); + String advertised = (String) service.getServerConfig().get("defaultClusterVersion"); + + // "-" and "latest" are the aliases the proto documents for UpdateMasterRequest.master_version. + service.updateMaster(PROJECT, LOCATION, "alias-cluster", Map.of("masterVersion", "-")); + assertEquals(advertised, service.getCluster(PROJECT, LOCATION, "alias-cluster").getCurrentMasterVersion()); + + service.updateMaster(PROJECT, LOCATION, "alias-cluster", Map.of("masterVersion", "1.29.0-gke.1")); + service.updateMaster(PROJECT, LOCATION, "alias-cluster", Map.of("masterVersion", "latest")); + assertEquals(advertised, service.getCluster(PROJECT, LOCATION, "alias-cluster").getCurrentMasterVersion()); + + // A "1.X" / "1.X.Y" prefix of the advertised version picks that version. + String minor = advertised.substring(0, advertised.indexOf('.', advertised.indexOf('.') + 1)); + service.updateMaster(PROJECT, LOCATION, "alias-cluster", Map.of("masterVersion", "1.29.0-gke.1")); + service.updateMaster(PROJECT, LOCATION, "alias-cluster", Map.of("masterVersion", minor)); + assertEquals(advertised, service.getCluster(PROJECT, LOCATION, "alias-cluster").getCurrentMasterVersion()); + + // A prefix that only shares leading characters is not a match ("1.3" is not "1.30"). + String notAPrefix = minor.substring(0, minor.length() - 1); + service.updateMaster(PROJECT, LOCATION, "alias-cluster", Map.of("masterVersion", notAPrefix)); + assertEquals(notAPrefix, service.getCluster(PROJECT, LOCATION, "alias-cluster").getCurrentMasterVersion()); + } + + @Test + void updateMasterRejectsAMissingVersionWithoutTouchingTheCluster() { + service.createCluster(PROJECT, LOCATION, Map.of("name", "needs-version")); + StoredCluster before = service.getCluster(PROJECT, LOCATION, "needs-version"); + + GcpException missing = assertThrows(GcpException.class, + () -> service.updateMaster(PROJECT, LOCATION, "needs-version", Map.of())); + assertEquals(400, missing.getHttpStatus()); + GcpException blank = assertThrows(GcpException.class, + () -> service.updateMaster(PROJECT, LOCATION, "needs-version", Map.of("masterVersion", " "))); + assertEquals(400, blank.getHttpStatus()); + assertThrows(GcpException.class, + () -> service.updateMaster(PROJECT, LOCATION, "needs-version", null)); + + StoredCluster after = service.getCluster(PROJECT, LOCATION, "needs-version"); + assertEquals(before.getCurrentMasterVersion(), after.getCurrentMasterVersion()); + assertEquals(before.getEtag(), after.getEtag()); + } + + @Test + void updateMasterRequiresAnExistingCluster() { + GcpException ex = assertThrows(GcpException.class, + () -> service.updateMaster(PROJECT, LOCATION, "ghost", Map.of("masterVersion", "1.31.5-gke.1"))); + assertEquals(404, ex.getHttpStatus()); + } + @Test void getServerConfigReturnsVersionAndChannelInfo() { Map config = service.getServerConfig(); diff --git a/src/test/java/io/floci/gcp/services/gke/GkeUpdateMasterRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/gke/GkeUpdateMasterRestIntegrationTest.java new file mode 100644 index 00000000..92dd7f07 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/gke/GkeUpdateMasterRestIntegrationTest.java @@ -0,0 +1,117 @@ +package io.floci.gcp.services.gke; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +/** + * {@code ClusterManager.UpdateMaster} over REST ({@code POST .../clusters/{id}:updateMaster}). + * The custom-method colon suffix is what makes this worth a route-level test on top of + * {@link GkeServiceTest}: the JAX-RS path regex is the piece a service-level test cannot see. + */ +@QuarkusTest +class GkeUpdateMasterRestIntegrationTest { + + private static final String PROJECT = "gke-update-master-it"; + private static final String LOCATION = "us-central1"; + private static final String BASE = "/container/v1/projects/" + PROJECT + "/locations/" + LOCATION; + + @Test + void updateMasterMovesTheControlPlaneAndLeavesNodesAlone() { + String cluster = "upgrade-me"; + String clusterPath = BASE + "/clusters/" + cluster; + + given() + .contentType("application/json") + .body("{\"cluster\":{\"name\":\"" + cluster + "\"}}") + .when().post(BASE + "/clusters") + .then() + .statusCode(200) + .body("status", equalTo("DONE")); + + String nodeVersionBefore = given() + .when().get(clusterPath) + .then() + .statusCode(200) + .extract().path("currentNodeVersion"); + + String operationName = given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body("{\"masterVersion\":\"1.31.5-gke.1\"}") + .when().post(clusterPath + ":updateMaster") + .then() + .statusCode(200) + .body("operationType", equalTo("UPGRADE_MASTER")) + .body("status", equalTo("DONE")) + .body("targetLink", equalTo("projects/" + PROJECT + "/locations/" + LOCATION + "/clusters/" + cluster)) + .body("name", startsWith("operation-")) + .extract().path("name"); + + given() + .when().get(BASE + "/operations/" + operationName) + .then() + .statusCode(200) + .body("operationType", equalTo("UPGRADE_MASTER")) + .body("status", equalTo("DONE")); + + // Real GKE upgrades the control plane independently of node pools: the node version + // and the pool's own version are unaffected by a master-only upgrade. + given() + .when().get(clusterPath) + .then() + .statusCode(200) + .body("currentMasterVersion", equalTo("1.31.5-gke.1")) + .body("currentNodeVersion", equalTo(nodeVersionBefore)) + .body("nodePools[0].version", equalTo(nodeVersionBefore)); + } + + @Test + void updateMasterRequiresAMasterVersion() { + String cluster = "no-version"; + String clusterPath = BASE + "/clusters/" + cluster; + + given() + .contentType("application/json") + .body("{\"cluster\":{\"name\":\"" + cluster + "\"}}") + .when().post(BASE + "/clusters") + .then() + .statusCode(200); + + String masterVersionBefore = given() + .when().get(clusterPath) + .then() + .statusCode(200) + .extract().path("currentMasterVersion"); + + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body("{}") + .when().post(clusterPath + ":updateMaster") + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")); + + given() + .when().get(clusterPath) + .then() + .statusCode(200) + .body("currentMasterVersion", equalTo(masterVersionBefore)); + } + + @Test + void updateMasterOnAMissingClusterIs404() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body("{\"masterVersion\":\"1.31.5-gke.1\"}") + .when().post(BASE + "/clusters/does-not-exist:updateMaster") + .then() + .statusCode(404) + .body("error.status", equalTo("NOT_FOUND")); + } +}