2727SPDX-License-Identifier: CC-BY-SA-4.0
2828"""
2929
30- from collections import Counter
3130from dataclasses import dataclass
3231from datetime import datetime , timedelta
3332from pathlib import Path
34- import aiohttp
35- import asyncio
3633import contextlib
3734import getopt
38- import kubernetes_asyncio
3935import logging
4036import logging .config
4137import re
42- import requests
4338import sys
39+
40+ import aiohttp
41+ import asyncio
42+ import kubernetes_asyncio
4443import yaml
4544
4645
5150CVE_SEVERITY = 8 # CRITICAL
5251
5352HERE = Path (__file__ ).parent
54- EOLDATA_FILE = "k8s-eol-data.yml"
55-
56- logging_config = {
57- "level" : "INFO" ,
58- "version" : 1 ,
59- "disable_existing_loggers" : False ,
60- "formatters" : {
61- "k8s_version_policy" : {
62- "format" : "%(levelname)s: %(message)s"
63- }
64- },
65- "handlers" : {
66- "console" : {
67- "class" : "logging.StreamHandler" ,
68- "formatter" : "k8s_version_policy" ,
69- "stream" : "ext://sys.stderr"
70- }
71- },
72- "root" : {
73- "handlers" : ["console" ]
74- }
75- }
53+ EOLDATA_PATH = Path (HERE , "k8s-eol-data.yml" )
7654
7755logger = logging .getLogger (__name__ )
7856
7957
80- class CountingHandler (logging .Handler ):
81- def __init__ (self , level = logging .NOTSET ):
82- super ().__init__ (level = level )
83- self .bylevel = Counter ()
84-
85- def handle (self , record ):
86- self .bylevel [record .levelno ] += 1
87-
88-
8958class ConfigException (BaseException ):
9059 """Exception raised in a configuration error occurs"""
9160
@@ -94,63 +63,48 @@ class HelpException(BaseException):
9463 """Exception raised if the help functionality is called"""
9564
9665
97- class Config :
98- kubeconfig = None
99- context = None
100- logging = logging_config
101-
102-
10366def print_usage ():
10467 print ("""
10568K8s Version Policy Compliance Check
10669
107- Usage: k8s_version_policy.py [-h] -k|--kubeconfig PATH/TO/KUBECONFIG [--context CONTEXT]
70+ Usage: k8s_version_policy.py [-h] -k|--kubeconfig PATH/TO/KUBECONFIG
10871
10972This tool checks whether the given cluster conforms to the SCS k8s version policy. It checks one
11073cluster only, so it doesn't check whether multiple k8s branches are offered. The return code
11174will be 0 precisely when all attempted checks are passed; otherwise check log messages.
11275
11376 -k/--kubeconfig PATH/TO/KUBECONFIG - Path to the kubeconfig of the server we want to check
114- -C/--context CONTEXT - Optional: kubeconfig context to use
11577 -h - Output help
11678""" )
11779
11880
119- def parse_arguments (argv ):
120- """Parse cli arguments from the script call"""
121- try :
122- opts , args = getopt .gnu_getopt (argv , "C:k:h" , ["context=" , "kubeconfig=" , "help" ])
123- except getopt .GetoptError :
124- raise ConfigException
81+ class Config :
82+ def __init__ (self , log_level = logging .INFO ):
83+ self .kubeconfig = None
84+ self .log_level = log_level
12585
126- config = Config ()
127- for opt in opts :
128- if opt [0 ] == "-h" or opt [0 ] == "--help" :
129- raise HelpException
130- if opt [0 ] == "-k" or opt [0 ] == "--kubeconfig" :
131- config .kubeconfig = opt [1 ]
132- if opt [0 ] == "-C" or opt [0 ] == "--context" :
133- config .context = opt [1 ]
134- return config
135-
136-
137- def setup_logging (config_log ):
138- logging .config .dictConfig (config_log )
139- loggers = [
140- logging .getLogger (name )
141- for name in logging .root .manager .loggerDict
142- if not logging .getLogger (name ).level
143- ]
144- for log in loggers :
145- log .setLevel (config_log ['level' ])
146-
147-
148- def initialize_config (config ):
149- """Initialize the configuration for the test script"""
150- setup_logging (config .logging )
151- if config .kubeconfig is None :
152- raise ConfigException ("A kubeconfig needs to be set in order to test a k8s cluster version." )
153- return config
86+ def apply_argv (self , argv ):
87+ """Parse cli arguments from the script call"""
88+ try :
89+ opts , args = getopt .gnu_getopt (argv , "k:h" , ["kubeconfig=" , "help" ])
90+ except getopt .GetoptError :
91+ raise ConfigException
92+
93+ for opt in opts :
94+ if opt [0 ] == "-h" or opt [0 ] == "--help" :
95+ raise HelpException
96+ if opt [0 ] == "-k" or opt [0 ] == "--kubeconfig" :
97+ self .kubeconfig = opt [1 ]
98+
99+ def setup (self ):
100+ """Initialize the configuration for the test script"""
101+ logging .basicConfig (format = '%(levelname)s: %(message)s' , level = self .log_level )
102+ for name in logging .root .manager .loggerDict :
103+ logger = logging .getLogger (name )
104+ if not logger .level :
105+ logger .setLevel (self .log_level )
106+ if self .kubeconfig is None :
107+ raise ConfigException ("A kubeconfig needs to be set in order to test a k8s cluster version." )
154108
155109
156110@dataclass (frozen = True , eq = True , order = True )
@@ -222,17 +176,18 @@ def age(self):
222176 return datetime .now () - self .released_at
223177
224178
225- def fetch_k8s_releases_data () -> list [dict ]:
179+ async def fetch_k8s_releases_data (session : aiohttp . ClientSession ) -> list [dict ]:
226180 github_headers = {
227181 "Accept" : "application/vnd.github+json" ,
228182 "X-GitHub-Api-Version" : "2022-11-28"
229183 }
230184
231185 # Request the latest 100 releases (the next are not needed, since these versions are too old)
232- return requests .get (
186+ response = await session .get (
233187 "https://api.github.com/repos/kubernetes/kubernetes/releases?per_page=100" ,
234188 headers = github_headers ,
235- ).json ()
189+ )
190+ return await response .json ()
236191
237192
238193def parse_github_release_data (release_data : dict ) -> K8sRelease :
@@ -451,76 +406,78 @@ def read_supported_k8s_branches(eol_data_path: Path) -> dict[K8sBranch, K8sBranc
451406 return {info .branch : info for info in infos }
452407
453408
454- async def main (argv ):
455- try :
456- config = initialize_config (parse_arguments (argv ))
457- except (OSError , ConfigException , HelpException ) as e :
458- logger .critical ("%s" , e )
459- print_usage ()
460- return 1
461-
462- counting_handler = CountingHandler (level = logging .INFO )
463- logger .addHandler (counting_handler )
464-
465- branch_infos = read_supported_k8s_branches (Path (HERE , EOLDATA_FILE ))
409+ def determine_supported_branches (eoldata_path = EOLDATA_PATH ):
410+ branch_infos = read_supported_k8s_branches (eoldata_path )
466411 supported_branches = {
467412 branch
468413 for branch , branch_info
469414 in branch_infos .items ()
470415 if branch_info .is_supported ()
471416 }
472417 if len (supported_branches ) < 3 :
473- logger .warning ("The EOL data in %s isn't up-to-date." , EOLDATA_FILE )
418+ logger .warning ("The EOL data in %s isn't up-to-date." , eoldata_path . name )
474419 if len (supported_branches ) < 2 :
475- logger .critical ("The EOL data in %s is outdated and we cannot reliably run this script." , EOLDATA_FILE )
476- return 1
477-
478- connector = aiohttp .TCPConnector (limit = 5 )
479- async with aiohttp .ClientSession (connector = connector ) as session :
480- cve_affected_ranges = await collect_cve_versions (session )
481- releases_data = fetch_k8s_releases_data ()
482-
483- try :
484- context_desc = f"context '{ config .context } '" if config .context else "default context"
485- logger .info ("Checking cluster specified by %s in %s." , context_desc , config .kubeconfig )
486- cluster = await get_k8s_cluster_info (config .kubeconfig , config .context )
487- cluster_branch = cluster .version .branch
488-
489- if cluster_branch not in supported_branches :
490- logger .error ("The K8s cluster version %s of cluster '%s' is already EOL." , cluster .version , cluster .name )
491- elif check_k8s_version_recency (cluster .version , releases_data , cve_affected_ranges ):
492- logger .info (
493- "The K8s cluster version %s of cluster '%s' is still in the recency time window." ,
494- cluster .version ,
495- cluster .name ,
496- )
497- else :
420+ raise RuntimeError (f"The EOL data in { eoldata_path .name } is critically outdated!" )
421+ return supported_branches
422+
423+
424+ def compute_version_policy_check (supported_branches , cve_affected_ranges , releases_data , cluster ):
425+ if cluster .version .branch not in supported_branches :
426+ logger .error ("The K8s cluster version %s of cluster '%s' is already EOL." , cluster .version , cluster .name )
427+ return False
428+ if check_k8s_version_recency (cluster .version , releases_data , cve_affected_ranges ):
429+ logger .info (
430+ "The K8s cluster version %s of cluster '%s' is still in the recency time window." ,
431+ cluster .version ,
432+ cluster .name ,
433+ )
434+ return True
435+ for affected_range in cve_affected_ranges :
436+ if cluster .version in affected_range :
498437 logger .error (
499- "The K8s cluster version %s of cluster '%s' is outdated according to the standard ." ,
438+ "The K8s cluster version %s of cluster '%s' is an outdated version with a possible CRITICAL CVE ." ,
500439 cluster .version ,
501440 cluster .name ,
502441 )
442+ logger .error (
443+ "The K8s cluster version %s of cluster '%s' is outdated according to the standard." ,
444+ cluster .version ,
445+ cluster .name ,
446+ )
447+ return False
448+
503449
504- for affected_range in cve_affected_ranges :
505- if cluster .version in affected_range :
506- logger .error (
507- "The K8s cluster version %s of cluster '%s' is an outdated version with a possible CRITICAL CVE." ,
508- cluster .version ,
509- cluster .name ,
510- )
450+ async def main (argv ):
451+ config = Config ()
452+ try :
453+ config .apply_argv (argv )
454+ config .setup ()
455+ except HelpException :
456+ print_usage ()
457+ return 0
511458 except BaseException as e :
512459 logger .critical ("%s" , e )
513- logger .debug ("Exception info" , exc_info = True )
514460 return 1
515461
516- c = counting_handler .bylevel
517- logger .debug (
518- "Total error / warning: "
519- f"{ c [logging .ERROR ]} / { c [logging .WARNING ]} "
520- )
521- if not c [logging .CRITICAL ]:
522- print ("version-policy-check: " + ('PASS' , 'FAIL' )[min (1 , c [logging .ERROR ])])
523- return min (127 , c [logging .ERROR ]) # cap at 127 due to OS restrictions
462+ try :
463+ supported_branches = determine_supported_branches ()
464+ connector = aiohttp .TCPConnector (limit = 5 )
465+ logger .info ("Checking cluster specified in %s." , config .kubeconfig )
466+ async with aiohttp .ClientSession (connector = connector ) as session :
467+ cve_affected_ranges , releases_data , cluster = await asyncio .gather (
468+ collect_cve_versions (session ),
469+ fetch_k8s_releases_data (session ),
470+ get_k8s_cluster_info (config .kubeconfig ),
471+ )
472+ result = compute_version_policy_check (
473+ supported_branches , cve_affected_ranges , releases_data , cluster )
474+ print ("version-policy-check: " + ('FAIL' , 'PASS' )[bool (result )])
475+ except BaseException :
476+ print ("version-policy-check: ABORT" )
477+ logger .critical ("Could not complete version-policy-check" , exc_info = True )
478+ return 1
479+
480+ return 0
524481
525482
526483if __name__ == "__main__" :
0 commit comments