gcpdiag.queries.gke

Queries related to GCP Kubernetes Engine clusters.
IPv4NetOrIPv6Net = typing.Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
DEFAULT_MAX_PODS_PER_NODE = 110
class NodeConfig:
39class NodeConfig:
40  """Represents a GKE node pool configuration."""
41
42  def __init__(self, resource_data):
43    self._resource_data = resource_data
44
45  def has_accelerators(self) -> bool:
46    if 'accelerators' in self._resource_data:
47      return True
48    return False
49
50  @property
51  def machine_type(self) -> str:
52    return self._resource_data['machineType']
53
54  @property
55  def image_type(self) -> str:
56    return self._resource_data['imageType']
57
58  @property
59  def oauth_scopes(self) -> list:
60    return self._resource_data['oauthScopes']
61
62  @property
63  def has_serial_port_logging_enabled(self) -> bool:
64    """Check if serial port logging is enabled in the node config.
65
66    Returns:
67      bool: True if serial port logging is enabled or not explicitly disabled.
68            False if explicitly disabled.
69    """
70    metadata = self._resource_data.get('metadata', {})
71    return metadata.get('serial-port-logging-enable', 'true').lower() == 'true'

Represents a GKE node pool configuration.

NodeConfig(resource_data)
42  def __init__(self, resource_data):
43    self._resource_data = resource_data
def has_accelerators(self) -> bool:
45  def has_accelerators(self) -> bool:
46    if 'accelerators' in self._resource_data:
47      return True
48    return False
machine_type: str
50  @property
51  def machine_type(self) -> str:
52    return self._resource_data['machineType']
image_type: str
54  @property
55  def image_type(self) -> str:
56    return self._resource_data['imageType']
oauth_scopes: list
58  @property
59  def oauth_scopes(self) -> list:
60    return self._resource_data['oauthScopes']
has_serial_port_logging_enabled: bool
62  @property
63  def has_serial_port_logging_enabled(self) -> bool:
64    """Check if serial port logging is enabled in the node config.
65
66    Returns:
67      bool: True if serial port logging is enabled or not explicitly disabled.
68            False if explicitly disabled.
69    """
70    metadata = self._resource_data.get('metadata', {})
71    return metadata.get('serial-port-logging-enable', 'true').lower() == 'true'

Check if serial port logging is enabled in the node config.

Returns:

bool: True if serial port logging is enabled or not explicitly disabled. False if explicitly disabled.

class NodePool(gcpdiag.models.Resource):
 74class NodePool(models.Resource):
 75  """Represents a GKE node pool."""
 76
 77  version: Version
 78
 79  def __init__(self, cluster, resource_data):
 80    super().__init__(project_id=cluster.project_id)
 81    self._cluster = cluster
 82    self._resource_data = resource_data
 83    self.version = Version(self._resource_data['version'])
 84    self._migs = None
 85
 86  def _get_service_account(self) -> str:
 87    return self._resource_data.get('config', {}).get('serviceAccount', None)
 88
 89  @property
 90  def full_path(self) -> str:
 91    # https://container.googleapis.com/v1/projects/gcpdiag-gke1-aaaa/
 92    #   locations/europe-west1/clusters/gke2/nodePools/default-pool
 93    m = re.match(
 94      r'https://container.googleapis.com/v1/(.*)',
 95      self._resource_data.get('selfLink', ''),
 96    )
 97    if not m:
 98      raise RuntimeError("can't parse selfLink of nodepool resource")
 99    return m.group(1)
100
101  @property
102  def short_path(self) -> str:
103    path = self.full_path
104    path = re.sub(r'^projects/', '', path)
105    path = re.sub(r'/locations/', '/', path)
106    path = re.sub(r'/zones/', '/', path)
107    path = re.sub(r'/clusters/', '/', path)
108    path = re.sub(r'/nodePools/', '/', path)
109    return path
110
111  @property
112  def name(self) -> str:
113    return self._resource_data['name']
114
115  @property
116  def config(self) -> NodeConfig:
117    return NodeConfig(self._resource_data['config'])
118
119  @property
120  def node_count(self) -> int:
121    return self._resource_data.get('initialNodeCount', 0)
122
123  def has_default_service_account(self) -> bool:
124    sa = self._get_service_account()
125    return sa == 'default'
126
127  def has_image_streaming_enabled(self) -> bool:
128    return get_path(self._resource_data, ('config', 'gcfsConfig', 'enabled'), default=False)
129
130  def has_md_concealment_enabled(self) -> bool:
131    # Empty ({}) workloadMetadataConfig means that 'Metadata concealment'
132    # (predecessor of Workload Identity) is enabled.
133    # https://cloud.google.com/kubernetes-engine/docs/how-to/protecting-cluster-metadata#concealment
134    return (
135      get_path(
136        self._resource_data,
137        ('config', 'workloadMetadataConfig'),
138        default=None,
139      )
140      == {}
141    )
142
143  def has_workload_identity_enabled(self) -> bool:
144    # 'Metadata concealment' (workloadMetadataConfig == {}) doesn't protect the
145    # default SA's token
146    return bool(
147      get_path(
148        self._resource_data,
149        ('config', 'workloadMetadataConfig'),
150        default=None,
151      )
152    )
153
154  @property
155  def service_account(self) -> str:
156    sa = self._get_service_account()
157    if sa == 'default':
158      project_nr = crm.get_project(self.project_id).number
159      return f'{project_nr}-compute@developer.gserviceaccount.com'
160    else:
161      return sa
162
163  @property
164  def pod_ipv4_cidr_size(self) -> int:
165    return self._resource_data['podIpv4CidrSize']
166
167  @property
168  def pod_ipv4_cidr_block(self) -> Optional[IPv4NetOrIPv6Net]:
169    # Get the pod cidr range in use by the nodepool
170    pod_cidr = get_path(self._resource_data, ('networkConfig', 'podIpv4CidrBlock'), default=None)
171
172    if pod_cidr:
173      return ipaddress.ip_network(pod_cidr)
174    else:
175      return None
176
177  @property
178  def max_pod_per_node(self) -> int:
179    return int(
180      get_path(
181        self._resource_data,
182        ('maxPodsConstraint', 'maxPodsPerNode'),
183        default=DEFAULT_MAX_PODS_PER_NODE,
184      )
185    )
186
187  @property
188  def cluster(self) -> 'Cluster':
189    return self._cluster
190
191  @property
192  def instance_groups(self) -> List[gce.ManagedInstanceGroup]:
193    if self._migs is None:
194      project_migs_by_selflink = {}
195      for m in gce.get_managed_instance_groups(models.Context(project_id=self.project_id)).values():
196        project_migs_by_selflink[m.self_link] = m
197
198      self._migs = []
199      for url in self._resource_data.get('instanceGroupUrls', []):
200        try:
201          self._migs.append(project_migs_by_selflink[url])
202        except KeyError:
203          continue
204    return self._migs
205
206  @property
207  def node_tags(self) -> List[str]:
208    """Returns the firewall tags used for nodes in this cluster.
209
210    If the node tags can't be determined, [] is returned.
211    """
212    migs = self.instance_groups
213    if not migs:
214      return []
215    return migs[0].template.tags
216
217  def get_machine_type(self) -> str:
218    """Returns the machine type of the nodepool nodes"""
219    return self.config.machine_type

