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"));
+ }
+}