Skip to content

fix(aws-kafka): async lifecycle, scheduled AWS/Kafka metrics, real broker subnets, managed KMS key - #1654

Merged
Satyam-Trivedi-ZS merged 2 commits into
stackshy:developmentfrom
Satyam-Trivedi-ZS:fix/aws-kafka-parity-1517
Oct 23, 2026
Merged

Satyam-Trivedi-ZS merged 2 commits into
stackshy:developmentfrom
Satyam-Trivedi-ZS:fix/aws-kafka-parity-1517

Conversation

@Satyam-Trivedi-ZS

@Satyam-Trivedi-ZS Satyam-Trivedi-ZS commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Closes the six open real-cloud parity gaps for Amazon MSK listed in AWS Kafka: open real-cloud parity gaps #1517: broker client subnets, the CREATING, DELETING and VPC-connection lifecycles, AWS/Kafka CloudWatch metrics, and the default at-rest KMS key.
  • The lifecycle states appear only with --async-settle. The default mode is still synchronous.
  • Metrics are published by the shared serve scheduler. No request publishes a metric.

Item status

# Item Status Evidence
1 (M) ListNodes fabricates clientSubnet as subnet-<brokerId> FIXED Confirmed in providers/aws/kafka/nodes.go. The API reference says: "When you create a cluster, Amazon MSK distributes the broker nodes evenly across the subnets that you specify" (BrokerNodeGroupInfo). Brokers now get the cluster's own ClientSubnets in round-robin order by broker ID. If the cluster has no subnets, clientSubnet is left out.
2 (M) Cluster ACTIVE immediately, CREATING never visible FIXED With --async-settle, CreateCluster/CreateClusterV2, Describe and List show CREATING for settle.DefaultClusterSettle (3s), then ACTIVE. Update operations on a cluster that is not ACTIVE return BadRequestException. The MSK docs say that in CREATING you "must wait for the cluster to reach the ACTIVE state before you can ... perform Amazon MSK API or AWS CLI operations on it" (cluster states).
3 (M) No AWS/Kafka metrics, no SetMonitoring wiring FIXED Kafka.SetMonitoring(CloudWatch) is wired. Kafka is now a config.Tickable and runs before CloudWatch in Provider.Tickables(), so alarms see the metrics from the same tick. MSK pushes metrics "at 1 minute intervals", and the DEFAULT metrics become visible "After the cluster gets to the ACTIVE state" (metrics-details). See the metrics section below.
4 (L) DeleteCluster removes synchronously, DELETING never visible FIXED With --async-settle, the cluster stays visible as DELETING in Describe and List and keeps its name, so a create with the same name gets ConflictException. After the window it returns NotFoundException (404). A second delete while DELETING returns DELETING.
5 (L) CreateVpcConnection returns AVAILABLE immediately FIXED VpcConnectionState is CREATING, AVAILABLE, INACTIVE, UPDATING, DEACTIVATING, DELETING, FAILED, REJECTED or REJECTING (API reference). With --async-settle, a new connection shows CREATING, then AVAILABLE. REJECTED is never hidden.
6 (L) Default at-rest key synthesized as key/msk-default FIXED The docs say: "If you don't specify EncryptionAtRest, Amazon MSK still encrypts your data at rest under the AWS managed key" (alias/aws/kafka), and DescribeCluster reports that key (encryption). The provider now fills in encryptionAtRest.dataVolumeKMSKeyId at create time. The value is a arn:aws:kms:<region>:<account>:key/<uuid> ARN that is stable for each account and region, so every cluster in that account and region reports the same key. The in-transit defaults are ClientBroker=TLS and InCluster=true, as documented. The wire layer no longer hard-codes a us-east-1:000000000000 key.

