2019-12-13 11:19:14 +01:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
import re
|
|
|
|
import sys
|
|
|
|
|
2019-12-19 17:36:35 +01:00
|
|
|
from typing import (IO, List, Tuple)
|
2019-12-16 12:09:56 +01:00
|
|
|
|
2019-12-13 11:19:14 +01:00
|
|
|
import requests
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
K8sResourceIdentifier = Tuple[str, str, str, str]
|
|
|
|
|
|
|
|
HEADERS = {"Content-Type": "application/json"}
|
|
|
|
|
|
|
|
BLACKLIST_REGEXS = [
|
2019-12-16 12:09:56 +01:00
|
|
|
# Kubernetes inherent blacklist (should apply to every k8s cluster out there)
|
|
|
|
r'^.*:apps/v1:ControllerRevision:.*$',
|
|
|
|
r'^.*:apps/v1:ReplicaSet:.*$',
|
|
|
|
r'^.*:batch/v1:Job:.*-\d{10,}$', # jobs created by cron jobs with unix timestamp suffix
|
2020-08-31 13:18:14 +02:00
|
|
|
r'^.*:events.k8s.io/v1:Event:.*$',
|
2019-12-16 12:09:56 +01:00
|
|
|
r'^.*:metrics.k8s.io/v1beta1:PodMetrics:.*$',
|
|
|
|
r'^.*:v1:Endpoints:.*$',
|
2020-08-21 14:19:51 +02:00
|
|
|
r'^.*:.*:EndpointSlice:.*$',
|
2019-12-16 12:09:56 +01:00
|
|
|
r'^.*:v1:Event:.*$',
|
|
|
|
r'^.*:v1:Pod:.*$',
|
|
|
|
r'^.*:v1:Secret:.*-token-\S{5}$', # secrets with token for service accounts
|
|
|
|
r'^.*:v1:ServiceAccount:default$',
|
|
|
|
r'^default:v1:Service:kubernetes$',
|
|
|
|
r'^kube-node-lease:.*$',
|
|
|
|
r'^kube-public:.*$',
|
|
|
|
r'^kube-system:.*$',
|
|
|
|
|
|
|
|
# GKE specific parts (should apply to every GKE-managed k8s cluster)
|
|
|
|
# '^.*:v1:ResourceQuota:gke-resource-quotas$',
|
|
|
|
# '^default:v1:LimitRange:limits$,
|
2019-12-13 11:19:14 +01:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def get_live_namespaced_resources(url: str) -> List[K8sResourceIdentifier]:
|
2019-12-19 17:36:35 +01:00
|
|
|
"""
|
|
|
|
Returns list of Kubernetes resource identifiers of namespaced resources out of the live cluster reachable at url.
|
|
|
|
"""
|
|
|
|
|
2019-12-13 11:19:14 +01:00
|
|
|
result = []
|
|
|
|
|
|
|
|
# merges https://kubernetes.io/docs/reference/using-api/#api-groups
|
|
|
|
|
|
|
|
# legacy API group
|
|
|
|
apiVersions = requests.get(url + '/api', headers=HEADERS).json()['versions']
|
|
|
|
for apiVersion in apiVersions:
|
|
|
|
apiResources = requests.get(url + '/api/' + apiVersion, headers=HEADERS).json()['resources']
|
|
|
|
for apiResource in apiResources:
|
|
|
|
if not ('list' in apiResource['verbs'] and apiResource['namespaced']):
|
|
|
|
continue
|
|
|
|
|
|
|
|
items = requests.get(url + '/api/' + apiVersion + '/' + apiResource['name'],
|
|
|
|
headers=HEADERS).json()['items']
|
|
|
|
for item in items:
|
|
|
|
result.append(
|
2019-12-16 12:09:56 +01:00
|
|
|
(item['metadata']['namespace'], apiVersion, apiResource['kind'], item['metadata']['name']))
|
2019-12-13 11:19:14 +01:00
|
|
|
|
|
|
|
# named API groups
|
|
|
|
apiGroups = requests.get(url + '/apis', headers=HEADERS).json()['groups']
|
|
|
|
for apiGroup in apiGroups:
|
|
|
|
|
|
|
|
apiResources = requests.get(url + '/apis/' + apiGroup['preferredVersion']['groupVersion'],
|
|
|
|
headers=HEADERS).json()['resources']
|
|
|
|
for apiResource in apiResources:
|
|
|
|
if not ('list' in apiResource['verbs'] and apiResource['namespaced']):
|
|
|
|
continue
|
|
|
|
|
|
|
|
if apiGroup['preferredVersion']['groupVersion'] == 'extensions/v1beta1' and apiResource['kind'] != 'Ingress':
|
|
|
|
# everything else in extensions/v1beta1 should be migrated to the preferred version
|
|
|
|
# except ingresses, see https://kubernetes.io/blog/2019/07/18/api-deprecations-in-1-16/
|
|
|
|
continue
|
|
|
|
|
|
|
|
items = requests.get(url + '/apis/' + apiGroup['preferredVersion']['groupVersion'] + '/' +
|
|
|
|
apiResource['name'],
|
|
|
|
headers=HEADERS).json()['items']
|
|
|
|
for item in items:
|
|
|
|
result.append((item['metadata']['namespace'], apiGroup['preferredVersion']['groupVersion'],
|
|
|
|
apiResource['kind'], item['metadata']['name']))
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def get_target_namespaced_resources(stream: IO) -> List[K8sResourceIdentifier]:
|
2019-12-19 17:36:35 +01:00
|
|
|
"""
|
|
|
|
Returns list of Kubernetes resource identifiers of namespaced resources out of the target stream.
|
|
|
|
"""
|
2019-12-13 11:19:14 +01:00
|
|
|
result = []
|
|
|
|
|
|
|
|
target_documents = list(yaml.load_all(stream, Loader=yaml.SafeLoader))
|
|
|
|
for document in target_documents:
|
|
|
|
if not document:
|
|
|
|
continue
|
|
|
|
if not 'namespace' in document['metadata']:
|
|
|
|
continue
|
|
|
|
|
|
|
|
result.append(
|
2019-12-16 12:09:56 +01:00
|
|
|
(document['metadata']['namespace'], document['apiVersion'], document['kind'], document['metadata']['name']))
|
2019-12-13 11:19:14 +01:00
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def get_compact_resource_identifiers(tuples: List[K8sResourceIdentifier]) -> List[str]:
|
2019-12-19 17:36:35 +01:00
|
|
|
"""
|
|
|
|
Returns a compact, sortable string for a Kubernetes resource identifier.
|
|
|
|
"""
|
2019-12-13 11:19:14 +01:00
|
|
|
return [namespace + ':' + apiVersion + ':' + kind + ':' + name for namespace, apiVersion, kind, name in tuples]
|
|
|
|
|
|
|
|
|
2019-12-16 12:09:56 +01:00
|
|
|
def main():
|
2019-12-13 11:19:14 +01:00
|
|
|
parser = argparse.ArgumentParser(description='Utility to detect k8s configuration drift.')
|
|
|
|
|
|
|
|
parser.add_argument('-f',
|
|
|
|
dest='target_manifests_file',
|
|
|
|
required=True,
|
|
|
|
help='File path (or - for stdin) to read Kubernetes manifests from for target state.')
|
|
|
|
parser.add_argument('--url',
|
|
|
|
dest='k8s_apiserver_url',
|
|
|
|
default='http://localhost:8001',
|
|
|
|
help='URL of Kubernetes apiserver to retrieve live state.')
|
|
|
|
parser.add_argument('--blacklist',
|
|
|
|
dest='blacklist_file',
|
|
|
|
help='File path to read blacklist regex entries from that will be used to filter live state.')
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
2019-12-19 17:36:35 +01:00
|
|
|
blacklist_regexs: List[str] = []
|
2019-12-13 11:19:14 +01:00
|
|
|
blacklist_regexs += BLACKLIST_REGEXS
|
|
|
|
|
|
|
|
if args.blacklist_file:
|
|
|
|
print(f'Reading blacklist file {args.blacklist_file}...')
|
|
|
|
with open(args.blacklist_file, 'r') as f:
|
2019-12-16 12:09:56 +01:00
|
|
|
blacklist_regexs += list(filter(lambda x: not re.match(r'^\s*$', x), f.read().split('\n')))
|
2019-12-13 11:19:14 +01:00
|
|
|
|
|
|
|
print('Retrieving target state...')
|
|
|
|
if args.target_manifests_file == '-':
|
|
|
|
target_tuples = get_target_namespaced_resources(sys.stdin)
|
|
|
|
else:
|
|
|
|
with open(args.target_manifests_file, 'r') as f:
|
|
|
|
target_tuples = get_target_namespaced_resources(f)
|
|
|
|
|
|
|
|
print(f'Retrieving live state from {args.k8s_apiserver_url}...')
|
|
|
|
raw_live_strings = get_compact_resource_identifiers(get_live_namespaced_resources(args.k8s_apiserver_url))
|
|
|
|
|
|
|
|
live_strings = list(filter(lambda s: not re.match('|'.join(blacklist_regexs), s), raw_live_strings))
|
|
|
|
|
|
|
|
starget = set(get_compact_resource_identifiers(target_tuples))
|
|
|
|
slive = set(live_strings)
|
|
|
|
|
|
|
|
print('Live dynamic configmaps that are not in target (stale):')
|
|
|
|
counter = 0
|
|
|
|
for x in sorted(list(slive - starget)):
|
|
|
|
if re.match('^.*:v1:ConfigMap:.*-[a-z0-9]{10}', x):
|
|
|
|
counter += 1
|
|
|
|
print(' ' + x)
|
|
|
|
print("..", counter, "entries")
|
|
|
|
|
|
|
|
print()
|
|
|
|
print('Live resources w/o dynamic configmaps that are not in target (stale):')
|
|
|
|
counter = 0
|
|
|
|
for x in sorted(list(slive - starget)):
|
|
|
|
if not re.match('^.*:v1:ConfigMap:.*-[a-z0-9]{10}', x):
|
|
|
|
counter += 1
|
|
|
|
print(' ' + x)
|
|
|
|
print("..", counter, "entries")
|
2019-12-16 12:09:56 +01:00
|
|
|
|
2020-08-21 14:19:51 +02:00
|
|
|
sys.exit(len(list(slive - starget)))
|
|
|
|
|
2019-12-16 12:09:56 +01:00
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|