Represents a GKE node pool.

NodePool(cluster, resource_data)
79  def __init__(self, cluster, resource_data):
80    super().__init__(project_id=cluster.project_id)
81    self._cluster = cluster
82    self._resource_data = resource_data
83    self.version = Version(self._resource_data['version'])
84    self._migs = None
version: gcpdiag.utils.Version
full_path: str
89  @property
90  def full_path(self) -> str:
91    # https://container.googleapis.com/v1/projects/gcpdiag-gke1-aaaa/
92    #   locations/europe-west1/clusters/gke2/nodePools/default-pool
93    m = re.match(
94      r'https://container.googleapis.com/v1/(.*)',
95      self._resource_data.get('selfLink', ''),
96    )
97    if not m:
98      raise RuntimeError("can't parse selfLink of nodepool resource")
99    return m.group(1)

Returns the full path of this resource.

Example: 'projects/gcpdiag-gke-1-9b90/zones/europe-west4-a/clusters/gke1'

short_path: str
101  @property
102  def short_path(self) -> str:
103    path = self.full_path
104    path = re.sub(r'^projects/', '', path)
105    path = re.sub(r'/locations/', '/', path)
106    path = re.sub(r'/zones/', '/', path)
107    path = re.sub(r'/clusters/', '/', path)
108    path = re.sub(r'/nodePools/', '/', path)
109    return path

Returns the short name for this resource.

Note that it isn't clear from this name what kind of resource it is.

Example: 'gke1'

name: str
111  @property
112  def name(self) -> str:
113    return self._resource_data['name']
config: NodeConfig
115  @property
116  def config(self) -> NodeConfig:
117    return NodeConfig(self._resource_data['config'])
node_count: int
119  @property
120  def node_count(self) -> int:
121    return self._resource_data.get('initialNodeCount', 0)
def has_default_service_account(self) -> bool:
123  def has_default_service_account(self) -> bool:
124    sa = self._get_service_account()
125    return sa == 'default'
def has_image_streaming_enabled(self) -> bool:
127  def has_image_streaming_enabled(self) -> bool:
128    return get_path(self._resource_data, ('config', 'gcfsConfig', 'enabled'), default=False)
def has_md_concealment_enabled(self) -> bool:
130  def has_md_concealment_enabled(self) -> bool:
131    # Empty ({}) workloadMetadataConfig means that 'Metadata concealment'
132    # (predecessor of Workload Identity) is enabled.
133    # https://cloud.google.com/kubernetes-engine/docs/how-to/protecting-cluster-metadata#concealment
134    return (
135      get_path(
136        self._resource_data,
137        ('config', 'workloadMetadataConfig'),
138        default=None,
139      )
140      == {}
141    )
def has_workload_identity_enabled(self) -> bool:
143  def has_workload_identity_enabled(self) -> bool:
144    # 'Metadata concealment' (workloadMetadataConfig == {}) doesn't protect the
145    # default SA's token
146    return bool(
147      get_path(
148        self._resource_data,
149        ('config', 'workloadMetadataConfig'),
150        default=None,
151      )
152    )
service_account: str
154  @property
155  def service_account(self) -> str:
156    sa = self._get_service_account()
157    if sa == 'default':
158      project_nr = crm.get_project(self.project_id).number
159      return f'{project_nr}-compute@developer.gserviceaccount.com'
160    else:
161      return sa
pod_ipv4_cidr_size: int
163  @property
164  def pod_ipv4_cidr_size(self) -> int:
165    return self._resource_data['podIpv4CidrSize']
pod_ipv4_cidr_block: Union[ipaddress.IPv4Network, ipaddress.IPv6Network, NoneType]
167  @property
168  def pod_ipv4_cidr_block(self) -> Optional[IPv4NetOrIPv6Net]:
169    # Get the pod cidr range in use by the nodepool
170    pod_cidr = get_path(self._resource_data, ('networkConfig', 'podIpv4CidrBlock'), default=None)
171
172    if pod_cidr:
173      return ipaddress.ip_network(pod_cidr)
174    else:
175      return None
max_pod_per_node: int
177  @property
178  def max_pod_per_node(self) -> int:
179    return int(
180      get_path(
181        self._resource_data,
182        ('maxPodsConstraint', 'maxPodsPerNode'),
183        default=DEFAULT_MAX_PODS_PER_NODE,
184      )
185    )
cluster: Cluster
187  @property
188  def cluster(self) -> 'Cluster':
189    return self._cluster
instance_groups: List[gcpdiag.queries.gce.ManagedInstanceGroup]
191  @property
192  def instance_groups(self) -> List[gce.ManagedInstanceGroup]:
193    if self._migs is None:
194      project_migs_by_selflink = {}
195      for m in gce.get_managed_instance_groups(models.Context(project_id=self.project_id)).values():
196        project_migs_by_selflink[m.self_link] = m
197
198      self._migs = []
199      for url in self._resource_data.get('instanceGroupUrls', []):
200        try:
201          self._migs.append(project_migs_by_selflink[url])
202        except KeyError:
203          continue
204    return self._migs
node_tags: List[str]
206  @property
207  def node_tags(self) -> List[str]:
208    """Returns the firewall tags used for nodes in this cluster.
209
210    If the node tags can't be determined, [] is returned.
211    """
212    migs = self.instance_groups
213    if not migs:
214      return []
215    return migs[0].template.tags