Changes

  • providers/aws/kafka/nodes.go: broker clientSubnet comes from the cluster's ClientSubnets.
  • providers/aws/kafka/lifecycle.go (new): per-record lifecycle deadlines: the cluster's readyAt/deleteAt and the VPC connection's readyAt.
    • The windows are set through opts.SettleDuration(settle.DefaultClusterSettle), so they are zero, and inactive, unless --async-settle is set.
    • A cluster whose DELETING window has passed is removed by the serve Tick, which reports the change so --persist saves it, or on the next read or create, whichever comes first, under reapMu.
    • requireActive guards mutateClusterErr, plus topic create/update/delete, SCRAM secret associate/disassociate and cluster policy put/delete. Reads stay allowed.
    • No changes to internal/settle.
  • providers/aws/kafka/metrics.go (new): Tick(now) publishes DEFAULT-level AWS/Kafka metrics once a minute for each ACTIVE provisioned cluster, after all cluster locks are released. It also removes clusters whose DELETING window has ended. Every state check uses Tick's now.
  • providers/aws/kafka/encryption.go (new): fills in EncryptionInfo the way MSK does. Each field the request leaves out gets its documented default: the managed at-rest key, clientBroker TLS and inCluster true. Explicit values, including inCluster: false, are kept.
  • providers/aws/kafka/snapshot.go: the lifecycle deadlines and the last metric sample time are persisted. Every record is deep-copied under its own lock, which fixes a race with concurrent topic and SCRAM writes that existed before this PR. The per-store loops are now helpers.
    • vpcConnReadyAt sits beside vpcConns, so older snapshots still load.
    • Older snapshots without encryptionInfo get the default filled in on restore.
  • server/aws/kafka/types.go: the hard-coded defaultEncryptionInfo is removed. v1 and v2 describes both show the encryptionInfo stored by the provider. This file only translates the wire format.
  • providers/aws/aws.go: wires Kafka.SetMonitoring(CloudWatch) and adds p.Kafka to Tickables().

AWS/Kafka metrics published (all Count, every 60s, ACTIVE provisioned clusters only)

Metric Dimensions Value
ActiveControllerCount Cluster Name 1
GlobalTopicCount Cluster Name number of stored topics
GlobalPartitionCount Cluster Name sum of the topics' partition counts (replicas not counted, as documented)
OfflinePartitionsCount Cluster Name 0
UnderReplicatedPartitions Cluster Name, Broker ID (1..N, the same IDs ListNodes returns) 0
UnderMinIsrPartitionCount Cluster Name, Broker ID 0

These metrics are deliberately not published:

  • CPU, memory, disk, network, connection and byte or message rate metrics, because they describe broker hosts and client traffic that cloudemu does not run.
  • Per-broker PartitionCount and LeaderCount, because they need a partition assignment that is not modeled.
  • PER_BROKER and higher levels, because none of those metrics has a value cloudemu knows.
  • Serverless clusters, which use a different metric set.

Provider Coverage

  • AWS
  • Azure (n/a: MSK is AWS-only)
  • GCP (n/a)
  • OCI (n/a)

Checklist

  • All tests pass: go test -p 2 -count=1 over all 657 packages, run in chunks. Everything passed.
  • Linter: golangci-lint run --allow-parallel-runners --concurrency 2 --new-from-rev=upstream/development ./providers/aws/kafka/... ./server/aws/kafka/... ./providers/aws/ reports 0 issues., and a full run (without --new-from-rev) on ./providers/aws/kafka/... ./server/aws/kafka/... also reports 0 issues.
  • Every provider the change applies to implements the same behavior (AWS only)
  • Integration tests added to cloudemu_test.go (not added: the tests are provider tests plus aws-sdk-go-v2 wire tests)
  • Unit tests added to provider test files
  • Regenerated docs: go generate . produced no docs/coverage diff, because no driver interface or wiring of operations changed

Test Plan

New tests. Each one fails on upstream/development, checked with a no-op SetMonitoring/Tick stub so the tests compile, and passes with this change:

  • providers/aws/kafka/parity_test.go (FakeClock):
    • TestListNodesClientSubnetFromCluster
    • TestListNodesNoSubnetsOmitsClientSubnet
    • TestClusterCreatingThenActive
    • TestDeleteClusterDeletingThenGone
    • TestVpcConnectionCreatingThenAvailable
    • TestClusterMetricsPublishedByTick: checks that create publishes nothing, nothing is published while CREATING, publishing happens once per minute, and no host metrics are made up
    • TestDefaultEncryptionAtRestKey
    • TestSnapshotSettleStates: CREATING, DELETING and VPC CREATING all survive snapshot and restore, then settle
  • TestTickSkipsDeletingAndServerless: new; passes on the fixed branch.
  • providers/aws/kafka/review_test.go (review follow-up; each one fails before c86fc4e):
    • TestSnapshotConcurrentWithMutations: -race
    • TestDefaultEncryptionInTransitCompleted
    • TestTickUsesItsNow
    • TestTickReapsFinishedDelete
    • TestClusterWritesRequireActive
    • TestCompatibleVersionsAfterDeleteWindow
    • TestCreateReclaimsDanglingName: coverage of the reapName branch
  • server/aws/kafka/parity_sdk_test.go (real aws-sdk-go-v2 kafka and cloudwatch clients):
    • TestSDKListNodesClientSubnet
    • TestSDKAsyncClusterLifecycle
    • TestSDKAsyncVpcConnectionCreating
    • TestSDKKafkaMetricsAndDefaultKey: ListMetrics is empty after create. After two scheduler ticks one minute apart, GetMetricStatistics returns 2 datapoints with SampleCount 1, Maximum 1 and Unit Count. The v1 and v2 key ARNs match.
  • providers/aws/tickables_test.go is updated for the new Kafka tickable.

