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
6 changes: 3 additions & 3 deletions compatibility-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|:---:|:---:|:---:|:---:|
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion docs/services/gke.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/io/floci/gcp/services/gke/GkeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
*
* <p>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<String, Object> 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");
}
Comment on lines +384 to +387

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Malformed versions cause 500s

A REST caller can send valid JSON such as {"masterVersion":123}. Jackson stores that value as a non-string object, so this direct cast throws ClassCastException. The exception is not mapped to the GCP error format, causing an internal server error instead of 400 INVALID_ARGUMENT. This violates the repository directive to preserve GCP protocol compatibility and return GCP-compatible JSON errors.

Suggested change
String masterVersion = body == null ? null : (String) body.get("masterVersion");
if (masterVersion == null || masterVersion.isBlank()) {
throw GcpException.invalidArgument("masterVersion is required");
}
Object masterVersionValue = body == null ? null : body.get("masterVersion");
if (!(masterVersionValue instanceof String masterVersion) || masterVersion.isBlank()) {
throw GcpException.invalidArgument("masterVersion is required");
}

Context Used: AGENTS.md (source)

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<String, Object> body) {
StoredCluster cluster = requireCluster(project, location, clusterId);
Expand Down Expand Up @@ -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.
*
* <p>{@code desired_node_version} upgrades the single pool named by {@code
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/io/floci/gcp/services/gke/KubernetesController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> body) {

return Response.ok(gkeService.updateMaster(project, location, clusterId, body)).build();
}

@POST
@Path("/clusters/{clusterId: [^:/]+}:startIpRotation")
public Response startIpRotation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum OperationType {
CREATE_CLUSTER,
DELETE_CLUSTER,
UPDATE_CLUSTER,
UPGRADE_MASTER,
CREATE_NODE_POOL,
DELETE_NODE_POOL,
UPDATE_NODE_POOL,
Expand Down
79 changes: 79 additions & 0 deletions src/test/java/io/floci/gcp/services/gke/GkeServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> config = service.getServerConfig();
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}