Returns the firewall tags used for nodes in this cluster.

If the node tags can't be determined, [] is returned.

def get_machine_type(self) -> str:
217  def get_machine_type(self) -> str:
218    """Returns the machine type of the nodepool nodes"""
219    return self.config.machine_type

Returns the machine type of the nodepool nodes

class UndefinedClusterPropertyError(builtins.Exception):
222class UndefinedClusterPropertyError(Exception):
223  """Thrown when a property of a cluster can't be determined for some reason.
224
225  For example, the cluster_hash can't be determined because there are no
226  nodepools defined.
227  """
228
229  pass

Thrown when a property of a cluster can't be determined for some reason.

For example, the cluster_hash can't be determined because there are no nodepools defined.

class Cluster(gcpdiag.models.Resource):
232class Cluster(models.Resource):
233  """Represents a GKE cluster.
234
235  https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.locations.clusters#Cluster
236  """
237
238  _resource_data: dict
239  master_version: Version
240  _context: models.Context
241  _nodepools: Optional[List[NodePool]]
242
243  def __init__(self, project_id, resource_data, context: models.Context):
244    super().__init__(project_id=project_id)
245    self._resource_data = resource_data
246    self.master_version = Version(self._resource_data['currentMasterVersion'])
247    self._nodepools = None
248    self._context = context
249
250  @property
251  def full_path(self) -> str:
252    if utils.is_region(self._resource_data['location']):
253      return f'projects/{self.project_id}/locations/{self.location}/clusters/{self.name}'
254    else:
255      return f'projects/{self.project_id}/zones/{self.location}/clusters/{self.name}'
256
257  @property
258  def short_path(self) -> str:
259    path = self.full_path
260    path = re.sub(r'^projects/', '', path)
261    path = re.sub(r'/locations/', '/', path)
262    path = re.sub(r'/zones/', '/', path)
263    path = re.sub(r'/clusters/', '/', path)
264    return path
265
266  @property
267  def name(self) -> str:
268    return self._resource_data['name']
269
270  @property
271  def location(self) -> str:
272    return self._resource_data['location']
273
274  @property
275  def pod_ipv4_cidr(self) -> IPv4NetOrIPv6Net:
276    cidr = self._resource_data['clusterIpv4Cidr']
277    return ipaddress.ip_network(cidr)
278
279  @property
280  def current_node_count(self) -> int:
281    return self._resource_data.get('currentNodeCount', 0)
282
283  @property
284  def release_channel(self) -> Optional[str]:
285    try:
286      return self._resource_data['releaseChannel']['channel']
287    except KeyError:
288      return None
289
290  @property
291  def nap_node_image_type(self) -> Optional[str]:
292    return get_path(
293      self._resource_data,
294      ('autoscaling', 'autoprovisioningNodePoolDefaults', 'imageType'),
295      default=None,
296    )
297
298  @property
299  def app_layer_sec_key(self) -> str:
300    return self._resource_data['databaseEncryption'].get('keyName')
301
302  @property
303  def status(self) -> str:
304    return self._resource_data['status']
305
306  @property
307  def status_message(self) -> str:
308    return self._resource_data.get('statusMessage', None)
309
310  def has_app_layer_enc_enabled(self) -> bool:
311    # state := 'DECRYPTED' | 'ENCRYPTED', keyName := 'full_path_to_key_resouce'
312    return (
313      get_path(self._resource_data, ('databaseEncryption', 'state'), default=None) == 'ENCRYPTED'
314    )
315
316  def has_logging_enabled(self) -> bool:
317    return self._resource_data['loggingService'] != 'none'
318
319  def enabled_logging_components(self) -> List[str]:
320    return self._resource_data['loggingConfig']['componentConfig']['enableComponents']
321
322  def has_monitoring_enabled(self) -> bool:
323    return self._resource_data['monitoringService'] != 'none'
324
325  def enabled_monitoring_components(self) -> List[str]:
326    return self._resource_data['monitoringConfig']['componentConfig']['enableComponents']
327
328  def has_control_plane_logging_enabled(self) -> bool:
329    if not self.has_logging_enabled():
330      return False
331    components = get_path(
332      self._resource_data, ('loggingConfig', 'componentConfig', 'enableComponents'), default=[]
333    )
334    return all(c in components for c in ['API_SERVER', 'SCHEDULER', 'CONTROLLER_MANAGER'])
335
336  def has_control_plane_monitoring_enabled(self) -> bool:
337    if not self.has_monitoring_enabled():
338      return False
339    components = get_path(
340      self._resource_data, ('monitoringConfig', 'componentConfig', 'enableComponents'), default=[]
341    )
342    return all(c in components for c in ['API_SERVER', 'SCHEDULER', 'CONTROLLER_MANAGER'])
343
344  def has_authenticator_group_enabled(self) -> bool:
345    return len(self._resource_data.get('authenticatorGroupsConfig', {})) > 0
346
347  def has_workload_identity_enabled(self) -> bool:
348    return len(self._resource_data.get('workloadIdentityConfig', {})) > 0
349
350  def has_http_load_balancing_enabled(self) -> bool:
351    # HTTP load balancing needs to be enabled to use GKE ingress
352    return (
353      get_path(
354        self._resource_data,
355        ('addonsConfig', 'httpLoadBalancing', 'disabled'),
356        default=None,
357      )
358      is not True
359    )
360
361  def has_network_policy_enabled(self) -> bool:
362    # Network policy enforcement
363    return (
364      get_path(
365        self._resource_data,
366        ('addonsConfig', 'networkPolicyConfig', 'disabled'),
367        default=False,
368      )
369      is not True
370    )
371
372  def has_dpv2_enabled(self) -> bool:
373    # Checks whether dataplane V2 is enabled in clusters
374    return (
375      get_path(
376        self._resource_data,
377        ('networkConfig', 'datapathProvider'),
378        default=None,
379      )
380      == 'ADVANCED_DATAPATH'
381    )
382
383  def has_intra_node_visibility_enabled(self) -> bool:
384    if (
385      'networkConfig' in self._resource_data
386      and 'enableIntraNodeVisibility' in self._resource_data['networkConfig']
387    ):
388      return self._resource_data['networkConfig']['enableIntraNodeVisibility']
389    return False
390
391  def has_maintenance_window(self) -> bool:
392    # 'e3b0c442' is a hexadecimal string that represents the value of an empty
393    # string ('') in cryptography. If the maintenance windows are defined, the
394    # value of 'resourceVersion' is not empty ('e3b0c442').
395    return self._resource_data['maintenancePolicy']['resourceVersion'] != 'e3b0c442'
396
397  @property
398  def maintenance_policy(self) -> dict:
399    """Returns the maintenance policy of the cluster."""
400    return self._resource_data.get('maintenancePolicy', {})
401
402  def has_image_streaming_enabled(self) -> bool:
403    """Check if cluster has Image Streaming (aka  Google Container File System)
404
405    enabled
406    """
407    global_gcsfs = get_path(
408      self._resource_data,
409      ('nodePoolDefaults', 'nodeConfigDefaults', 'gcfsConfig', 'enabled'),
410      default=False,
411    )
412    # Check nodePoolDefaults settings
413    if global_gcsfs:
414      return True
415    for np in self.nodepools:
416      # Check if any nodepool has image streaming enabled
417      if np.has_image_streaming_enabled():
418        return True
419    return False
420
421  @property
422  def nodepools(self) -> List[NodePool]:
423    if self._nodepools is None:
424      self._nodepools = []
425      for n in self._resource_data.get('nodePools', []):
426        self._nodepools.append(NodePool(self, n))
427    return self._nodepools
428
429  @property
430  def network(self) -> network.Network:
431    # projects/gcpdiag-gke1-aaaa/global/networks/default
432    network_string = self._resource_data['networkConfig']['network']
433    m = re.match(r'projects/([^/]+)/global/networks/([^/]+)$', network_string)
434    if not m:
435      raise RuntimeError("can't parse network string: %s" % network_string)
436    return network.get_network(m.group(1), m.group(2), self._context)
437
438  @property
439  def subnetwork(self) -> Optional[models.Resource]:
440    # 'projects/gcpdiag-gke1-aaaa/regions/europe-west4/subnetworks/default'
441    if 'subnetwork' not in self._resource_data['networkConfig']:
442      return None
443
444    subnetwork_string = self._resource_data['networkConfig']['subnetwork']
445    m = re.match(
446      r'projects/([^/]+)/regions/([^/]+)/subnetworks/([^/]+)$',
447      subnetwork_string,
448    )
449    if not m:
450      raise RuntimeError("can't parse network string: %s" % subnetwork_string)
451    return network.get_subnetwork(m.group(1), m.group(2), m.group(3))
452
453  @property
454  def get_subnet_name(self) -> Optional[models.Resource]:
455    if 'subnetwork' not in self._resource_data:
456      return None
457    return self._resource_data['subnetwork']
458
459  @property
460  def get_nodepool_config(self) -> Optional[models.Resource]:
461    if 'nodePools' not in self._resource_data:
462      return None
463    return self._resource_data['nodePools']
464
465  @property
466  def get_network_string(self) -> str:
467    if 'networkConfig' not in self._resource_data:
468      return ''
469    if 'network' not in self._resource_data['networkConfig']:
470      return ''
471    return self._resource_data['networkConfig']['network']
472
473  @property
474  def is_private(self) -> bool:
475    if 'privateClusterConfig' not in self._resource_data:
476      return False
477
478    return self._resource_data['privateClusterConfig'].get('enablePrivateNodes', False)
479
480  @property
481  def is_vpc_native(self) -> bool:
482    return get_path(
483      self._resource_data,
484      ('ipAllocationPolicy', 'useIpAliases'),
485      default=False,
486    )
487
488  @property
489  def is_regional(self) -> bool:
490    return len(self._resource_data['locations']) > 1
491
492  @property
493  def cluster_ca_certificate(self) -> str:
494    return self._resource_data['masterAuth']['clusterCaCertificate']
495
496  @property
497  def endpoint(self) -> Optional[str]:
498    if 'endpoint' not in self._resource_data:
499      return None
500    return self._resource_data['endpoint']
501
502  @property
503  def is_autopilot(self) -> bool:
504    if 'autopilot' not in self._resource_data:
505      return False
506    return self._resource_data['autopilot'].get('enabled', False)
507
508  @property
509  def masters_cidr_list(self) -> Iterable[IPv4NetOrIPv6Net]:
510    if get_path(
511      self._resource_data,
512      ('privateClusterConfig', 'masterIpv4CidrBlock'),
513      default=None,
514    ):
515      return [
516        ipaddress.ip_network(self._resource_data['privateClusterConfig']['masterIpv4CidrBlock'])
517      ]
518    else:
519      # only older clusters still have ssh firewall rules
520      if self.current_node_count and not self.cluster_hash:
521        logging.warning("couldn't retrieve cluster hash for cluster %s.", self.name)
522        return []
523      fw_rule_name = f'gke-{self.name}-{self.cluster_hash}-ssh'
524      rule = self.network.firewall.get_vpc_ingress_rules(name=fw_rule_name)
525      if rule and rule[0].is_enabled():
526        return rule[0].source_ranges
527      return []
528
529  @property
530  def cluster_hash(self) -> Optional[str]:
531    """Returns the "cluster hash" as used in automatic firewall rules for GKE clusters.
532
533    Cluster hash is the first 8 characters of cluster id. See also:
534    https://cloud.google.com/kubernetes-engine/docs/concepts/firewall-rules
535    """
536    if 'id' in self._resource_data:
537      return self._resource_data['id'][:8]
538    raise UndefinedClusterPropertyError('no id')
539
540  @property
541  def is_nodelocal_dnscache_enabled(self) -> bool:
542    """Returns True if NodeLocal DNSCache is enabled for the cluster."""
543    addons_config = self._resource_data.get('addonsConfig', {})
544    dns_cache_config = addons_config.get('dnsCacheConfig', {})
545    return dns_cache_config.get('enabled', False)
Cluster(project_id, resource_data, context: gcpdiag.models.Context)
243  def __init__(self, project_id, resource_data, context: models.Context):
244    super().__init__(project_id=project_id)
245    self._resource_data = resource_data
246    self.master_version = Version(self._resource_data['currentMasterVersion'])
247    self._nodepools = None
248    self._context = context
master_version: gcpdiag.utils.Version
full_path: str
250  @property
251  def full_path(self) -> str:
252    if utils.is_region(self._resource_data['location']):
253      return f'projects/{self.project_id}/locations/{self.location}/clusters/{self.name}'
254    else:
255      return f'projects/{self.project_id}/zones/{self.location}/clusters/{self.name}'