Commands, all passing:

go build ./...
go test -race -count=1 ./providers/aws/kafka/... ./server/aws/kafka/... ./persist/... ./providers/aws/
go test -p 2 -count=1 <all 657 packages, in chunks of 50>

Real-user test (cloudemu serve plus AWS CLI 1.44.87 / botocore 1.42.97)

With serve -providers aws -aws-port 4607 -async-settle:

aws kafka create-cluster --cluster-name orders --number-of-broker-nodes 3 \
  --broker-node-group-info '{"InstanceType":"kafka.m5.large","ClientSubnets":["subnet-0aaa","subnet-0bbb","subnet-0ccc"]}' ...
  -> "State": "CREATING"
aws kafka describe-cluster ...                      -> "CREATING"
aws kafka update-broker-count ... (while CREATING)  -> BadRequestException: ... is in CREATING state; this operation requires ACTIVE
(4s later) aws kafka describe-cluster ...           -> "ACTIVE"
aws kafka list-nodes ...                            -> 1 subnet-0aaa / 2 subnet-0bbb / 3 subnet-0ccc
aws kafka describe-cluster --query ClusterInfo.EncryptionInfo
  -> DataVolumeKMSKeyId arn:aws:kms:us-east-1:000000000000:key/<uuid>, ClientBroker TLS, InCluster true
aws kafka describe-cluster-v2 ... Provisioned.EncryptionInfo.EncryptionAtRest -> same key ARN
aws kafka create-vpc-connection ...                 -> CREATING; describe-vpc-connection -> CREATING, then AVAILABLE
(about 1 minute later, no traffic)
aws cloudwatch list-metrics --namespace AWS/Kafka   -> 4 metrics on "Cluster Name=orders", plus UnderReplicatedPartitions
                                                       and UnderMinIsrPartitionCount for Broker ID 1..3
aws cloudwatch get-metric-statistics --metric-name ActiveControllerCount --dimensions 'Name=Cluster Name,Value=orders' --period 60 ...
  -> 2 datapoints, each SampleCount 1.0, Maximum 1.0, Unit Count (one per minute)
aws kafka delete-cluster ...                        -> "State": "DELETING"
aws kafka describe-cluster / list-clusters-v2       -> DELETING
aws kafka create-cluster --cluster-name orders ...  -> ConflictException (name still held)
(4s later) aws kafka describe-cluster ...           -> NotFoundException; list-clusters-v2 -> 0 clusters
errors: describe-cluster (unknown) -> NotFoundException (HTTP 404); --enhanced-monitoring BOGUS -> BadRequestException;
        describe-vpc-connection (unknown) -> NotFoundException

With plain serve -providers aws -aws-port 4607 (default mode):

  • create-cluster returns ACTIVE.
  • list-nodes with 4 brokers over 2 subnets gives a/b/a/b.
  • create-vpc-connection returns AVAILABLE.
  • About 2 seconds after create there are 12 AWS/Kafka metrics: 4 cluster-level plus 2 for each of the 4 brokers.
  • delete-cluster returns DELETING, and the next describe returns NotFoundException.

The server was stopped after each run, and nothing was left listening on 4607.

Terraform was not run.

Known limits / risks

  • Broker-to-subnet order is round-robin by broker ID (1→first subnet, 2→second, and so on). The docs only promise an even distribution, not this exact order.
  • CreateCluster does not yet check that the broker count is a multiple of the subnet count, or that there are 2–3 subnets. Existing tests and users create clusters that would break that rule, so it is left for a separate change.
  • The managed aws/kafka key ARN is a deterministic UUID for each account and region. It is not created in, or resolvable through, the cloudemu KMS mock.
  • The settle windows are short (3s, settle.DefaultClusterSettle), not the minutes real MSK takes. Removal after DELETING happens on the next serve tick (every second by default) or the next read or create. In library mode, which never ticks, only the read or create removes it.
  • Under --async-settle, rejecting updates and topic, SCRAM and policy writes on non-ACTIVE clusters is new behavior, but only under that flag.
  • Tick returning true once a minute for each active cluster marks state as changed for the persist flusher. EFS does the same.

