Google Cloud: real-cloud results
Results of running the Halyx conformance kits against real Google Cloud services in australia-southeast1 (Sydney), acting as
the halyx-test service account, impersonated through Application Default Credentials. Every run creates its own throwaway resources and deletes them afterwards. See
Cloud testing for how the runs work.
| Tier | Services | Latest result |
|---|---|---|
| 1 | Cloud Storage, Firestore, Pub/Sub, Secret Manager, Parameter Manager, Eventarc Advanced; Spring Data repositories (blocking and reactive) on Firestore, and Spring Boot on Firestore, Eventarc and, with halyx-reactor, WebFlux | 240/246 passed, 6 skipped (expected); Spring 20/22, 2 skipped (expected) |
| 2 | Cloud SQL for PostgreSQL and MySQL with IAM auth, direct and through the Cloud SQL connector; R2DBC and Spring Data R2DBC | 22/22; Spring 2/2 |
| 3 | Memorystore for Valkey with IAM auth and TLS (private CA) | 57/57; Spring 2/2 |
| 4 | Streams on Managed Service for Apache Kafka with Google IAM, from a runner inside the VPC, plus Spring Boot 3 and 4 | 29/29; Spring 2/2 |
Tier 1: latest result#
GCP_PROJECT=<project> GCP_REGION=australia-southeast1 cloud-testing/scripts/gcp-real-cloud.sh
IT_TEST='RealGcpIT$Firestore' cloud-testing/scripts/gcp-real-cloud.sh # one suite only
Identity. Terraform and the tests act as a dedicated halyx-test service account, through
Application Default Credentials that impersonate it:
gcloud auth application-default login --impersonate-service-account=halyx-test@<project>.iam.gserviceaccount.com
Provision. Terraform (cloud-testing/infra/gcp/tier1) creates the following, labelled halyx-test=<run id>:
- a Cloud Storage bucket, which also holds the messaging kit's claim-check payloads
- a Firestore database of the run's own, so the project's
(default)database is never touched - the composite indexes the document store's index queries need:
statuswithplacedAtascending and descending, on both test tables, plus a collection-group index onowner - an Eventarc Advanced message bus, an enrollment matching every event, and a pipeline that publishes them to
a Pub/Sub topic with an OAuth token for
halyx-test, and the topic's inbox subscription - the function kit's Cloud Run function (the emulator tests'
main.py, onpython312), with one instance taking one request at a time so the runs it counts in memory hold for every invocation, its source bucket, androles/run.invokeron it for the test service account only
Test. RealGcpIT creates Pub/Sub topics and subscriptions named after the run, plus tck-* secrets
and parameters (Parameter Manager both global and regional).
Tear down. A sweep deletes what the tests left behind, then terraform destroy runs, then a check
lists every kind of resource. A listing that fails is reported as CHECK FAILED, never taken as empty.
| Date | 2026-10-16, 15:14–15:53 UTC (39 min: the Eventarc pipeline and Firestore indexes about 7 min, tests about 22 min, teardown the rest) |
| Run id | r2610170214c74e |
| Project and region | dedicated test project, australia-southeast1 |
| Result | BUILD SUCCESS: 202 passed, 0 failed, 4 skipped (expected); Spring Boot repository, event and reactive tests passed on Boot 3 and 4 |
| Teardown | bucket, Firestore database and Eventarc bus, pipeline and enrollment destroyed; check: nothing left |
| Service | Halyx API | Tests | Passed | Skipped | Time |
|---|---|---|---|---|---|
| Cloud Storage | BlobStore |
75 | 74 | 1 | 520.5s |
| Firestore | DocumentStore |
23 | 23 | 0 | 15.3s |
| Firestore | Spring Data repositories | 10 | 10 | 0 | 5.6s |
| Firestore | reactive Spring Data repositories | 10 | 10 | 0 | 4.9s |
| Pub/Sub | Publisher / Subscription |
15 | 13 | 2 | 250.8s |
| Secret Manager | ParameterStore |
22 | 22 | 0 | 164.0s |
| Parameter Manager, global | ParameterStore |
22 | 22 | 0 | 127.1s |
Parameter Manager, australia-southeast1 |
ParameterStore |
22 | 22 | 0 | 38.6s |
| Eventarc Advanced → Pub/Sub | EventPublisher / CloudEvents.decode |
7 | 6 | 1 | 63.5s |
| Total | 206 | 202 | 4 | ||
| Firestore, Spring Boot 3.5 | Spring Boot repository test | 1 | 1 | ||
| Firestore, Spring Boot 4 | Spring Boot repository test | 1 | 1 | ||
| Eventarc → Pub/Sub, Spring Boot 3.5 | Spring Boot event test | 1 | 1 | ||
| Eventarc → Pub/Sub, Spring Boot 4 | Spring Boot event test | 1 | 1 | ||
| Bucket, Firestore and Eventarc, Spring Boot 3.5 | Spring Boot reactive test (WebFlux, reactive repository, events) | 3 | 3 | ||
| Bucket, Firestore and Eventarc, Spring Boot 4 | Spring Boot reactive test (WebFlux, reactive repository, events) | 3 | 3 |
Skipped tests, all expected:
- Cloud Storage
presignWithoutCapabilityIsRejected. This test only applies to providers that can't presign; GCS can. - Pub/Sub
repeatedDeduplicationIdIsDeliveredOnceanddelayedMessageIsHeldBack. Pub/Sub has no publish-side deduplication and no per-message delay, so the driver declares neither capability. - Eventarc
eventsWithoutDataArrive. Pub/Sub refuses a pipeline's publish of an event without data, so the event never arrives; declared as a known gap (see what real GCP found).
Pub/Sub's time is mostly the redelivery tests, which wait for the 10s ack deadline to pass.
The repository kit and the Spring Boot repository test run on the run's items and items-sorted
collections and their status/placedAt composite indexes, through halyx-spring-data-document.
Per-test results#
Cloud Storage — BlobStore: 74/75 passed, 520.5s
| Test | Result | Time (s) |
|---|---|---|
abortedPresignedUploadCreatesNoBlob |
passed | 1.95 |
closingTheStreamInsideWriteDoesNotCommitEarly |
passed | 1.26 |
contentEncodingIsStoredAndBytesAreReturnedUnchanged |
passed | 1.72 |
copyDuplicatesContentAndKeepsSource |
passed | 3.03 |
copyMissingSourceThrowsNotFound |
passed | 0.29 |
copyPreservesHttpHeaders |
passed | 1.20 |
defaultTierIsHot |
passed | 0.93 |
deleteAllRemovesEveryKeyIncludingMissingOnes |
passed | 5.23 |
deleteAllSpansSeveralProviderBatches |
passed | 176.14 |
deleteAllWithNoKeys |
passed | 0.00 |
deleteIfMatchOnMissingBlobFails |
passed | 1.68 |
deleteIfMatchWithCurrentETagDeletes |
passed | 1.27 |
deleteIfMatchWithStaleETagFails |
passed | 2.32 |
deleteMissingBlobIsIdempotent |
passed | 0.29 |
deleteRemovesBlob |
passed | 1.25 |
downloadWritesCompleteFile(Path) |
passed | 8.90 |
emptyBlobRoundTrips |
passed | 1.50 |
failedWriteKeepsExistingBlob |
passed | 1.53 |
failedWriteStoresNothing |
passed | 0.60 |
getMissingBlobThrowsNotFound |
passed | 0.29 |
getReturnsMetadataWithContent |
passed | 1.21 |
getWithStaleETagThrowsPreconditionFailed |
passed | 1.65 |
headMissingBlobReturnsEmpty |
passed | 0.64 |
headReturnsSizeAndLastModified |
passed | 0.92 |
ifMatchWithCurrentETagSucceeds |
passed | 1.85 |
ifMatchWithStaleETagThrowsPreconditionFailed |
passed | 3.47 |
ifNotExistsCreatesMissingBlob |
passed | 1.56 |
ifNotExistsRejectsExistingBlob |
passed | 1.83 |
listAllStreamsEveryBlob |
passed | 4.72 |
listEmptyPrefixReturnsNothing |
passed | 0.30 |
listFiltersByPrefix |
passed | 2.16 |
listPaginatesInKeyOrder |
passed | 7.23 |
listSummariesCarrySize |
passed | 0.91 |
listWithDelimiterGroupsKeysIntoPrefixes |
passed | 3.72 |
listWithDelimiterPaginatesOverItemsAndPrefixes |
passed | 4.65 |
listWithoutDelimiterReturnsNoPrefixes |
passed | 0.96 |
openEndedRangeReadsToEnd |
passed | 1.27 |
parallelReadReassemblesRangesInOrder |
passed | 8.41 |
parallelReadValidatesChecksum |
passed | 7.74 |
presignWithoutCapabilityIsRejected |
skipped | 0.00 |
presignedDownloadUrlDownloadsBlob |
passed | 1.62 |
presignedMultipartUploadAssemblesPartsInOrder |
passed | 13.45 |
presignedUploadUrlReplacesExistingBlob |
passed | 2.25 |
presignedUploadUrlUploadsBlob |
passed | 1.44 |
presignedUploadWithContentTypeSetsContentType |
passed | 1.10 |
presignedUrlReportsExpiry |
passed | 0.22 |
putFromFile(Path) |
passed | 1.17 |
putFromStream |
passed | 1.23 |
putLargeFile(Path) |
passed | 33.63 |
putOverwritesExistingBlob |
passed | 1.87 |
putReturnsETagMatchingHead |
passed | 0.93 |
putSmallUnknownLengthStream |
passed | 1.21 |
putStoresContentTypeAndUserMetadata |
passed | 0.91 |
putStoresStandardHttpHeaders |
passed | 0.95 |
putThenGetRoundTripsContent |
passed | 1.21 |
putUnknownLengthStream |
passed | 33.64 |
putWithTierStoresTier |
passed | 1.80 |
rangeReadReturnsSlice |
passed | 1.69 |
setTierChangesTierButNotContent |
passed | 3.21 |
setTierToCurrentTierIsANoOp |
passed | 3.11 |
storedChecksumsMatchTheContent |
passed | 0.94 |
unwrapUnknownTypeThrows |
passed | 0.00 |
updateMetadataKeepsTier |
passed | 1.53 |
updateMetadataOfMissingBlobThrowsNotFound |
passed | 0.28 |
updateMetadataReplacesHeadersAndMetadataButNotContent |
passed | 2.39 |
updateMetadataWithStaleETagFails |
passed | 1.83 |
validatedFileAndStreamUploads(Path) |
passed | 4.55 |
validatedLargeFileUploadRoundTrips(Path) |
passed | 20.70 |
validatedPutStoresAChecksumThatValidatesOnDownload |
passed | 1.48 |
validatedWriteInParts |
passed | 28.28 |
writeEmptyBlob |
passed | 1.22 |
writeExactMultipleOfPartSize |
passed | 31.27 |
writeInPartsWithIfNotExistsRejectsExistingBlob |
passed | 23.56 |
writeLargeBlobInParts |
passed | 27.14 |
writeSmallBlobInOneRequest |
passed | 2.09 |
Firestore — DocumentStore: 23/23 passed, 15.3s
| Test | Result | Time (s) |
|---|---|---|
checksGuardATransactionWithoutWriting |
passed | 0.61 |
concurrentIncrementsAreNotLost |
passed | 0.87 |
decimalsRoundTripAsNumbers |
passed | 0.10 |
deleteReportsWhetherAnItemWasRemoved |
passed | 0.58 |
expiredItemsAreNeverReturned |
passed | 3.78 |
failedConditionRollsBackTheWholeTransaction |
passed | 0.92 |
getAllReturnsWhatExistsAcrossBatches |
passed | 0.31 |
ifNotExistsCreatesOnlyOnce |
passed | 0.23 |
indexQueryFindsItemsAcrossPartitionsInSortOrder |
passed | 2.92 |
indexQueryPagesThroughManyMatches |
passed | 0.38 |
indexWithoutSortAttributeFindsEveryMatch |
passed | 0.25 |
invalidKeysAndReservedFieldsAreRejected |
passed | 0.00 |
keysWithSpecialCharactersRoundTrip |
passed | 0.49 |
missingItemIsEmpty |
passed | 0.15 |
putAndGetRoundTripsEveryValueType |
passed | 0.15 |
putReplacesTheWholeItem |
passed | 0.16 |
queryPagesThroughLargePartitions |
passed | 0.79 |
queryReturnsAPartitionInSortOrder |
passed | 0.44 |
transactionAppliesEveryWriteTogether |
passed | 0.76 |
transactionsStayWithinOnePartition |
passed | 0.00 |
updateNeedsAnExistingItemAndTheRightVersion |
passed | 0.43 |
updateSetsRemovesAndIncrements |
passed | 0.20 |
versionsGuardConcurrentWrites |
passed | 0.77 |
Firestore — Spring Data repositories: 10/10 passed, 5.6s
| Test | Result | Time (s) |
|---|---|---|
indexQueriesAcrossPartitions |
passed | 0.60 |
indexWithoutASortAttribute |
passed | 0.30 |
partitionQueriesInSortKeyOrderEitherWay |
passed | 0.73 |
savedEntitiesReadBackWithTheirVersion |
passed | 0.20 |
sortKeyConditions |
passed | 0.62 |
staleVersionsAndTakenIdsAreRejected |
passed | 1.47 |
streamsReadLazily |
passed | 0.26 |
unversionedEntitiesAreSavedInBatchesAndOverwritten |
passed | 0.65 |
uuidKeysAndStringIds |
passed | 0.26 |
windowsScrollThroughAPartition |
passed | 0.54 |
Firestore — reactive Spring Data repositories: 10/10 passed, 4.9s
| Test | Result | Time (s) |
|---|---|---|
fluxQueriesStreamAndCanStopEarly |
passed | 0.24 |
indexQueriesAcrossPartitions |
passed | 0.52 |
indexWithoutASortAttribute |
passed | 0.26 |
partitionQueriesInSortKeyOrderEitherWay |
passed | 0.60 |
savedEntitiesReadBackWithTheirVersion |
passed | 0.24 |
sortKeyConditions |
passed | 0.58 |
staleVersionsAndTakenIdsAreRejected |
passed | 0.69 |
unversionedEntitiesAreSavedInBatchesFromIterablesAndPublishers |
passed | 0.61 |
uuidKeysAndStringIds |
passed | 0.22 |
windowsScrollThroughAPartition |
passed | 0.96 |
Pub/Sub — Publisher / Subscription: 13/15 passed, 250.8s
| Test | Result | Time (s) |
|---|---|---|
ackedMessageIsNotRedelivered |
passed | 25.61 |
binaryBodiesArriveUnchanged |
passed | 12.49 |
claimCheckSendsLargeBodiesThroughBlobStorage |
passed | 13.57 |
delayedMessageIsHeldBack |
skipped | 0.00 |
extendedLeaseKeepsMessageHidden |
passed | 31.93 |
listenerAcksSuccessesAndRetriesFailures |
passed | 34.49 |
listenerLimitsConcurrency |
passed | 22.06 |
nackedMessageIsRedelivered |
passed | 13.64 |
orderingKeyDeliversEachKeyInOrder |
passed | 20.60 |
publishAllDeliversEveryMessage |
passed | 20.00 |
publishedMessageArrivesWithBodyAndAttributes |
passed | 12.04 |
repeatedDeduplicationIdIsDeliveredOnce |
skipped | 0.00 |
reservedAttributesAndUnsupportedControlsAreRejected |
passed | 8.37 |
settlingTwiceIsRejected |
passed | 12.49 |
unsettledMessageReturnsAfterItsLease |
passed | 23.49 |
Secret Manager — ParameterStore: 22/22 passed, 164.0s
| Test | Result | Time (s) |
|---|---|---|
cachedReadsAreServedUntilRefresh |
passed | 7.55 |
deleteIsIdempotent |
passed | 7.20 |
deletedNamesCanBeReused |
passed | 10.64 |
earlierVersionsCanBeRead |
passed | 6.92 |
getAllDoesNotMatchPartialSegments |
passed | 12.17 |
getAllReturnsEverythingBelowAPrefix |
passed | 18.02 |
invalidNamesAreRejected |
passed | 0.30 |
jsonFieldsCanBeRead |
passed | 7.08 |
largeValues |
passed | 5.75 |
missingIsEmptyAndRequireFails |
passed | 1.20 |
nativeNamesAreUsedAsTheyAre |
passed | 7.40 |
plainValuesHaveNoFields |
passed | 5.88 |
putReplacesWithANewVersion |
passed | 7.39 |
putThenGet |
passed | 5.59 |
reportsKindAndProvider |
passed | 0.31 |
statisticsCountCacheHits |
passed | 5.82 |
storesAreIsolatedByPrefix |
passed | 9.11 |
typedReads |
passed | 5.77 |
valuesKeepSpecialCharactersAndLines |
passed | 6.97 |
watchReportsAddedUpdatedAndRemovedValues |
passed | 14.03 |
watchedChangesInvalidateTheCache |
passed | 9.02 |
writesInvalidateTheirOwnCache |
passed | 9.91 |
Parameter Manager, global — ParameterStore: 22/22 passed, 127.1s
| Test | Result | Time (s) |
|---|---|---|
cachedReadsAreServedUntilRefresh |
passed | 6.18 |
deleteIsIdempotent |
passed | 3.62 |
deletedNamesCanBeReused |
passed | 7.27 |
earlierVersionsCanBeRead |
passed | 5.74 |
getAllDoesNotMatchPartialSegments |
passed | 9.84 |
getAllReturnsEverythingBelowAPrefix |
passed | 14.48 |
invalidNamesAreRejected |
passed | 0.25 |
jsonFieldsCanBeRead |
passed | 6.06 |
largeValues |
passed | 4.40 |
missingIsEmptyAndRequireFails |
passed | 0.74 |
nativeNamesAreUsedAsTheyAre |
passed | 5.61 |
plainValuesHaveNoFields |
passed | 4.27 |
putReplacesWithANewVersion |
passed | 5.75 |
putThenGet |
passed | 5.22 |
reportsKindAndProvider |
passed | 0.24 |
statisticsCountCacheHits |
passed | 4.30 |
storesAreIsolatedByPrefix |
passed | 6.46 |
typedReads |
passed | 4.68 |
valuesKeepSpecialCharactersAndLines |
passed | 4.78 |
watchReportsAddedUpdatedAndRemovedValues |
passed | 12.41 |
watchedChangesInvalidateTheCache |
passed | 7.98 |
writesInvalidateTheirOwnCache |
passed | 6.82 |
Parameter Manager, `australia-southeast1` — ParameterStore: 22/22 passed, 38.6s
| Test | Result | Time (s) |
|---|---|---|
cachedReadsAreServedUntilRefresh |
passed | 1.75 |
deleteIsIdempotent |
passed | 1.16 |
deletedNamesCanBeReused |
passed | 2.25 |
earlierVersionsCanBeRead |
passed | 1.62 |
getAllDoesNotMatchPartialSegments |
passed | 2.72 |
getAllReturnsEverythingBelowAPrefix |
passed | 4.45 |
invalidNamesAreRejected |
passed | 0.06 |
jsonFieldsCanBeRead |
passed | 1.70 |
largeValues |
passed | 1.51 |
missingIsEmptyAndRequireFails |
passed | 0.20 |
nativeNamesAreUsedAsTheyAre |
passed | 1.51 |
plainValuesHaveNoFields |
passed | 1.38 |
putReplacesWithANewVersion |
passed | 1.53 |
putThenGet |
passed | 1.48 |
reportsKindAndProvider |
passed | 0.06 |
statisticsCountCacheHits |
passed | 1.31 |
storesAreIsolatedByPrefix |
passed | 2.54 |
typedReads |
passed | 1.47 |
valuesKeepSpecialCharactersAndLines |
passed | 1.35 |
watchReportsAddedUpdatedAndRemovedValues |
passed | 3.96 |
watchedChangesInvalidateTheCache |
passed | 2.42 |
writesInvalidateTheirOwnCache |
passed | 2.15 |
Eventarc Advanced → Pub/Sub — EventPublisher / CloudEvents.decode: 6/7 passed, 63.5s
| Test | Result | Time (s) |
|---|---|---|
aRepublishedEventKeepsItsId |
passed | 3.07 |
eventsWithoutDataArrive |
skipped | 0.00 |
listenersReceiveEvents |
passed | 19.80 |
oversizedEventsAreRefusedBeforeSending |
passed | 0.06 |
publishAllDeliversEveryEventAcrossRequests |
passed | 30.39 |
publishedEventArrivesWithEveryAttribute |
passed | 7.64 |
textAndBinaryData |
passed | 2.59 |
Tier 2: latest result#
TIER=2 GCP_PROJECT=<project> GCP_REGION=australia-southeast1 cloud-testing/scripts/gcp-real-cloud.sh
Terraform (cloud-testing/infra/gcp/tier2) creates Cloud SQL for PostgreSQL 17 and for MySQL 8.4:
- shared-core
db-f1-microon theENTERPRISEedition (newer versions default to Enterprise Plus, which has no shared-core tiers) - IAM authentication on, encrypted connections only, no backups, no deletion protection
- a public IP that accepts only the test machine's
/32; the Cloud SQL Java connector needs no authorized network
Each instance has three users:
halyx_admin, a built-in user with a password generated for the run- the test service account as an IAM user:
halyx-test@<project>.iamon PostgreSQL andhalyx-teston MySQL halyx_password_only, a built-in user that an IAM token must not log in
On MySQL the test then grants the IAM user SELECT on the app database, as the admin. IAM users start
with no privileges there; see the findings below.
| Date | 2026-10-16, 04:18–04:36 UTC (18 min: PostgreSQL ready in 12m 13s, MySQL in 12m 43s) |
| Run id | r2610161518d9f9 |
| Engines | PostgreSQL 17, MySQL 8.4 |
| Result | BUILD SUCCESS: 22 passed, 0 failed (JDBC 18, R2DBC 4); Spring Boot R2DBC test passed on Boot 3 and 4 |
| Teardown | instances (with their databases and users) destroyed; state deleted; check: nothing left |
| Test | PostgreSQL | MySQL |
|---|---|---|
loginLatency: runs first and times each login (see below); a warm direct-token login must take under 3s |
passed (16.6s) | passed (7.0s) |
passwordLoginRunsQueriesOverTls: direct, and the server reports the session encrypted |
passed (0.9s) | passed (1.0s) |
iamLoginWithDerivedUserRunsQueriesOverTls: SqlAuth.iam() derives the user from the service account |
passed (1.4s) | passed (1.4s) |
iamLoginWithExplicitUser: SqlAuth.iam("<user>") |
passed (1.3s) | passed (1.3s) |
connectorIamLogin: gcp.sql.instance through the Cloud SQL Java connector, no host or authorized network |
passed (1.0s) | passed (1.0s) |
connectorPasswordLogin |
passed (1.3s) | passed (1.2s) |
replacedConnectionsLogInAgainWithTokens: three rounds of filling the pool, then evicting it |
passed (6.9s) | passed (7.3s) |
tokenDoesNotLogInAUserNotEnabledForIam: a token for halyx_password_only is rejected |
passed (3.1s) | passed (3.1s) |
wrongPasswordDoesNotLogIn |
passed (1.3s) | passed (1.3s) |
r2dbcIamLoginWithDerivedUserRunsQueriesOverTls: R2DBC (halyx-r2dbc), SqlAuth.iam() with the user derived from the service account, over TLS |
passed (1.4s) | passed (1.4s) |
r2dbcThroughTheConnectorIsRefused: R2DBC needs a host: gcp.sql.instance is refused, saying so |
passed (0.0s) | passed (0.0s) |
Spring Data R2DBC over halyx.r2dbc-databases with Cloud SQL IAM, Spring Boot 3.5 and 4 |
passed, passed | — |
Login latency#
From run r2610111859cad1, the last one that timed logins.
Times per new connection, in the order they ran, on newly created instances:
| Login | PostgreSQL | MySQL |
|---|---|---|
| direct, password | 841 ms, then 332–339 ms | 4,515 ms, then 1,184–1,501 ms |
| direct, token | 1,426 ms, then 471–472 ms | 11,402 ms, then 1,538–2,299 ms |
| connector, token | 1,454 ms, then 354–368 ms | 8,700 ms, then 1,706–2,065 ms |
- PostgreSQL: a token login costs about 140 ms more than a password login. There's no slow first login like RDS's 5–6s.
- MySQL on
db-f1-micro: slow for every login, passwords included. The shared-core instance is the bottleneck, not IAM or Halyx, and a larger tier would bring it close to PostgreSQL. Its warm token logins (up to 2.3s) are near the test's 3s limit, so that limit could become flaky on this tier. - The connector costs the same as a direct connection once warm.
Tier 3: latest result#
TIER=3 GCP_PROJECT=<project> GCP_REGION=australia-southeast1 cloud-testing/scripts/gcp-real-cloud.sh
Terraform (cloud-testing/infra/gcp/tier3) creates the following, in a VPC and subnet of the run's own:
- Cache: a Memorystore for Valkey 8.0 instance: cluster mode disabled,
SHARED_CORE_NANO, IAM authorization, and TLS (SERVER_AUTHENTICATION) with a server certificate from Memorystore's private CA. Cluster mode is off so the client gets one endpoint and no redirects to node IPs a tunnel can't reach. - Private endpoint: a service-connection policy (
gcp-memorystore), under which Memorystore creates its Private Service Connect endpoint in the subnet. - Bastion: an
e2-microVM with no service account. A firewall rule allows SSH only from the test machine's/32, with a key generated for each run.
The script then:
- fetches the instance's CA (
gcloud memorystore instances get-certificate-authority) - forwards
127.0.0.1:16380through the bastion to the private endpoint - runs
RealGcpCacheIT
The certificate names the private endpoint, not the tunnel's 127.0.0.1, so the caches use
redis.verify-peer=ca. That checks the chain against the instance's CA, but not the name.
| Date | 2026-10-11, 08:38–09:07 UTC (29 min: Memorystore ready in 7m 6s, tests 17s, teardown about 20 min) |
| Run id | r261011193814e3 |
| Engine | Valkey 8.0, cluster mode disabled |
| Result | BUILD SUCCESS: 54 passed, 0 failed, on the first run |
| Teardown | instance, VM, firewall, connection policy, subnet and VPC destroyed; run key and CA deleted; check: nothing left |
| Test | Result | Time |
|---|---|---|
| Cache conformance kit, logged in with the identity's access token over TLS against the instance's CA | 49/49 passed | 12.1s |
iamTokenReadsAndWrites |
passed | 0.55s |
unauthenticatedConnectionIsRejected |
passed | 0.25s |
invalidTokenIsRejected: AUTH with a made-up token |
passed | 0.49s |
fullVerificationRejectsTheTunnelAddress: with full checks, the certificate doesn't name 127.0.0.1 |
passed | 0.25s |
chainIsRejectedWithoutTheInstanceCa: the CA is private, and the JDK's public roots don't trust it |
passed | 2.77s |
This is the first time Memorystore IAM authentication has been verified end to end. Before this,
MemorystoreIamTokens was only unit-tested, against a plain Valkey container with a password.
Unlike AWS tier 3, Memorystore IAM has no per-user access: the token's principal needs
memorystore.instances.connect (roles/memorystore.dbConnectionUser), and there are no database users to
mix up. So there is no "denied user" test. The rejection tests cover a missing and a forged token.
Cache conformance kit: 49/49 passed, 12.1s
| Test | Result | Time (s) |
|---|---|---|
binaryValuesWithBytesCodec |
passed | 0.09 |
clearMatchesNamespaceLiterally |
passed | 0.20 |
clearRemovesOnlyThisNamespace |
passed | 0.85 |
clearWithoutNamespaceIsRefused |
passed | 0.00 |
concurrentGetOrLoadSharesOneLoad |
passed | 0.46 |
customCodec |
passed | 0.10 |
defaultTtlApplies |
passed | 0.07 |
deleteAllCountsExistingKeys |
passed | 0.15 |
deleteIfEqualsOnlyDeletesMatchingValue |
passed | 0.20 |
deleteReportsWhetherKeyExisted |
passed | 0.15 |
emptyBulkOperations |
passed | 0.01 |
entriesExpire |
passed | 0.46 |
expireOnMissingKeyReturnsFalse |
passed | 0.04 |
expireSetsNewTtl |
passed | 0.41 |
expiredLeaseCannotReleaseTheNextHoldersLock |
passed | 0.59 |
extendKeepsTheLockAlive |
passed | 0.84 |
failingLoaderCachesNothing |
passed | 0.25 |
getAndDeleteConsumesOnce |
passed | 0.15 |
getOrLoadLoadsOnceThenServesFromCache |
passed | 0.18 |
getOrLoadWithTtl |
passed | 0.15 |
incrementCreatesAndAdds |
passed | 0.16 |
incrementKeepsTtl |
passed | 0.19 |
incrementOnNonNumberFails |
passed | 0.08 |
incrementWithTtlStartsAWindowOnce |
passed | 0.92 |
invalidKeysAreRejected |
passed | 0.01 |
largeValue |
passed | 1.72 |
lockGivesUpAfterWaiting |
passed | 0.30 |
lockIsExclusiveUntilReleased |
passed | 0.16 |
lockWaitsForTheHolder |
passed | 0.52 |
missingKeyIsEmpty |
passed | 0.14 |
namespacesAreIsolated |
passed | 0.12 |
nonPositiveTtlIsRejected |
passed | 0.01 |
nullLoadResultIsNotCached |
passed | 0.15 |
putAllAndGetAll |
passed | 0.14 |
putAllWithTtl |
passed | 0.17 |
putIfAbsentOnlyStoresOnce |
passed | 0.14 |
putIfAbsentWithTtlExpires |
passed | 0.41 |
putOverwrites |
passed | 0.14 |
putThenGet |
passed | 0.09 |
replaceIsCompareAndSet |
passed | 0.27 |
reportsProviderAndNamespace |
passed | 0.00 |
statisticsCountHitsMissesAndLoads |
passed | 0.37 |
ttlOfMissingKeyIsEmpty |
passed | 0.04 |
ttlReportsRemainingTime |
passed | 0.08 |
unicodeKeysAndValues |
passed | 0.08 |
unwrapUnknownTypeThrows |
passed | 0.00 |
updateOfMissingKeyStoresNothing |
passed | 0.08 |
updateReplacesExistingValueAndKeepsTtl |
passed | 0.17 |
withoutTtlEntriesDoNotExpire |
passed | 0.11 |
Tier 4: latest result#
TIER=4 GCP_PROJECT=<project> GCP_REGION=australia-southeast1 cloud-testing/scripts/gcp-real-cloud.sh
Terraform (cloud-testing/infra/gcp/tier4) creates the following, in a VPC and subnet of the run's own:
- Cluster: a Managed Service for Apache Kafka cluster with the smallest capacity, 3 vCPUs and 3 GiB. Its brokers get Private Service Connect addresses in the subnet, and the service adds their DNS names to the VPC. There is no public endpoint.
- Runner: an
e2-smallUbuntu 24.04 VM that runs as the test service account. A firewall rule allows SSH only from the test machine's/32, with a key generated for each run. Its startup script installs Java 21. - Claim checks: a Cloud Storage bucket, so the claim-check test runs too.
Kafka clients connect to the bootstrap address, then to each broker by the name the cluster advertises, so
an SSH tunnel to one address, as in tier 3, can't work. The tests run on the runner instead. The script
builds them on the test machine, copies them with their classpath (cloud-testing/scripts/in-vpc-runner.sh, 172 jars,
141 MB), and runs RealGcpStreamIT there with the JUnit console launcher. The reports come back into
halyx-gcp/target/failsafe-reports. No credentials leave the test machine: the runner gets tokens from
its metadata server, as an application on Compute Engine would.
RealGcpStreamIT logs in with Google IAM through SASL OAUTHBEARER, the token wrapping the service
account's access token. It creates each test's topics through Kafka's admin API, logged in the same way, and
deletes older ones as it goes. Then RealSpringStreamIT runs on the runner on Spring Boot 3.5 and on Spring
Boot 4: in one Spring application, the starter's StreamProducer bean and @HalyxListener, and Spring for
Apache Kafka's KafkaTemplate and @KafkaListener logging in through halyx.spring-kafka.cluster. The
script stages the three test modules' jars into one directory on the runner and sends each jar once (313 MB
in all).
| Date | 2026-10-16, 00:30–01:04 UTC (34 min: cluster created in 12m 9s, tests 2m 25s, the rest staging, copying and teardown) |
| Run id | r2610161130d877 |
| Runner | e2-small, OpenJDK 21.0.12.1; Kafka clients 3.9.2 (kit and Boot 3, with Spring for Apache Kafka 3.3.16) and 4.2.1 (Boot 4, with Spring for Apache Kafka 4.1.1) |
| Result | all passed: kit 25 of 25; Spring 2 of 2 |
| Teardown | cluster, runner, firewall rule, subnet, VPC and bucket destroyed; run key deleted; check: nothing left |
| Test | Result |
|---|---|
| Stream conformance kit, with Google IAM tokens over TLS | 25/25 passed (123.2s) |
RealSpringStreamIT on Spring Boot 3.5: starter, KafkaTemplate and @KafkaListener |
passed (11.0s) |
RealSpringStreamIT on Spring Boot 4 |
passed (10.9s) |
largeValuesTravelThroughAClaimCheck |
passed: a 2 MiB value through the bucket |
idempotentProducersNeedTheCapability, transactionalSendAllIsVisibleToReadCommittedConsumers |
passed: Managed Kafka is Apache Kafka, so Halyx declares both capabilities, and both work |
The first tier 4 run (r261016095774dd) was the first time Managed Kafka's IAM login had been checked end to
end: until then its tokens had only been unit-tested (ManagedKafkaTokensTest). It passed first time and found
nothing to fix.
Stream conformance kit: 25/25 passed in the first run, 125.1s
| Test | Result | Time (s) |
|---|---|---|
aFailingPartitionDoesNotHoldBackTheOthers |
passed | 4.33 |
binaryValuesArriveUnchanged |
passed | 4.24 |
closingTheListenerLetsTheRunningHandlerFinish |
passed | 7.28 |
committedPositionIsWhereTheGroupResumes |
passed | 9.10 |
everyGroupReadsEveryRecord |
passed | 7.56 |
exhaustedRecordGoesToTheDeadLetterTopic |
passed | 7.95 |
failedRecordIsRetriedBeforeTheRestOfItsPartition |
passed | 4.25 |
handledRecordsAreCommittedWhenTheListenerStops |
passed | 9.17 |
handlerSeesEveryRecordOfItsPartitionInOrder |
passed | 4.26 |
idempotentProducersNeedTheCapability |
passed | 4.28 |
largeValuesTravelThroughAClaimCheck |
passed | 5.43 |
latestStartSkipsEarlierRecords |
passed | 5.28 |
listenerHandlesEveryRecord |
passed | 4.81 |
membersOfAGroupShareThePartitions |
passed | 7.73 |
oversizedRecordWithoutClaimCheckIsRejected |
passed | 0.59 |
partitionCountMatchesTheTopic |
passed | 0.59 |
pollAndListenCannotBeMixed |
passed | 3.76 |
recordsWithTheSameKeyStayInOrder |
passed | 4.48 |
recordsWithoutKeyArrive |
passed | 4.56 |
reservedHeadersAreRejected |
passed | 0.57 |
sameKeyGoesToOnePartitionWithIncreasingOffsets |
passed | 0.80 |
sendAllReturnsResultsInOrder |
passed | 4.93 |
sentRecordArrivesWithKeyValueAndHeaders |
passed | 4.29 |
transactionalSendAllIsVisibleToReadCommittedConsumers |
passed | 5.40 |
uncommittedRecordsAreReadAgain |
passed | 7.87 |
Tier 5: latest result#
TIER=5 GCP_PROJECT=<project> cloud-testing/scripts/gcp-real-cloud.sh
Terraform (cloud-testing/infra/gcp/tier5) creates a bucket, and grants roles/storage.objectUser on it to the
login target, halyx-login-target: a standing service account with no other roles, which the test service account
may impersonate (set up once).
RealGcpLoginIT builds Halyx with Credentials.impersonate(target), starting from Application Default Credentials:
| Test | Checks | Result |
|---|---|---|
servicesActAsTheImpersonatedServiceAccount |
a blob written and read as the target; Google's token info for the token Cloud Storage uses names the target (its azp, the account's unique ID) |
passed |
anAccountItMayNotActAsIsRefused |
impersonating the Compute Engine default service account fails with iam.serviceAccounts.getAccessToken denied |
passed |
theDefaultChainActsAsTheTestIdentity |
without impersonation, the token isn't the target's | passed |
Then Spring Boot 3.5 and 4: a provider with credentials.type: impersonate and a BlobStore bean that uses it.
3/3; Spring 2/2, run r2610231129846c, nothing left after teardown.
Run history#
| Run id | Scope | Result | Teardown | Notes |
|---|---|---|---|---|
r2610111704a192 |
tier 1 | 176 passed, 1 failed, 1 error, 3 skipped | clean (checked by hand) | Pub/Sub orderingKeyDeliversEachKeyInOrder and Cloud Storage updateMetadataWithStaleETagFails failed (see below). The regional Parameter Manager check failed with a misleading PERMISSION_DENIED, and the script took the failure as empty. |
r2610111748599e |
tier 1 | 176/179, 3 skipped | clean | |
r26101118357636 |
tier 2 | 13/18 | clean | PostgreSQL 9/9. MySQL's five IAM tests logged in, then were refused the app database: IAM users have no privileges until granted. |
r2610111859cad1 |
tier 2 | 18/18 | clean | The test now grants the MySQL IAM user SELECT on app. Login latency above is from this run. |
r261011193814e3 |
tier 3 | 54/54 | clean | Latest tier 3 result, above, on the first run. Teardown took about 20 min: the VPC can only go once Memorystore's delete has finished. |
r261016095774dd |
tier 4 | 25/25 | clean | The first run. Cluster created in 24m 7s. |
r2610161130d877 |
tier 4, plus Spring Boot 3 and 4 | 25/25; Spring 2/2 | clean | Latest tier 4 result, above. |
r26101613087031 |
tier 1, plus Spring Data repositories and Spring Boot 3 and 4 | 186/189, 3 skipped; Spring 0/2 | clean | Repositories 10/10 on Firestore. The Spring test compared whole records: Firestore keeps decimals as doubles, so a BigDecimal of 10 read back as 10.0, and the records weren't equals. The test now compares the value; the guide says so. |
r26101613452b74 |
tier 1, plus Spring Data repositories and Spring Boot 3 and 4 | 186/189, 3 skipped; Spring 2/2 | clean | Latest tier 1 result, above, after renaming the module to halyx-spring-data-document. |
r2610161518d9f9 |
tier 2, plus R2DBC and Spring Boot 3 and 4 | 22/22; Spring 2/2 | clean | Latest tier 2 result, above. R2DBC's IAM login passed on both engines first time. |
r2610161942fea5 |
tier 1, plus the event bus kit and Spring Boot 3 and 4 | 184 passed, 1 failed, 5 errors, 3 skipped; Spring 2/4 | clean | Eventarc 1/6 and both Spring event tests: the driver published to the regional <location>-eventarcpublishing endpoint, which answers 404 for message buses. Probing by hand showed a second problem: the pipeline's publish to Pub/Sub was refused with 403, having no credentials. Secret Manager putReplacesWithANewVersion read the previous value once. |
r26101620472de5 |
tier 1, plus the event bus kit and Spring Boot 3 and 4 | 185 passed, 3 failed, 3 skipped; Spring 4/4 | clean | Global endpoint and authenticated pipeline: Eventarc 5/6. Pub/Sub refused (400) the pipeline's publish of the event without data. Secret Manager read a previous value twice; the kit now allows for latest being eventually consistent. |
r26101622034481 |
tier 1, plus the event bus kit and Spring Boot 3 and 4 | 192 passed, 4 skipped; Spring 4/4 | clean | Eventarc 6/7, with events without data declared a known gap. |
r2610170214c74e |
tier 1, plus halyx-reactor and Spring Boot 3 and 4 | 202 passed, 4 skipped; Spring 10/10 | clean | Latest tier 1 result, above. Reactive repositories 10/10 on Firestore; the reactive Spring test (WebFlux over the bucket, a reactive repository, events through the reactive beans) 3/3 on Boot 3.5 and 4. |
r2610171333de9d |
tier 1, plus Spring Boot 3 and 4 | 205 passed, 4 skipped; Spring 16/16 | clean | Latest tier 1 result, with the new Spring tests: @HalyxListener with a claim check (RealSpringMessagingIT) and spring.config.import with reload, health ping and the store bean's cache (RealSpringParametersIT) on Pub/Sub, Secret Manager and Parameter Manager. |
r2610171412729c |
tier 2, plus Spring Boot 3 and 4 | 22/22; Spring 2/2 | clean | Latest tier 2 result. |
r261017143360ac |
tier 3, plus Spring Boot 3 and 4 | 55/55; Spring 2/2 | clean | Latest tier 3 result: the cache kit with ping, then Cache beans with IAM over TLS (private CA), a JSON codec and @Cacheable on Boot 3.5 and 4. |
r2610171453c670 |
tier 4, plus Spring Boot 3 and 4 | 25/25; Spring 2/2 | clean | Latest tier 4 result. |
r26101721004590 |
tier 1, plus Spring Boot 3 and 4 | 218 passed, 4 skipped; Spring 16/16 | clean | Latest tier 1 result, with the asynchronous views (blob, document, messaging and event kits) on every service, non-blocking where the driver has a path: Firestore, Pub/Sub and Eventarc. |
r26101802235ad4 |
tier 1, plus Spring Boot 3 and 4 | 221 passed, 4 skipped; Spring 16/16 | clean | Latest tier 1 result, with asynchronous receives and settlement in the messaging kit, non-blocking on Pub/Sub. |
r2610181923c397 |
tier 1, plus Spring Boot 3 and 4 | 230 passed, 4 skipped; Spring 16/16 | clean | Latest tier 1 result, with the asynchronous parameter store: Secret Manager and Parameter Manager (global and regional) non-blocking, proven with a counting executor. |
r261022150030d1 |
tier 1, plus Spring Boot 3 and 4 | 230 passed, 4 skipped; Spring 16/16 | clean | Full regression before 1.0.0, at be792e3. |
r26102215401be8 |
tier 2, plus Spring Boot 3 and 4 | 22/22; Spring 2/2 | clean | Full regression before 1.0.0, at be792e3. |
r2610221559bb6b |
tier 3, plus Spring Boot 3 and 4 | 57/57; Spring 2/2 | clean | Full regression before 1.0.0, at be792e3. |
r2610221620c638 |
tier 4, plus Spring Boot 3 and 4 | 29/29; Spring 2/2 | clean | Full regression before 1.0.0, at be792e3, with the kit's new listener tests: a replacement listener carries on where the last stopped, and a joining member waits for the previous owner's partitions. Cluster created in 13m 19s. |
r2610231113261d |
tier 5, plus Spring Boot 3 and 4 | 1/3; Spring 2/2 | clean | The first tier 5 run. Impersonation was refused for a minute after the Token Creator grant on the target was made, then worked, as the Spring tests show. The test's own token check refreshed unscoped credentials, which Google refuses, and looked for an email, which token info leaves out for a cloud-platform-only token. Two runs of RealGcpLoginIT alone confirmed the fixes. |
r2610231129846c |
tier 5, plus Spring Boot 3 and 4 | 3/3; Spring 2/2 | clean | Latest tier 5 result, above. |
r26103118357945 |
tier 1, functions only (IT_TEST='RealGcpIT$CloudRunFunctions', SPRING_IT_TEST=RealSpringFunctionIT) |
not run | clean | Stopped at terraform apply: the test service account had no cloudfunctions.functions.create. It was granted Cloud Functions Developer, Cloud Run Admin (to grant run.invoker on the function) and Cloud Build Service Account, and the function is now built as the test service account. The Cloud Functions, Cloud Build and Cloud Run APIs were enabled first. |
r2610312021456e |
tier 1, functions only | 10 passed, 2 skipped (expected: no trigger and no header limit on Cloud Run functions); Spring 4/4 |
clean | First function run: the function kit at the function's run.app URL with Google ID tokens, one instance taking one request at a time; a missing function at its default URL answered 404. RealSpringFunctionIT 2/2 on Boot 3.5 and 4, at the default cloudfunctions.net URL. |
r261101194024cd |
tier 1, plus Spring Boot 3 and 4 | 240 passed, 6 skipped; Spring 20/22, 2 skipped | clean | Full regression before 1.1.0, at 40d4101. Tier 1 now includes the function kit on Cloud Run functions. The skips are expected: features the services don't offer, and on Spring the Key Vault rotation test, which runs on Azure only. |
r261101214786d7 |
tier 2, plus Spring Boot 3 and 4 | 22/22; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. |
r261101220895fe |
tier 3, plus Spring Boot 3 and 4 | 57/57; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. |
r26110122291cd3 |
tier 4, plus Spring Boot 3 and 4 | 29/29; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. |
r26110123052fed |
tier 5, plus Spring Boot 3 and 4 | 3/3; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. |
What real GCP found#
The emulators (Floci-gcp, and Google's Pub/Sub and Firestore emulators) missed all of these:
| Finding | Fix |
|---|---|
Pub/Sub rejects a publish request whose messages have different ordering keys (FAILED_PRECONDITION: ... all messages must have no ordering key or they must all have the same ordering key) |
The publisher sends one request per ordering key, keeping each key's messages in order, and returns ids in the caller's order |
updateMetadata with a stale ETag patched the old generation, which no longer exists in an unversioned bucket, so GCS answered 404 instead of 412 |
Patch the live object with an ifGenerationMatch precondition, which GCS answers with 412 |
gcloud parametermanager sends regional locations to the global endpoint and gets PERMISSION_DENIED |
The test script uses the regional endpoint (parametermanager.<region>.rep.googleapis.com). The Java client was already correct |
Cloud SQL for MySQL IAM users have no privileges, so a connection naming a database is refused (Access denied for user 'halyx-test'@'%' to database 'app'). PostgreSQL grants CONNECT to everyone |
Documented in the README; the test grants SELECT as the admin, as an application's setup would |
| Memorystore's server certificate comes from a private, per-instance CA and names the private endpoint, so through a tunnel or proxy the name never matches | New redis.verify-peer=ca checks the chain against the CA (redis.ca-certificate) without the name. Full checks stay the default |
| Firestore collection-group queries need indexes the emulator doesn't ask for | Terraform creates them; the driver's documentation already lists them |
Eventarc Advanced message buses aren't served by the regional <location>-eventarcpublishing endpoints, which answer 404 with an HTML page (they serve Eventarc Standard channels) |
Publishing goes to the global eventarcpublishing.googleapis.com endpoint, whatever the bus's location |
| A pipeline with a Pub/Sub destination and no authentication sends its publish without credentials, and Pub/Sub refuses it with 403. The bus accepts the event, so the publisher sees no error | Terraform names a service account for the destination's OAuth token, and the Eventarc service agent gets Token Creator on it. The API guide says so; pipeline error logging is on in Terraform |
| Pub/Sub refuses (400) a pipeline's publish of an event without data, so such events never arrive | Not fixable in Halyx: the kit declares it a known gap on Eventarc, and the API guide says to give events data on Google Cloud |
Secret Manager's latest alias is eventually consistent: a read right after a new version occasionally returned the previous one: 4 failed reads in 5 runs of the Secret Manager kit on 2026-10-16, after passing on every earlier run, but none in 39 probes (14 by REST, 25 by gRPC) |
Google guarantees only reads by version number. The kit allows overwriteLag() on Secret Manager, as it allows listing and delete lag on AWS. The store doesn't wait |
IAM#
| Who | Role | Why |
|---|---|---|
halyx-test service account |
roles/storage.admin |
create and destroy the bucket; read and write objects |
roles/datastore.owner |
create and destroy the Firestore database and its indexes | |
roles/pubsub.admin |
create and delete topics and subscriptions; publish and pull | |
roles/secretmanager.admin |
secrets | |
roles/parametermanager.admin |
parameters, global and regional | |
roles/eventarc.admin |
create and delete the message bus, pipeline and enrollment; publish events | |
roles/eventarc.messageBusUser |
publish to the message bus (eventarc.messageBuses.publish), the role an application gets |
|
roles/serviceusage.serviceUsageConsumer |
call APIs billed to the project | |
roles/compute.admin (tier 3) |
the run's VPC, subnet, firewall rule and bastion VM | |
roles/memorystore.admin (tier 3) |
create and delete the instance; read its CA | |
roles/memorystore.dbConnectionUser (tier 3) |
IAM login to the instance (memorystore.instances.connect), the role an application gets |
|
roles/networkconnectivity.consumerNetworkAdmin (tier 3) |
the service-connection policy for Memorystore's private endpoint | |
roles/managedkafka.admin (tier 4) |
create and delete the cluster | |
roles/managedkafka.client (tier 4) |
IAM login to the cluster (managedkafka.clusters.connect), the role an application gets |
|
roles/cloudsql.admin (tier 2) |
create instances and database users. It also includes what an application needs: cloudsql.instances.login for IAM database login (roles/cloudsql.instanceUser) and cloudsql.instances.connect for the connector (roles/cloudsql.client) |
|
roles/iam.serviceAccountUser on itself (tiers 1 and 4) |
name itself as the Eventarc pipeline's publishing identity (tier 1); run the runner VM as itself (tier 4) | |
the Eventarc service agent (service-<project number>@gcp-sa-eventarc.iam.gserviceaccount.com) |
roles/iam.serviceAccountTokenCreator on halyx-test |
the pipeline publishes to Pub/Sub with an OAuth token for halyx-test, which the service agent mints |
| the person running the tests | roles/iam.serviceAccountTokenCreator on halyx-test |
impersonate it. Project Owner doesn't include this |
halyx-test service account |
roles/iam.serviceAccountTokenCreator on halyx-login-target (tier 5) |
impersonate the login target through Halyx |
halyx-login-target service account (tier 5) |
roles/storage.objectUser on each run's bucket (Terraform) |
the objects the impersonated tests read and write. It has no other roles |
The project also needs these APIs enabled: the IAM Service Account Credentials API
(iamcredentials.googleapis.com) for impersonation, Eventarc and Eventarc Publishing
(eventarc.googleapis.com, eventarcpublishing.googleapis.com) for tier 1's event bus, and the Cloud SQL Admin API
(sqladmin.googleapis.com) for tier 2, and Compute Engine, Memorystore, Network Connectivity and Service
Consumer Management (compute, memorystore, networkconnectivity, serviceconsumermanagement) for tier 3,
and Managed Service for Apache Kafka (managedkafka) for tier 4.
Tier 5 needs a standing service account to impersonate, created once:
gcloud iam service-accounts create halyx-login-target --display-name="Halyx tier 5: impersonation target"
gcloud iam service-accounts add-iam-policy-binding halyx-login-target@<project>.iam.gserviceaccount.com \
--member=serviceAccount:halyx-test@<project>.iam.gserviceaccount.com --role=roles/iam.serviceAccountTokenCreator
Its refusal test impersonates the Compute Engine default service account (<project number>-compute@developer.gserviceaccount.com),
on which halyx-test has no rights; set GCP_LOGIN_DENIED to use another.
IAM for the GKE run#
cloud-testing/scripts/gke-real-cloud.sh runs the portable orders service on GKE. The nodes
and the pods run as halyx-test, which already holds every data-plane role above, so the service needs nothing new.
Creating the cluster and its registry does:
| Who | Role | Why |
|---|---|---|
halyx-test service account |
roles/container.admin |
create and delete the cluster and its node pool; deploy to it |
roles/artifactregistry.admin |
create and delete the run's repository and push the images; the nodes pull with it too | |
the Kubernetes service account portable-orders/portable-orders |
roles/iam.workloadIdentityUser on halyx-test |
the pods act as halyx-test through Workload Identity Federation for GKE |
The project also needs the Kubernetes Engine and Artifact Registry APIs (container, artifactregistry). The
workload identity binding can only be made once the project's workload identity pool exists, which happens when the
first cluster with Workload Identity is created. So the script makes the binding itself, right after the cluster is
created, as the person running it; on later runs it changes nothing.
gcloud services enable container.googleapis.com artifactregistry.googleapis.com
for role in roles/container.admin roles/artifactregistry.admin; do
gcloud projects add-iam-policy-binding <project> --member=serviceAccount:halyx-test@<project>.iam.gserviceaccount.com \
--role=$role --condition=None
done