Returns the full path of this resource.

Example: 'projects/gcpdiag-gke-1-9b90/zones/europe-west4-a/clusters/gke1'

short_path: str
257  @property
258  def short_path(self) -> str:
259    path = self.full_path
260    path = re.sub(r'^projects/', '', path)
261    path = re.sub(r'/locations/', '/', path)
262    path = re.sub(r'/zones/', '/', path)
263    path = re.sub(r'/clusters/', '/', path)
264    return path

Returns the short name for this resource.

Note that it isn't clear from this name what kind of resource it is.

Example: 'gke1'

name: str
266  @property
267  def name(self) -> str:
268    return self._resource_data['name']
location: str
270  @property
271  def location(self) -> str:
272    return self._resource_data['location']
pod_ipv4_cidr: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
274  @property
275  def pod_ipv4_cidr(self) -> IPv4NetOrIPv6Net:
276    cidr = self._resource_data['clusterIpv4Cidr']
277    return ipaddress.ip_network(cidr)
current_node_count: int
279  @property
280  def current_node_count(self) -> int:
281    return self._resource_data.get('currentNodeCount', 0)
release_channel: Optional[str]
283  @property
284  def release_channel(self) -> Optional[str]:
285    try:
286      return self._resource_data['releaseChannel']['channel']
287    except KeyError:
288      return None
nap_node_image_type: Optional[str]
290  @property
291  def nap_node_image_type(self) -> Optional[str]:
292    return get_path(
293      self._resource_data,
294      ('autoscaling', 'autoprovisioningNodePoolDefaults', 'imageType'),
295      default=None,
296    )
app_layer_sec_key: str
298  @property
299  def app_layer_sec_key(self) -> str:
300    return self._resource_data['databaseEncryption'].get('keyName')
status: str
302  @property
303  def status(self) -> str:
304    return self._resource_data['status']
status_message: str
306  @property
307  def status_message(self) -> str:
308    return self._resource_data.get('statusMessage', None)
def has_app_layer_enc_enabled(self) -> bool:
310  def has_app_layer_enc_enabled(self) -> bool:
311    # state := 'DECRYPTED' | 'ENCRYPTED', keyName := 'full_path_to_key_resouce'
312    return (
313      get_path(self._resource_data, ('databaseEncryption', 'state'), default=None) == 'ENCRYPTED'
314    )
def has_logging_enabled(self) -> bool:
316  def has_logging_enabled(self) -> bool:
317    return self._resource_data['loggingService'] != 'none'
def enabled_logging_components(self) -> List[str]:
319  def enabled_logging_components(self) -> List[str]:
320    return self._resource_data['loggingConfig']['componentConfig']['enableComponents']
def has_monitoring_enabled(self) -> bool:
322  def has_monitoring_enabled(self) -> bool:
323    return self._resource_data['monitoringService'] != 'none'
def enabled_monitoring_components(self) -> List[str]:
325  def enabled_monitoring_components(self) -> List[str]:
326    return self._resource_data['monitoringConfig']['componentConfig']['enableComponents']
def has_control_plane_logging_enabled(self) -> bool:
328  def has_control_plane_logging_enabled(self) -> bool:
329    if not self.has_logging_enabled():
330      return False
331    components = get_path(
332      self._resource_data, ('loggingConfig', 'componentConfig', 'enableComponents'), default=[]
333    )
334    return all(c in components for c in ['API_SERVER', 'SCHEDULER', 'CONTROLLER_MANAGER'])
def has_control_plane_monitoring_enabled(self) -> bool:
336  def has_control_plane_monitoring_enabled(self) -> bool:
337    if not self.has_monitoring_enabled():
338      return False
339    components = get_path(
340      self._resource_data, ('monitoringConfig', 'componentConfig', 'enableComponents'), default=[]
341    )
342    return all(c in components for c in ['API_SERVER', 'SCHEDULER', 'CONTROLLER_MANAGER'])
def has_authenticator_group_enabled(self) -> bool:
344  def has_authenticator_group_enabled(self) -> bool:
345    return len(self._resource_data.get('authenticatorGroupsConfig', {})) > 0
def has_workload_identity_enabled(self) -> bool:
347  def has_workload_identity_enabled(self) -> bool:
348    return len(self._resource_data.get('workloadIdentityConfig', {})) > 0
def has_http_load_balancing_enabled(self) -> bool:
350  def has_http_load_balancing_enabled(self) -> bool:
351    # HTTP load balancing needs to be enabled to use GKE ingress
352    return (
353      get_path(
354        self._resource_data,
355        ('addonsConfig', 'httpLoadBalancing', 'disabled'),
356        default=None,
357      )
358      is not True
359    )
def has_network_policy_enabled(self) -> bool:
361  def has_network_policy_enabled(self) -> bool:
362    # Network policy enforcement
363    return (
364      get_path(
365        self._resource_data,
366        ('addonsConfig', 'networkPolicyConfig', 'disabled'),
367        default=False,
368      )
369      is not True
370    )
def has_dpv2_enabled(self) -> bool:
372  def has_dpv2_enabled(self) -> bool:
373    # Checks whether dataplane V2 is enabled in clusters
374    return (
375      get_path(
376        self._resource_data,
377        ('networkConfig', 'datapathProvider'),
378        default=None,
379      )
380      == 'ADVANCED_DATAPATH'
381    )
def has_intra_node_visibility_enabled(self) -> bool:
383  def has_intra_node_visibility_enabled(self) -> bool:
384    if (
385      'networkConfig' in self._resource_data
386      and 'enableIntraNodeVisibility' in self._resource_data['networkConfig']
387    ):
388      return self._resource_data['networkConfig']['enableIntraNodeVisibility']
389    return False
def has_maintenance_window(self) -> bool:
391  def has_maintenance_window(self) -> bool:
392    # 'e3b0c442' is a hexadecimal string that represents the value of an empty
393    # string ('') in cryptography. If the maintenance windows are defined, the
394    # value of 'resourceVersion' is not empty ('e3b0c442').
395    return self._resource_data['maintenancePolicy']['resourceVersion'] != 'e3b0c442'
maintenance_policy: dict
397  @property
398  def maintenance_policy(self) -> dict:
399    """Returns the maintenance policy of the cluster."""
400    return self._resource_data.get('maintenancePolicy', {})