Related Issues

Part of #1517

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What I checked

  • All six items in #1517 against the PR head (ea11d32): broker client subnets, CREATING, DELETING, VPC connection CREATING, AWS/Kafka metrics with SetMonitoring wiring, and the managed at-rest key. Each one is handled in the provider. The wire layer only lost its hard-coded key/msk-default block and now surfaces the stored encryptionInfo for v1 and v2.
  • Layering looks right. Lifecycle, metrics and encryption defaults live in providers/aws/kafka, the factory wires Kafka.SetMonitoring(CloudWatch), and Kafka ticks before CloudWatch in Tickables(). No driver change, and go generate . leaves no diff.
  • Gates on the touched packages: go build ./... ok, go vet ok, scoped go test ok (kafka provider, kafka wire, providers/aws, persist), -race ok, gofmt clean on the touched files, go mod tidy clean. I also ran a throwaway concurrent create/describe/list/delete/Tick test on one cluster name under async settle with -race. It was clean and left no clusters behind.
  • golangci-lint (v2.14.0) on ./providers/aws/kafka/... ./server/aws/kafka/... ./providers/aws/ without --new-from-rev, compared with the merge base: one new issue (see Medium), plus newProvider funlen going from 66 to 67 statements. That funlen hit was already failing on the base.
  • cloudemu serve -async-settle with AWS CLI v2:
    • create-cluster returns CREATING.
    • update-broker-count while CREATING returns BadRequestException, and so does an update while DELETING.
    • The cluster becomes ACTIVE after about 3s.
    • list-nodes gives subnet-0aaa/0bbb/0ccc.
    • The v1 and v2 describes report the same managed key ARN with TLS/true.
    • delete returns DELETING, and a second delete also returns DELETING.
    • Creating a cluster with the same name while DELETING returns ConflictException.
    • After the window, describe and list-nodes return NotFoundException.
    • create-vpc-connection shows CREATING, then AVAILABLE. An unknown ARN returns NotFoundException.
    • After about a minute, list-metrics shows the 4 cluster-level metrics and the 2 per-broker metrics. get-metric-statistics returns one datapoint per minute with SampleCount 1, Maximum 1 and Unit Count.
  • Default serve (no async): ACTIVE immediately, 4 brokers over 2 subnets gives a/b/a/b, delete then describe returns NotFound, the name can be reused right away, and serverless v2 create still works.
  • Terraform hashicorp/aws 6.66.0, two aws_msk_cluster resources (one with the default key, one with an explicit encryption_at_rest_kms_key_arn), against serve -async-settle:
    • apply took about 3s per cluster, and the create waiter observed CREATING then ACTIVE.
    • plan exited 0.
    • Updating the broker count from 3 to 6 and changing tags applied cleanly, and the next plan exited 0.
    • destroy finished, and describe then returned NotFoundException.
    • The state for the default cluster carries the managed key ARN with client_broker = "TLS" and in_cluster = true.
  • The PR body and commit message have no attribution lines.

High

None.

Medium

  1. (*Mock).Snapshot in providers/aws/kafka/snapshot.go:61 now has a cyclomatic complexity of 12, over the limit of 10 (gocyclo). The function was at the limit before this change, and the new vpcConnReadyAt branch (if !vd.readyAt.IsZero() plus the lazy map init) pushed it over. A --new-from-rev run hides this because gocyclo reports on the func line, which this diff does not touch. That is why the PR's "0 issues" claim does not hold for a full lint of the package. Moving the VPC connection loop into a small helper such as snapshotVPCConns(snap *kafkaSnapshot) fixes it and keeps the function readable.

