Skip to content

Commit 207df46

Browse files
Merge pull request #25 from Redislabs-Solution-Architects/GTI-686/fix-cluster-zone-region-grouping
GTI-686: normalize Redis Cluster zone values in Region
2 parents a2d0b33 + 76dec02 commit 207df46

2 files changed

Lines changed: 105 additions & 3 deletions

File tree

memorystore.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
import argparse
2525
import csv
2626
import os
27+
import re
2728
import sys
2829
import time
29-
from typing import Dict, List, Optional, Any
30+
from typing import Dict, List, Optional, Any, Tuple
3031
from collections import defaultdict
3132

3233
from google.oauth2 import service_account
@@ -74,6 +75,28 @@
7475
"instance_type",
7576
)
7677

78+
_GCP_ZONE_RE = re.compile(r"^([a-z]+(?:-[a-z]+)+\d+)-[a-z]$")
79+
80+
81+
def _normalize_location(value: Optional[str]) -> Tuple[str, str]:
82+
"""Split a GCP location label into its (region, zone) pair.
83+
84+
Encodes GCP's location hierarchy: a zone is always
85+
``<region>-<letter>`` (e.g. ``us-central1-a`` -> region
86+
``us-central1``). When the input matches that shape the parent
87+
region is returned as the first element and the original value as
88+
the zone. Region-shaped inputs (``us-east4``) and any non-matching
89+
string (``""``, ``"global"``, multi-region aliases like ``"us"``)
90+
are passed through as the region with an empty zone, so callers
91+
never silently lose information.
92+
"""
93+
if not value:
94+
return ("", "")
95+
match = _GCP_ZONE_RE.match(value)
96+
if match:
97+
return (match.group(1), value)
98+
return (value, "")
99+
77100

78101
def _pick(labels: Dict[str, str], keys) -> Optional[str]:
79102
for k in keys:
@@ -192,8 +215,9 @@ def _accumulate_commands(results, table, product_name: str, project_id: str):
192215
entry["InstanceId"] = (
193216
rlabels.get("instance_id") or rlabels.get("cluster_id") or ""
194217
)
195-
entry["Region"] = _pick(rlabels, REGION_LABELS) or entry["Region"]
196-
entry["Zone"] = _pick(rlabels, ZONE_LABELS) or entry["Zone"]
218+
region, zone_from_loc = _normalize_location(_pick(rlabels, REGION_LABELS))
219+
entry["Region"] = region or entry["Region"]
220+
entry["Zone"] = _pick(rlabels, ZONE_LABELS) or zone_from_loc or entry["Zone"]
197221
entry["NodeType"] = _pick(rlabels, NODETYPE_LABELS) or entry["NodeType"]
198222

199223
# Node role if provided (e.g., 'primary'/'replica')

test_msstats.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
_resolve_inst_key,
2121
_attach_memory_usage,
2222
_attach_capacity_scalar,
23+
_accumulate_commands,
24+
_normalize_location,
2325
)
2426

2527

@@ -556,6 +558,82 @@ def test_attach_capacity_scalar_uses_resolve_inst_key(self):
556558
table["my-project/memorystore-valkey"]["abc123"]["MaxMemory"], 10000000
557559
)
558560

561+
def test_normalize_location_zone_form(self):
562+
"""GCP zone string is split into (region, zone)"""
563+
self.assertEqual(
564+
_normalize_location("us-central1-a"),
565+
("us-central1", "us-central1-a"),
566+
)
567+
568+
def test_normalize_location_region_form(self):
569+
"""Region-shaped string is passed through as region, zone blank"""
570+
self.assertEqual(_normalize_location("us-east4"), ("us-east4", ""))
571+
572+
def test_normalize_location_multi_segment_zone(self):
573+
"""Multi-word region prefix (e.g. asia-northeast1) is handled"""
574+
self.assertEqual(
575+
_normalize_location("asia-northeast1-c"),
576+
("asia-northeast1", "asia-northeast1-c"),
577+
)
578+
579+
def test_normalize_location_empty_and_none(self):
580+
"""Empty string and None return ('', '')"""
581+
self.assertEqual(_normalize_location(""), ("", ""))
582+
self.assertEqual(_normalize_location(None), ("", ""))
583+
584+
def _make_cmd_ts(self, resource_labels, cmd="GET"):
585+
mock_ts = MagicMock()
586+
mock_ts.resource.labels = resource_labels
587+
mock_ts.metric.labels = {"cmd": cmd}
588+
mock_point = MagicMock()
589+
mock_point.interval.start_time.timestamp.return_value = 1000.0
590+
mock_point.value.int64_value = 1
591+
mock_point.value.double_value = 0
592+
mock_ts.points = [mock_point]
593+
return mock_ts
594+
595+
def test_accumulate_commands_cluster_location_splits_into_region_and_zone(self):
596+
"""Redis Cluster: location=<zone> must split into Region + Zone"""
597+
table = {}
598+
ts = self._make_cmd_ts(
599+
{"cluster_id": "c1", "shard_id": "s0", "location": "us-central1-a"}
600+
)
601+
_accumulate_commands([ts], table, "Redis Cluster", "proj")
602+
entry = table["proj/c1"]["s0"]
603+
self.assertEqual(entry["Region"], "us-central1")
604+
self.assertEqual(entry["Zone"], "us-central1-a")
605+
606+
def test_accumulate_commands_standalone_keeps_region_and_explicit_zone(self):
607+
"""Redis standalone: explicit region + zone labels survive untouched"""
608+
table = {}
609+
ts = self._make_cmd_ts(
610+
{
611+
"instance_id": "projects/proj/locations/us-central1/instances/r1",
612+
"node_id": "n0",
613+
"region": "us-central1",
614+
"zone": "us-central1-a",
615+
}
616+
)
617+
_accumulate_commands([ts], table, "Redis", "proj")
618+
entry = table["projects/proj/locations/us-central1/instances/r1"]["n0"]
619+
self.assertEqual(entry["Region"], "us-central1")
620+
self.assertEqual(entry["Zone"], "us-central1-a")
621+
622+
def test_accumulate_commands_standalone_region_only_leaves_zone_blank(self):
623+
"""Redis standalone: region-only label leaves Zone blank (regex no match)"""
624+
table = {}
625+
ts = self._make_cmd_ts(
626+
{
627+
"instance_id": "projects/proj/locations/us-east4/instances/r1",
628+
"node_id": "n0",
629+
"region": "us-east4",
630+
}
631+
)
632+
_accumulate_commands([ts], table, "Redis", "proj")
633+
entry = table["projects/proj/locations/us-east4/instances/r1"]["n0"]
634+
self.assertEqual(entry["Region"], "us-east4")
635+
self.assertEqual(entry["Zone"], "")
636+
559637

560638
if __name__ == "__main__":
561639
unittest.main()

0 commit comments

Comments
 (0)