Returns the maintenance policy of the cluster.

def has_image_streaming_enabled(self) -> bool:
402  def has_image_streaming_enabled(self) -> bool:
403    """Check if cluster has Image Streaming (aka  Google Container File System)
404
405    enabled
406    """
407    global_gcsfs = get_path(
408      self._resource_data,
409      ('nodePoolDefaults', 'nodeConfigDefaults', 'gcfsConfig', 'enabled'),
410      default=False,
411    )
412    # Check nodePoolDefaults settings
413    if global_gcsfs:
414      return True
415    for np in self.nodepools:
416      # Check if any nodepool has image streaming enabled
417      if np.has_image_streaming_enabled():
418        return True
419    return False

Check if cluster has Image Streaming (aka Google Container File System)

enabled

nodepools: List[NodePool]
421  @property
422  def nodepools(self) -> List[NodePool]:
423    if self._nodepools is None:
424      self._nodepools = []
425      for n in self._resource_data.get('nodePools', []):
426        self._nodepools.append(NodePool(self, n))
427    return self._nodepools
network: gcpdiag.queries.network.Network
429  @property
430  def network(self) -> network.Network:
431    # projects/gcpdiag-gke1-aaaa/global/networks/default
432    network_string = self._resource_data['networkConfig']['network']
433    m = re.match(r'projects/([^/]+)/global/networks/([^/]+)$', network_string)
434    if not m:
435      raise RuntimeError("can't parse network string: %s" % network_string)
436    return network.get_network(m.group(1), m.group(2), self._context)
subnetwork: Optional[gcpdiag.models.Resource]
438  @property
439  def subnetwork(self) -> Optional[models.Resource]:
440    # 'projects/gcpdiag-gke1-aaaa/regions/europe-west4/subnetworks/default'
441    if 'subnetwork' not in self._resource_data['networkConfig']:
442      return None
443
444    subnetwork_string = self._resource_data['networkConfig']['subnetwork']
445    m = re.match(
446      r'projects/([^/]+)/regions/([^/]+)/subnetworks/([^/]+)$',
447      subnetwork_string,
448    )
449    if not m:
450      raise RuntimeError("can't parse network string: %s" % subnetwork_string)
451    return network.get_subnetwork(m.group(1), m.group(2), m.group(3))
get_subnet_name: Optional[gcpdiag.models.Resource]
453  @property
454  def get_subnet_name(self) -> Optional[models.Resource]:
455    if 'subnetwork' not in self._resource_data:
456      return None
457    return self._resource_data['subnetwork']
get_nodepool_config: Optional[gcpdiag.models.Resource]
459  @property
460  def get_nodepool_config(self) -> Optional[models.Resource]:
461    if 'nodePools' not in self._resource_data:
462      return None
463    return self._resource_data['nodePools']
get_network_string: str
465  @property
466  def get_network_string(self) -> str:
467    if 'networkConfig' not in self._resource_data:
468      return ''
469    if 'network' not in self._resource_data['networkConfig']:
470      return ''
471    return self._resource_data['networkConfig']['network']
is_private: bool
473  @property
474  def is_private(self) -> bool:
475    if 'privateClusterConfig' not in self._resource_data:
476      return False
477
478    return self._resource_data['privateClusterConfig'].get('enablePrivateNodes', False)
is_vpc_native: bool
480  @property
481  def is_vpc_native(self) -> bool:
482    return get_path(
483      self._resource_data,
484      ('ipAllocationPolicy', 'useIpAliases'),
485      default=False,
486    )
is_regional: bool
488  @property
489  def is_regional(self) -> bool:
490    return len(self._resource_data['locations']) > 1
cluster_ca_certificate: str
492  @property
493  def cluster_ca_certificate(self) -> str:
494    return self._resource_data['masterAuth']['clusterCaCertificate']
endpoint: Optional[str]
496  @property
497  def endpoint(self) -> Optional[str]:
498    if 'endpoint' not in self._resource_data:
499      return None
500    return self._resource_data['endpoint']
is_autopilot: bool
502  @property
503  def is_autopilot(self) -> bool:
504    if 'autopilot' not in self._resource_data:
505      return False
506    return self._resource_data['autopilot'].get('enabled', False)
masters_cidr_list: Iterable[Union[ipaddress.IPv4Network, ipaddress.IPv6Network]]
508  @property
509  def masters_cidr_list(self) -> Iterable[IPv4NetOrIPv6Net]:
510    if get_path(
511      self._resource_data,
512      ('privateClusterConfig', 'masterIpv4CidrBlock'),
513      default=None,
514    ):
515      return [
516        ipaddress.ip_network(self._resource_data['privateClusterConfig']['masterIpv4CidrBlock'])
517      ]
518    else:
519      # only older clusters still have ssh firewall rules
520      if self.current_node_count and not self.cluster_hash:
521        logging.warning("couldn't retrieve cluster hash for cluster %s.", self.name)
522        return []
523      fw_rule_name = f'gke-{self.name}-{self.cluster_hash}-ssh'
524      rule = self.network.firewall.get_vpc_ingress_rules(name=fw_rule_name)
525      if rule and rule[0].is_enabled():
526        return rule[0].source_ranges
527      return []
cluster_hash: Optional[str]
529  @property
530  def cluster_hash(self) -> Optional[str]:
531    """Returns the "cluster hash" as used in automatic firewall rules for GKE clusters.
532
533    Cluster hash is the first 8 characters of cluster id. See also:
534    https://cloud.google.com/kubernetes-engine/docs/concepts/firewall-rules
535    """
536    if 'id' in self._resource_data:
537      return self._resource_data['id'][:8]
538    raise UndefinedClusterPropertyError('no id')

Returns the "cluster hash" as used in automatic firewall rules for GKE clusters.

Cluster hash is the first 8 characters of cluster id. See also: https://cloud.google.com/kubernetes-engine/docs/concepts/firewall-rules

is_nodelocal_dnscache_enabled: bool
540  @property
541  def is_nodelocal_dnscache_enabled(self) -> bool:
542    """Returns True if NodeLocal DNSCache is enabled for the cluster."""
543    addons_config = self._resource_data.get('addonsConfig', {})
544    dns_cache_config = addons_config.get('dnsCacheConfig', {})
545    return dns_cache_config.get('enabled', False)

Returns True if NodeLocal DNSCache is enabled for the cluster.

@caching.cached_api_call
def get_clusters( context: gcpdiag.models.Context) -> Mapping[str, Cluster]:
548@caching.cached_api_call
549def get_clusters(context: models.Context) -> Mapping[str, Cluster]:
550  """Get a list of Cluster matching the given context, indexed by cluster full path."""
551  clusters: Dict[str, Cluster] = {}
552  if not apis.is_enabled(context.project_id, 'container'):
553    return clusters
554  container_api = apis.get_api('container', 'v1', context.project_id)
555  logging.debug('fetching list of GKE clusters in project %s', context.project_id)
556  query = (
557    container_api.projects()
558    .locations()
559    .clusters()
560    .list(parent=f'projects/{context.project_id}/locations/-')
561  )
562  try:
563    resp = query.execute(num_retries=config.API_RETRIES)
564    if 'clusters' not in resp:
565      return clusters
566    for resp_c in resp['clusters']:
567      # verify that we some minimal data that we expect
568      if 'name' not in resp_c or 'location' not in resp_c:
569        raise RuntimeError('missing data in projects.locations.clusters.list response')
570      if not context.match_project_resource(
571        location=resp_c.get('location', ''),
572        labels=resp_c.get('resourceLabels', {}),
573        resource=resp_c.get('name', ''),
574      ):
575        continue
576      c = Cluster(project_id=context.project_id, resource_data=resp_c, context=context)
577      clusters[c.full_path] = c
578  except googleapiclient.errors.HttpError as err:
579    raise utils.GcpApiError(err) from err
580  return clusters