Low

  1. withDefaultEncryption (providers/aws/kafka/encryption.go:58-85) fills in only the at-rest key when the caller sends a partial encryptionInfo. Real MSK also reports the in-transit defaults in that case:

    • --encryption-info '{"EncryptionAtRest":{"DataVolumeKMSKeyId":"...mine"}}' describes with no EncryptionInTransit at all. Real MSK reports ClientBroker=TLS, InCluster=true.
    • {"EncryptionInTransit":{"ClientBroker":"TLS_PLAINTEXT"}} describes without InCluster. Real MSK reports true.

    Terraform happens not to drift on this, but SDK clients that read EncryptionInTransit.InCluster get nil. Since this function is the place that "completes EncryptionInfo the way MSK does", filling in the missing in-transit fields there would close the gap.

  2. Tick(now) uses the scheduler's now for the sample cadence and timestamps, but dueMetrics decides ACTIVE through m.clusterStateLocked, which reads m.now() (providers/aws/kafka/metrics.go:99). Under serve both are the wall clock, but a caller that passes a now different from the options clock gets a mix of the two. Passing now through to the state check would make Tick consistent with its argument.

  3. requireActive guards only the mutateClusterErr family. Topic create/update/delete, SCRAM secret association and cluster policy writes still succeed on a CREATING or DELETING cluster. Real MSK rejects API operations until the cluster is ACTIVE. This only matters under --async-settle, so it could be a follow-up.

  4. GetCompatibleKafkaVersions (providers/aws/kafka/versions.go:95) reads m.clusters directly and skips reapIfGone, so a cluster whose DELETING window has passed still answers until some other read removes it. This is a small edge case.

  5. reapName has 60% coverage. Neither of its two branches is tested: the one where the name points at an ARN that is no longer in the store, and the one where the name is reaped on create without any earlier read. The kafka package overall is at 88.4%, under the 90% target.

Comment thread providers/aws/kafka/snapshot.go Outdated
snap.VPCConnReadyAt = map[string]time.Time{}
}

snap.VPCConnReadyAt[arn] = vd.readyAt

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This branch pushes (*Mock).Snapshot to a cyclomatic complexity of 12, over the gocyclo limit of 10. A full golangci-lint run ./providers/aws/kafka/... reports it on the func line, which is why --new-from-rev misses it. Could the VPC connection loop move into a small helper like snapshotVPCConns(snap *kafkaSnapshot)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c86fc4e. Snapshot now delegates to per-store helpers (clustersForSnapshot, configsForSnapshot, vpcConnsForSnapshot, replicatorsForSnapshot), and a full golangci-lint run ./providers/aws/kafka/... ./server/aws/kafka/... (without --new-from-rev) reports 0 issues. While doing this I found a data race that was already there: topics, operations, scramSecrets and the raw-option maps were shared, and json.Marshal read them after the lock was released. Each record is now deep-copied under its own lock. TestSnapshotConcurrentWithMutations reported a race under -race before the fix and is clean after it.

Comment thread providers/aws/kafka/encryption.go Outdated
_ = json.Unmarshal(k, &existing)
}

if existing != "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When the caller sends a partial block, only the at-rest key gets filled in. Real MSK also reports the in-transit defaults here: with EncryptionAtRest only, describe should still show EncryptionInTransit {ClientBroker: TLS, InCluster: true}, and with {ClientBroker: TLS_PLAINTEXT} only, InCluster should come back true. Right now the first case has no EncryptionInTransit at all and the second has no InCluster.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c86fc4e. withDefaultEncryption now fills each missing field on its own with its documented default: the managed at-rest key, clientBroker TLS and inCluster true (EncryptionInTransit). Explicit values, including inCluster: false, are kept. TestDefaultEncryptionInTransitCompleted covers at-rest only, ClientBroker only, InCluster=false only, and {}. I also checked it with the CLI: EncryptionAtRest alone now describes with TLS/true, and TLS_PLAINTEXT alone describes with InCluster: true.

Comment thread providers/aws/kafka/metrics.go Outdated
defer cd.mu.Unlock()