Get a list of Cluster matching the given context, indexed by cluster full path.

@caching.cached_api_call
def get_cluster( project_id, cluster_id, location) -> Optional[Cluster]:
583@caching.cached_api_call
584def get_cluster(
585  project_id,
586  cluster_id,
587  location,
588) -> Union[Cluster, None]:
589  """Get a Cluster from project_id of a context."""
590  if not apis.is_enabled(project_id, 'container'):
591    return None
592  container_api = apis.get_api('container', 'v1', project_id)
593  logging.debug('fetching the GKE cluster %s in project %s', cluster_id, project_id)
594  query = (
595    container_api.projects()
596    .locations()
597    .clusters()
598    .get(name=f'projects/{project_id}/locations/{location}/clusters/{cluster_id}')
599  )
600  try:
601    resp = query.execute(num_retries=config.API_RETRIES)
602    if cluster_id not in str(resp):
603      raise RuntimeError('missing data in projects.locations.clusters.list response')
604  except googleapiclient.errors.HttpError as err:
605    raise utils.GcpApiError(err) from err
606  return Cluster(
607    project_id=project_id,
608    resource_data=resp,
609    context=models.Context(project_id=project_id),
610  )

Get a Cluster from project_id of a context.

def get_valid_master_versions(project_id: str, location: str) -> List[str]:
625def get_valid_master_versions(project_id: str, location: str) -> List[str]:
626  """Get a list of valid GKE master versions."""
627  server_config = _get_server_config(project_id, location)
628  versions: List[str] = []
629
630  # channel versions may extend the list of all available versions.\
631  # Especially for the Rapid channel - many new versions only available in Rapid
632  # channel and not as a static version to make sure nobody stuck on that
633  # version for an extended period of time.
634  for c in server_config['channels']:
635    versions += c['validVersions']
636
637  versions += server_config['validMasterVersions']
638
639  return versions

Get a list of valid GKE master versions.

def get_valid_node_versions(project_id: str, location: str) -> List[str]:
642def get_valid_node_versions(project_id: str, location: str) -> List[str]:
643  """Get a list of valid GKE master versions."""
644  server_config = _get_server_config(project_id, location)
645  versions: List[str] = []
646
647  # See explanation in get_valid_master_versions
648  for c in server_config['channels']:
649    versions += c['validVersions']
650
651  versions += server_config['validNodeVersions']
652
653  return versions

Get a list of valid GKE master versions.

class Node(gcpdiag.models.Resource):
656class Node(models.Resource):
657  """Represents a GKE node.
658
659  This class useful for example to determine the GKE cluster when you only have
660  an GCE instance id (like from a metrics label).
661  """
662
663  instance: gce.Instance
664  nodepool: NodePool
665  mig: gce.ManagedInstanceGroup
666
667  def __init__(self, instance, nodepool, mig):
668    super().__init__(project_id=instance.project_id)
669    self.instance = instance
670    self.nodepool = nodepool
671    self.mig = mig
672    pass
673
674  @property
675  def full_path(self) -> str:
676    return self.nodepool.cluster.full_path + '/nodes/' + self.instance.name
677
678  @property
679  def short_path(self) -> str:
680    # return self.nodepool.cluster.short_path + '/' + self.instance.name
681    return self.instance.short_path

Represents a GKE node.

This class useful for example to determine the GKE cluster when you only have an GCE instance id (like from a metrics label).

Node(instance, nodepool, mig)
667  def __init__(self, instance, nodepool, mig):
668    super().__init__(project_id=instance.project_id)
669    self.instance = instance
670    self.nodepool = nodepool
671    self.mig = mig
672    pass
nodepool: NodePool
full_path: str
674  @property
675  def full_path(self) -> str:
676    return self.nodepool.cluster.full_path + '/nodes/' + self.instance.name

Returns the full path of this resource.

Example: 'projects/gcpdiag-gke-1-9b90/zones/europe-west4-a/clusters/gke1'

short_path: str
678  @property
679  def short_path(self) -> str:
680    # return self.nodepool.cluster.short_path + '/' + self.instance.name
681    return self.instance.short_path

Returns the short name for this resource.

Note that it isn't clear from this name what kind of resource it is.

Example: 'gke1'

@functools.lru_cache()
def get_node_by_instance_id( context: gcpdiag.models.Context, instance_id: str) -> Node:
687@functools.lru_cache()
688def get_node_by_instance_id(context: models.Context, instance_id: str) -> Node:
689  """Get a gke.Node instance by instance id.
690
691  Throws a KeyError in case this instance is not found or isn't part of a GKE
692  cluster.
693  """
694  # This will throw a KeyError if the instance is not found, which is also
695  # the behavior that we want for this function.
696  instance = gce.get_instances(context)[instance_id]
697  clusters = get_clusters(context)
698  try:
699    # instance.mig throws AttributeError if it isn't part of a mig
700    mig = instance.mig
701
702    # find a NodePool that uses this MIG
703    for c in clusters.values():
704      for np in c.nodepools:
705        for np_mig in np.instance_groups:
706          if mig == np_mig:
707            return Node(instance=instance, nodepool=np, mig=mig)
708
709    # if we didn't find a nodepool that owns this instance, raise a KeyError
710    raise KeyError("can't determine GKE cluster for instance %s" % (instance_id))
711
712  except AttributeError as err:
713    raise KeyError from err
714  return None

Get a gke.Node instance by instance id.

Throws a KeyError in case this instance is not found or isn't part of a GKE cluster.

@caching.cached_api_call
def get_release_schedule() -> Dict:
717@caching.cached_api_call
718def get_release_schedule() -> Dict:
719  """Extract the release schedule for gke clusters
720
721  Returns:
722    A dictionary of release schedule.
723  """
724  page_url = 'https://cloud.google.com/kubernetes-engine/docs/release-schedule'
725  release_data = {}
726  # estimate first month of the quarter
727  quarter_dates = {'Q1': '1', 'Q2': '4', 'Q3': '7', 'Q4': '10'}
728  try:
729    rows = web.fetch_and_extract_table_data(
730      page_url, tag='table', class_name='gke-release-schedule'
731    )
732
733    # Function to parse a date string or return None for 'N/A'
734    def parse_date(date_str) -> Optional[Union[datetime.date, str]]:
735      if not date_str:
736        return None
737      if 'already reached EOL' in date_str:
738        return 'already reached EOL'
739      p = r'(?P<year>\d{4})-(?:(?P<quarter>Q[1-4])|(?P<month>[0-9]{1,2}))(?:-(?P<day>[0-9]{1,2}))?'
740      match = re.search(p, date_str)
741      # Handle incomplete dates in 'YYYY-MM' form
742      if match and match.group('month') and not match.group('day'):
743        return datetime.date.fromisoformat(f'{date_str}-15')
744      # Handle quarter year (for example, 2025-Q3) approximations that are updated when known.
745      # https://cloud.google.com/kubernetes-engine/docs/release-schedule.md#fn6
746      if match and match.group('quarter') and not match.group('day'):
747        date_str = f'{match.group("year")}-{quarter_dates[match.group("quarter")]}-01'
748        return datetime.date.fromisoformat(date_str)
749      if match and match.group('year') and match.group('month') and match.group('day'):
750        return datetime.date.fromisoformat(date_str)
751      # anything less like N/A return None
752      return None
753
754    # Iterate over each row and extract the data
755    for cols in rows:
756      if not cols:
757        continue
758      if len(cols) < 10:
759        continue
760      minor_version = cols[0]
761      rapid_avail = parse_date(cols[1])
762      regular_avail = parse_date(cols[3])
763      stable_avail = parse_date(cols[5])
764      extended_avail = parse_date(cols[7])
765      end_of_standard_support = parse_date(cols[9])
766      end_of_extended_support = parse_date(cols[10]) if len(cols) > 10 else None
767
768      # Add the extracted data into the dictionary in the desired format
769      release_data[minor_version] = {
770        'rapid_avail': rapid_avail,
771        'regular_avail': regular_avail,
772        'stable_avail': stable_avail,
773        'extended_avail': extended_avail,
774        'eol': end_of_standard_support,
775        'extended_eol': end_of_extended_support,
776      }
777    return release_data
778  except (
779    requests.exceptions.RequestException,
780    AttributeError,
781    TypeError,
782    ValueError,
783    IndexError,
784  ) as e:
785    logging.error('Error in extracting gke release schedule: %s', e)
786    return release_data

Extract the release schedule for gke clusters

Returns:

A dictionary of release schedule.