if cd.cluster.ClusterType != driver.ClusterTypeProvisioned ||
m.clusterStateLocked(cd) != driver.ClusterStateActive {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Small consistency point: the cadence and timestamps use Tick's now, but this ACTIVE check goes through clusterStateLocked, which reads m.now(). Passing now through would keep Tick consistent with its argument when the two clocks differ.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c86fc4e. clusterStateAt(cd, t) and goneAt(cd, t) now take the time explicitly. Tick passes its own now for both the ACTIVE check and the new delete reap, and request paths pass m.now(). Test: TestTickUsesItsNow.

@Satyam-Trivedi-ZS

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review, @NitinKumar004. I checked each finding against the code before changing anything. All of them are addressed in c86fc4e. New tests are in providers/aws/kafka/review_test.go, and each one failed before the fix.

Medium

1. Snapshot gocyclo 12: fixed. I confirmed it with a full golangci-lint run ./providers/aws/kafka/.... Snapshot is now split into per-store helpers (clustersForSnapshot, configsForSnapshot, vpcConnsForSnapshot, replicatorsForSnapshot). A full lint (no --new-from-rev) of ./providers/aws/kafka/... ./server/aws/kafka/... now reports 0 issues.

While I was in there, I found a real data race that was there before this PR. Snapshot copied the cluster struct under cd.mu, but it shared the topics map, the operations and scramSecrets slices, and the raw-option maps. It then ran json.Marshal after the lock was released, so a concurrent CreateTopic raced it. Every record is now deep-copied under its own lock (snapshotCluster, copyOperation, snapshotTopic, snapshotConfig, snapshotVpcConnection, snapshotReplicator). Test: TestSnapshotConcurrentWithMutations. It reported WARNING: DATA RACE under -race before the fix and is clean after it. It also round-trips topics and tags on a cluster, VPC connection, configuration and replicator.

Low

1. Partial encryptionInfo misses the in-transit defaults: fixed. The API reference documents the ClientBroker default as TLS and the InCluster default as true (EncryptionInTransit). withDefaultEncryption now fills each missing field on its own: the at-rest key, clientBroker and inCluster. Explicit values are kept, including InCluster=false. Old snapshots get the same completion on restore. Test: TestDefaultEncryptionInTransitCompleted covers at-rest only, ClientBroker only, InCluster=false only, and {}.

2. Tick mixing its now with the options clock: fixed. clusterStateAt(cd, t) and goneAt(cd, t) take the time explicitly. Tick passes its own now to them, and request paths pass m.now(). Test: TestTickUsesItsNow.

3. requireActive only on mutateClusterErr: fixed. CreateTopic, UpdateTopic, DeleteTopic, BatchAssociateScramSecret, BatchDisassociateScramSecret, PutClusterPolicy and DeleteClusterPolicy now return BadRequestException while the cluster is CREATING or DELETING. The cluster-states page says no MSK API operations are allowed in either state. Reads stay allowed. Default mode is unaffected, because a cluster there is always ACTIVE. Test: TestClusterWritesRequireActive.

4. GetCompatibleKafkaVersions skips the reap: fixed. compatibilitySources now resolves the cluster through getCluster, so a cluster past its DELETING window returns NotFoundException. Test: TestCompatibleVersionsAfterDeleteWindow.

5. reapName coverage: fixed. TestCreateReclaimsDanglingName covers the name-points-at-a-missing-ARN branch: it restores a snapshot whose name index has no cluster behind it. Together with the existing TestDeleteFreesNameForCreateWithoutRead, reapName is now at 100%.

Package coverage: the provider tests alone give 84.9%, and the provider plus wire tests (-coverpkg=./providers/aws/kafka) give 85.3%. Everything this PR adds (lifecycle, metrics, encryption and snapshot helpers) is at 89–100% (apart from mustJSON's unreachable marshal-error branch). The remaining gap is in update operations that existed before this PR (UpdateBrokerType, UpdateStorage, UpdateSecurity, RebootBroker, etc.). Only the wire tests exercise those. I'd treat raising them to 90% as a follow-up rather than growing this PR.

One more change related to this review

Removal after a delete used to happen only on the next read. Tick now also removes any cluster whose DELETING window has ended and returns true, so --persist saves the removal even when nothing reads the cluster. This is the same issue that came up on the EFS PR. Tests: TestTickReapsFinishedDelete, and TestTickSkipsDeletingAndServerless is updated.

Verification

  • go build ./... passes.
  • go test -race -count=1 ./providers/aws/kafka/... ./server/aws/kafka/... ./providers/aws/ ./persist/... passes.
  • go test -p 2 -count=1 over all 657 packages, run in chunks, passes.
  • golangci-lint run --new-from-rev=upstream/development ./providers/aws/kafka/... ./server/aws/kafka/... ./providers/aws/ reports 0 issues, and the full run on the kafka packages also reports 0 issues.
  • Real-user checks: AWS CLI against cloudemu serve -providers aws -aws-port 4607.
    • With -async-settle -persist:
      • With EncryptionAtRest only, describe shows EncryptionInTransit {ClientBroker: TLS, InCluster: true}.
      • With {ClientBroker: TLS_PLAINTEXT} only, describe-cluster-v2 shows InCluster: true.
      • create-topic, batch-associate-scram-secret and put-cluster-policy return BadRequestException while the cluster is CREATING and succeed once it is ACTIVE.
      • create-topic returns BadRequestException while the cluster is DELETING.
      • get-compatible-kafka-versions answers while the cluster is DELETING and returns NotFoundException after the window.
      • After the window, with no reads, the cluster is gone from the kafka section of the state file.
      • After a restart from that state file, the remaining cluster comes back with its topic, SCRAM secret and encryption info, and the deleted one returns NotFound.
      • GlobalTopicCount goes 0 → 1, one datapoint per minute.
    • In default mode:
      • InCluster=false is kept.
      • Topic, SCRAM and policy writes succeed right after create.
      • After delete, describe and get-compatible-kafka-versions return NotFound, and the name can be reused at once.

Follow-up (not in this PR)

  • Raise providers/aws/kafka package coverage to 90% with provider-level tests for the update operations that existed before this PR (these are currently covered only by the wire tests).

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What I checked

  • The new commit c86fc4e against each point from my earlier review, then the whole diff again from the merge base. The PR still merges cleanly into development and every CI check on c86fc4e is green.
  • Gates on the new head: go build ./... ok. go vet ok on ./providers/aws/kafka/... ./server/aws/kafka/... ./providers/aws/. Scoped go test and -race ok on those packages plus ./persist/.... gofmt is clean on the touched files, go mod tidy leaves no diff, and go generate . leaves no diff.
  • golangci-lint v2.14.0, full run (no --new-from-rev) on ./providers/aws/kafka/... ./server/aws/kafka/... ./providers/aws/, compared with a merge-base worktree: 53 issues on both sides, and the lists are the same apart from line numbers. The only changed entry is newProvider funlen going from 66 to 67 statements, which already fails on the base. The gocyclo hit on (*Mock).Snapshot is gone.
  • Coverage of providers/aws/kafka: 89.8% (merge base 86.8%, earlier head 88.4%). reapName is now at 100%.
  • cloudemu serve -async-settle with AWS CLI 2.31.19:
    • create-cluster returns CREATING. UpdateBrokerCount, PutClusterPolicy and BatchAssociateScramSecret while CREATING all return BadRequestException ("is in CREATING state; this operation requires ACTIVE"). GetCompatibleKafkaVersions is still allowed.
    • After about 3s the cluster is ACTIVE. The policy put and the SCRAM associate then succeed. list-nodes gives subnet-0aaa/0bbb/0ccc.
    • Partial encryptionInfo: at-rest key only describes with ClientBroker=TLS, InCluster=true. ClientBroker=TLS_PLAINTEXT only describes with InCluster=true and the managed key. InCluster=false only is kept as false, with ClientBroker=TLS. describe-cluster-v2 shows the same block.
    • delete-cluster returns DELETING. PutClusterPolicy and BatchDisassociateScramSecret while DELETING return BadRequestException. Creating a cluster with the same name returns ConflictException.
    • After the window, list-clusters-v2 no longer shows the cluster. GetCompatibleKafkaVersions and describe-cluster return NotFoundException, and the name can be reused.
  • Default serve (no async): create returns ACTIVE, writes work right away, delete then describe returns NotFoundException, and the name can be reused at once.
  • Terraform 1.14.3 with hashicorp/aws 6.70.0 (downloaded into a private directory), two aws_msk_cluster resources, one with no encryption_info and one with only encryption_in_transit { client_broker = "TLS_PLAINTEXT" }, against serve -async-settle:
    • apply: each cluster was created in about 3s.
    • plan -detailed-exitcode returned 0. The state for the partial cluster has the managed key ARN, TLS_PLAINTEXT and in_cluster = true.
    • I changed the broker count from 3 to 6 and changed a tag. apply worked and plan returned 0 again.
    • list-metrics showed 10 AWS/Kafka metrics for the cluster with no encryption block: 4 for the cluster and 2 for each of its 3 brokers.
    • destroy took about 3s per cluster, and describe then returned NotFoundException.
  • The PR body and commits have no attribution lines.

Status of the earlier points

  • Medium 1, gocyclo on (*Mock).Snapshot: fixed. Snapshot now calls clustersForSnapshot, configsForSnapshot, vpcConnsForSnapshot and replicatorsForSnapshot. A full lint run no longer reports it. Each record is also deep-copied under its own lock now. That fixes the existing race where json.Marshal read topic, operation, SCRAM and raw-option maps after the lock was released. TestSnapshotConcurrentWithMutations passes under -race.
  • Low 1, partial encryptionInfo: fixed. defaultField fills each missing field on its own, and an explicit inCluster: false is kept. I confirmed this with the CLI and Terraform as described above.
  • Low 2, Tick mixing clocks: fixed. Tick passes its now to both clusterStateAt and reapIfGoneAt. Request paths still use m.now(). TestTickUsesItsNow covers it.
  • Low 3, topic, SCRAM and policy writes not guarded: fixed. requireActive now runs under cd.mu in CreateTopic, UpdateTopic, DeleteTopic, BatchAssociateScramSecret, BatchDisassociateScramSecret, PutClusterPolicy and DeleteClusterPolicy. I checked the SCRAM and policy cases live.
  • Low 4, GetCompatibleKafkaVersions skipping the reap: fixed. compatibilitySources now goes through getCluster, so the reap runs and a cluster past its window returns NotFoundException. TestCompatibleVersionsAfterDeleteWindow covers it.
  • Low 5, coverage: mostly fixed. reapName is at 100% (TestCreateReclaimsDanglingName). The package is at 89.8%, just under the 90% target (see Low 1 below).

Tick now also removes clusters whose delete window has ended. The lock order is fine: reapIfGoneAt releases cd.mu before it takes reapMu, and Tick holds neither createMu nor any cluster lock at that point, so it follows createMu -> reapMu -> clusterData.mu.

High

None.

Medium

None.

Low

  1. The kafka provider package is at 89.8%, 0.2 points under the 90% target. The largest gap in the touched code is compatibilitySources in providers/aws/kafka/versions.go at 55.6%. Its no-cluster-ARN branch and the unknown-ARN getCluster error branch are not tested. One test that calls GetCompatibleKafkaVersions with an empty ARN and one with an unknown ARN would get the package over 90%.

cd, ok := m.clusters.Get(clusterARN)
if !ok {
cd, err := m.getCluster(clusterARN)
if err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Small coverage gap: compatibilitySources is at 55.6%, and this error branch (unknown ARN) and the empty-ARN branch above are not tested. Tests for those two cases would get the package from 89.8% to over 90%.

…oker subnets, managed KMS key

Close the open real-cloud parity gaps in stackshy#1517:

- ListNodes reports each broker's clientSubnet from the cluster's own
  BrokerNodeGroupInfo.ClientSubnets, spreading brokers evenly across them
  (round-robin by broker ID), instead of a fabricated subnet-<brokerId>.
  The field is omitted when the cluster records no subnets.
- Under AsyncSettle a new cluster reports CREATING (create response,
  Describe, List) and settles to ACTIVE; a deleted cluster reports DELETING
  and is gone (NotFoundException) once its window ends, keeping its name
  until then. Update operations on a non-ACTIVE cluster are rejected with
  BadRequestException. A new VPC connection reports CREATING, then
  AVAILABLE. The default (AsyncSettle off) stays synchronous. The windows
  are per-record deadlines persisted in snapshots.
- Publish DEFAULT-level AWS/Kafka metrics from the shared serve scheduler
  (Kafka is now a config.Tickable, ticked before CloudWatch), once a minute
  per ACTIVE provisioned cluster: ActiveControllerCount, GlobalTopicCount,
  GlobalPartitionCount and OfflinePartitionsCount on "Cluster Name", and
  UnderReplicatedPartitions and UnderMinIsrPartitionCount on
  "Cluster Name" + "Broker ID". Host and traffic metrics cloudemu does not
  model are not published. Kafka.SetMonitoring is wired to CloudWatch.
- The default encryption-at-rest key is the account+region's AWS managed
  aws/kafka key ARN (one stable UUID key id per account and region),
  completed by the provider at create time and reported by both v1 and v2
  describes, instead of a hard-coded key/msk-default in us-east-1.

Part of stackshy#1517
…ncryption defaults

- Snapshot deep-copies each cluster, configuration, VPC connection and
  replicator under its own lock (topics, operations, SCRAM secrets, tags,
  raw options), so encoding no longer races a concurrent CreateTopic. The
  per-store loops move into helpers, which also brings Snapshot back under
  the gocyclo limit.
- withDefaultEncryption fills every EncryptionInfo field the request leaves
  out with its documented default (managed at-rest key, ClientBroker TLS,
  InCluster true) and keeps explicit values, including InCluster false.
- Tick judges cluster state at its own now argument instead of the options
  clock, and removes clusters whose DELETING window has ended, reporting the
  change so persist saves it even when nothing reads the cluster.
- Topic create/update/delete, SCRAM secret associate/disassociate and
  cluster policy put/delete are rejected with BadRequestException while a
  cluster is CREATING or DELETING (only reachable under --async-settle).
- GetCompatibleKafkaVersions resolves the cluster through getCluster, so a
  cluster past its DELETING window is NotFound.

Part of stackshy#1517
@Satyam-Trivedi-ZS
Satyam-Trivedi-ZS force-pushed the fix/aws-kafka-parity-1517 branch from c86fc4e to d902f07 Compare October 23, 2026 20:34
@Satyam-Trivedi-ZS
Satyam-Trivedi-ZS merged commit 787a0f6 into stackshy:development Oct 23, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants