From 26f427df2a720f617ec00da04807954f15d6e847 Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Fri, 6 Dec 2024 10:49:30 -0500 Subject: [PATCH 1/8] ownership_tree.py working with nice-ish output Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 153 +++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100755 hack/tools/ownership_tree.py diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py new file mode 100755 index 000000000..6999f34f6 --- /dev/null +++ b/hack/tools/ownership_tree.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +import json +import subprocess +import sys +import argparse +from collections import defaultdict + +parser = argparse.ArgumentParser(description="Print a tree of ownership for all resources in a namespace, grouped by kind.") +parser.add_argument("namespace", help="The namespace to inspect") +parser.add_argument("--no-events", action="store_true", help="Do not show Events kind grouping") +args = parser.parse_args() + +NAMESPACE = args.namespace +SHOW_EVENTS = not args.no_events + +# Get all namespaced resource types +api_resources_cmd = ["kubectl", "api-resources", "--verbs=list", "--namespaced", "-o", "name"] +resource_types = subprocess.check_output(api_resources_cmd, text=True).strip().split('\n') + +uid_to_resource = {} +all_uids = set() + +def get_resources_for_type(r_type): + try: + items_json = subprocess.check_output( + ["kubectl", "get", r_type, "-n", NAMESPACE, "-o", "json"], + text=True + ) + except subprocess.CalledProcessError: + return [] + data = json.loads(items_json) + if "items" not in data: + return [] + return data["items"] + +# Collect all resources into uid_to_resource +for r_type in resource_types: + items = get_resources_for_type(r_type) + for item in items: + uid = item["metadata"]["uid"] + kind = item["kind"] + name = item["metadata"]["name"] + namespace = item["metadata"].get("namespace", NAMESPACE) + owners = [(o["kind"], o["name"], o["uid"]) for o in item["metadata"].get("ownerReferences", [])] + + # If --no-events and resource is an Event, skip adding it altogether + if kind == "Event" and not SHOW_EVENTS: + continue + + uid_to_resource[uid] = { + "kind": kind, + "name": name, + "namespace": namespace, + "uid": uid, + "owners": owners + } + all_uids.add(uid) + +# Build a map of owner_uid -> [child_uids] +owner_to_children = defaultdict(list) +for uid, res in uid_to_resource.items(): + for (o_kind, o_name, o_uid) in res["owners"]: + # May or may not exist in uid_to_resource + owner_to_children[o_uid].append(uid) + +# Find top-level resources +top_level = [] +for uid, res in uid_to_resource.items(): + if len(res["owners"]) == 0: + top_level.append(uid) + else: + # Check if all owners are known + all_known = True + for (_, _, o_uid) in res["owners"]: + if o_uid not in uid_to_resource: + all_known = False + break + if not all_known: + top_level.append(uid) + +# Group top-level resources by kind +kind_groups = defaultdict(list) +for uid in top_level: + r = uid_to_resource[uid] + if r["kind"] == "Event" and not SHOW_EVENTS: + # Skip events if no-events is true + continue + kind_groups[r["kind"]].append(uid) + +# We will create a pseudo-node for each kind that has top-level resources +# Named: KIND/(all s) +# Then list all those top-level resources under it. +# +# For example: +# Deployment/(all deployments) +# ├── Deployment/foo +# └── Deployment/bar +# +# If there is only one resource of a given kind, we still group it under that kind node for consistency. + +# We'll store these pseudo-nodes in uid_to_resource as well +pseudo_nodes = {} +for kind, uids in kind_groups.items(): + # Skip Events if SHOW_EVENTS is false + if kind == "Event" and not SHOW_EVENTS: + continue + + # Create a pseudo UID for the kind group node + pseudo_uid = f"PSEUDO_{kind.upper()}_NODE" + pseudo_nodes[kind] = pseudo_uid + uid_to_resource[pseudo_uid] = { + "kind": kind, + "name": f"(all {kind.lower()}s)", + "namespace": NAMESPACE, + "uid": pseudo_uid, + "owners": [] # top-level grouping node has no owners + } + + # The top-level resources of this kind become children of this pseudo-node + for child_uid in uids: + owner_to_children[pseudo_uid].append(child_uid) + +# Now our actual top-level nodes are these pseudo-nodes (one per kind) +top_level_kinds = list(pseudo_nodes.values()) + +# Sort these top-level kind nodes by their kind (and name) for stable output +def pseudo_sort_key(uid): + r = uid_to_resource[uid] + # The kind of this pseudo node is in r["kind"], and name is something like (all configmaps) + # Sorting by kind and name is sufficient. + return (r["kind"].lower(), r["name"].lower()) + +top_level_kinds.sort(key=pseudo_sort_key) + +# For printing the tree +def resource_sort_key(uid): + r = uid_to_resource[uid] + return (r["kind"].lower(), r["name"].lower()) + +def print_tree(uid, prefix="", is_last=True): + r = uid_to_resource[uid] + branch = "└── " if is_last else "├── " + print(prefix + branch + f"{r['kind']}/{r['name']}") + children = owner_to_children.get(uid, []) + children.sort(key=resource_sort_key) + child_prefix = prefix + (" " if is_last else "│ ") + for i, c_uid in enumerate(children): + print_tree(c_uid, prefix=child_prefix, is_last=(i == len(children)-1)) + +# Print all top-level kind groupings +for i, uid in enumerate(top_level_kinds): + print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) + print() \ No newline at end of file From 2f8f46bfc8da2f51c503c1ae1b10ad9b70b8e817 Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Fri, 6 Dec 2024 10:53:44 -0500 Subject: [PATCH 2/8] ownership_tree.py working with better output Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 55 +++++++++++++++++------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index 6999f34f6..30e3bc201 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -13,6 +13,24 @@ NAMESPACE = args.namespace SHOW_EVENTS = not args.no_events +# Build a mapping of Kind -> plural name from `kubectl api-resources` table output +kind_to_plural = {} +try: + all_resources_output = subprocess.check_output(["kubectl", "api-resources"], text=True).strip() + lines = all_resources_output.split('\n') + for line in lines[1:]: + parts = [p for p in line.split(' ') if p] + # NAME is first column, KIND is last column + if len(parts) < 2: + continue + plural_name = parts[0] + kind = parts[-1] + if kind not in kind_to_plural: + kind_to_plural[kind] = plural_name +except subprocess.CalledProcessError: + # If this fails, we just won't have any plural mapping + pass + # Get all namespaced resource types api_resources_cmd = ["kubectl", "api-resources", "--verbs=list", "--namespaced", "-o", "name"] resource_types = subprocess.check_output(api_resources_cmd, text=True).strip().split('\n') @@ -43,7 +61,6 @@ def get_resources_for_type(r_type): namespace = item["metadata"].get("namespace", NAMESPACE) owners = [(o["kind"], o["name"], o["uid"]) for o in item["metadata"].get("ownerReferences", [])] - # If --no-events and resource is an Event, skip adding it altogether if kind == "Event" and not SHOW_EVENTS: continue @@ -60,7 +77,6 @@ def get_resources_for_type(r_type): owner_to_children = defaultdict(list) for uid, res in uid_to_resource.items(): for (o_kind, o_name, o_uid) in res["owners"]: - # May or may not exist in uid_to_resource owner_to_children[o_uid].append(uid) # Find top-level resources @@ -69,7 +85,6 @@ def get_resources_for_type(r_type): if len(res["owners"]) == 0: top_level.append(uid) else: - # Check if all owners are known all_known = True for (_, _, o_uid) in res["owners"]: if o_uid not in uid_to_resource: @@ -83,56 +98,38 @@ def get_resources_for_type(r_type): for uid in top_level: r = uid_to_resource[uid] if r["kind"] == "Event" and not SHOW_EVENTS: - # Skip events if no-events is true continue kind_groups[r["kind"]].append(uid) -# We will create a pseudo-node for each kind that has top-level resources -# Named: KIND/(all s) -# Then list all those top-level resources under it. -# -# For example: -# Deployment/(all deployments) -# ├── Deployment/foo -# └── Deployment/bar -# -# If there is only one resource of a given kind, we still group it under that kind node for consistency. - -# We'll store these pseudo-nodes in uid_to_resource as well +# Create pseudo-nodes for each kind group pseudo_nodes = {} for kind, uids in kind_groups.items(): - # Skip Events if SHOW_EVENTS is false - if kind == "Event" and not SHOW_EVENTS: - continue - - # Create a pseudo UID for the kind group node + # Use cluster known plural if available, else fallback + plural = kind_to_plural.get(kind, kind.lower() + "s") + # Capitalize the plural to make it look nice as a "kind" name + # e.g. "configmaps" -> "Configmaps", "events" -> "Events" pseudo_uid = f"PSEUDO_{kind.upper()}_NODE" pseudo_nodes[kind] = pseudo_uid uid_to_resource[pseudo_uid] = { - "kind": kind, - "name": f"(all {kind.lower()}s)", + "kind": plural.capitalize(), + "name": f"(all {plural})", "namespace": NAMESPACE, "uid": pseudo_uid, - "owners": [] # top-level grouping node has no owners + "owners": [] } - # The top-level resources of this kind become children of this pseudo-node for child_uid in uids: owner_to_children[pseudo_uid].append(child_uid) # Now our actual top-level nodes are these pseudo-nodes (one per kind) top_level_kinds = list(pseudo_nodes.values()) -# Sort these top-level kind nodes by their kind (and name) for stable output def pseudo_sort_key(uid): r = uid_to_resource[uid] - # The kind of this pseudo node is in r["kind"], and name is something like (all configmaps) - # Sorting by kind and name is sufficient. return (r["kind"].lower(), r["name"].lower()) top_level_kinds.sort(key=pseudo_sort_key) -# For printing the tree def resource_sort_key(uid): r = uid_to_resource[uid] return (r["kind"].lower(), r["name"].lower()) From e24e20a1c47380823a8632e8c70c70bd3934b84b Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Fri, 6 Dec 2024 11:42:35 -0500 Subject: [PATCH 3/8] ownership_tree.py plurals working Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index 30e3bc201..8e2147652 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -13,14 +13,16 @@ NAMESPACE = args.namespace SHOW_EVENTS = not args.no_events -# Build a mapping of Kind -> plural name from `kubectl api-resources` table output +# Build a mapping of Kind -> plural name from `kubectl api-resources` output kind_to_plural = {} try: all_resources_output = subprocess.check_output(["kubectl", "api-resources"], text=True).strip() lines = all_resources_output.split('\n') + # Skip header line for line in lines[1:]: parts = [p for p in line.split(' ') if p] # NAME is first column, KIND is last column + # We need at least NAME and KIND if len(parts) < 2: continue plural_name = parts[0] @@ -51,7 +53,7 @@ def get_resources_for_type(r_type): return [] return data["items"] -# Collect all resources into uid_to_resource +# Collect all resources for r_type in resource_types: items = get_resources_for_type(r_type) for item in items: @@ -73,13 +75,12 @@ def get_resources_for_type(r_type): } all_uids.add(uid) -# Build a map of owner_uid -> [child_uids] owner_to_children = defaultdict(list) for uid, res in uid_to_resource.items(): for (o_kind, o_name, o_uid) in res["owners"]: owner_to_children[o_uid].append(uid) -# Find top-level resources +# Identify top-level resources top_level = [] for uid, res in uid_to_resource.items(): if len(res["owners"]) == 0: @@ -101,18 +102,20 @@ def get_resources_for_type(r_type): continue kind_groups[r["kind"]].append(uid) -# Create pseudo-nodes for each kind group +# Create pseudo-nodes for each kind group, using the plural form if available pseudo_nodes = {} for kind, uids in kind_groups.items(): - # Use cluster known plural if available, else fallback + if kind == "Event" and not SHOW_EVENTS: + continue + plural = kind_to_plural.get(kind, kind.lower() + "s") - # Capitalize the plural to make it look nice as a "kind" name - # e.g. "configmaps" -> "Configmaps", "events" -> "Events" pseudo_uid = f"PSEUDO_{kind.upper()}_NODE" pseudo_nodes[kind] = pseudo_uid uid_to_resource[pseudo_uid] = { + # Use the plural form, capitalized, as the "kind" "kind": plural.capitalize(), - "name": f"(all {plural})", + # Empty name so we don't print "(all ...)" + "name": "", "namespace": NAMESPACE, "uid": pseudo_uid, "owners": [] @@ -121,7 +124,6 @@ def get_resources_for_type(r_type): for child_uid in uids: owner_to_children[pseudo_uid].append(child_uid) -# Now our actual top-level nodes are these pseudo-nodes (one per kind) top_level_kinds = list(pseudo_nodes.values()) def pseudo_sort_key(uid): @@ -137,7 +139,11 @@ def resource_sort_key(uid): def print_tree(uid, prefix="", is_last=True): r = uid_to_resource[uid] branch = "└── " if is_last else "├── " - print(prefix + branch + f"{r['kind']}/{r['name']}") + # If name is empty, just print kind (which is pluralized) + if r['name']: + print(prefix + branch + f"{r['kind']}/{r['name']}") + else: + print(prefix + branch + f"{r['kind']}") children = owner_to_children.get(uid, []) children.sort(key=resource_sort_key) child_prefix = prefix + (" " if is_last else "│ ") From d1e817d2eba01951bc5c41c49ac2f7d465c91fae Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Fri, 6 Dec 2024 11:46:57 -0500 Subject: [PATCH 4/8] ownership_tree.py good output Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index 8e2147652..1ef10a061 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -152,5 +152,4 @@ def print_tree(uid, prefix="", is_last=True): # Print all top-level kind groupings for i, uid in enumerate(top_level_kinds): - print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) - print() \ No newline at end of file + print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) \ No newline at end of file From 866fbfbb27484faed49ff06992849f618e26b7ce Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Fri, 6 Dec 2024 14:06:32 -0500 Subject: [PATCH 5/8] ownership_tree.py good output now with ClusterExtension type resources included Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 124 ++++++++++++++++++++++++----------- 1 file changed, 86 insertions(+), 38 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index 1ef10a061..b046bbcd0 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -5,7 +5,7 @@ import argparse from collections import defaultdict -parser = argparse.ArgumentParser(description="Print a tree of ownership for all resources in a namespace, grouped by kind.") +parser = argparse.ArgumentParser(description="Print a tree of ownership for all resources in a namespace, including cluster-scoped ones that reference the namespace.") parser.add_argument("namespace", help="The namespace to inspect") parser.add_argument("--no-events", action="store_true", help="Do not show Events kind grouping") args = parser.parse_args() @@ -13,63 +13,117 @@ NAMESPACE = args.namespace SHOW_EVENTS = not args.no_events -# Build a mapping of Kind -> plural name from `kubectl api-resources` output +def parse_api_resources_line(line): + parts = [p for p in line.split(' ') if p] + if len(parts) < 3: + return None + # KIND is last + kind = parts[-1] + # NAMESPACED is second-last + namespaced_str = parts[-2].lower() + namespaced = (namespaced_str == "true") + # NAME is first + name = parts[0] + # Middle columns could be SHORTNAMES and APIVERSION + # If len(middle) == 1: NAME APIVERSION NAMESPACED KIND + # If len(middle) == 2: NAME SHORTNAMES APIVERSION NAMESPACED KIND + middle = parts[1:-2] + # We don't need these explicitly for logic now, just handle parsing consistently + # APIVERSION unused directly, just ensuring correct parse. + return name, "", "", namespaced, kind + +# Gather resource info kind_to_plural = {} +resource_info = [] + try: all_resources_output = subprocess.check_output(["kubectl", "api-resources"], text=True).strip() lines = all_resources_output.split('\n') - # Skip header line + for line in lines[1:]: - parts = [p for p in line.split(' ') if p] - # NAME is first column, KIND is last column - # We need at least NAME and KIND - if len(parts) < 2: + if not line.strip(): + continue + parsed = parse_api_resources_line(line) + if not parsed: continue - plural_name = parts[0] - kind = parts[-1] + name, _, _, is_namespaced, kind = parsed if kind not in kind_to_plural: - kind_to_plural[kind] = plural_name + kind_to_plural[kind] = name + resource_info.append((kind, name, is_namespaced)) + except subprocess.CalledProcessError: - # If this fails, we just won't have any plural mapping pass -# Get all namespaced resource types -api_resources_cmd = ["kubectl", "api-resources", "--verbs=list", "--namespaced", "-o", "name"] -resource_types = subprocess.check_output(api_resources_cmd, text=True).strip().split('\n') - uid_to_resource = {} all_uids = set() -def get_resources_for_type(r_type): +def get_resources_for_type(resource_name, namespaced): + if namespaced: + cmd = ["kubectl", "get", resource_name, "-n", NAMESPACE, "-o", "json", "--ignore-not-found"] + else: + cmd = ["kubectl", "get", resource_name, "-o", "json", "--ignore-not-found"] + try: - items_json = subprocess.check_output( - ["kubectl", "get", r_type, "-n", NAMESPACE, "-o", "json"], - text=True - ) + items_json = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: return [] + if not items_json.strip(): + return [] + data = json.loads(items_json) if "items" not in data: return [] - return data["items"] -# Collect all resources -for r_type in resource_types: - items = get_resources_for_type(r_type) + items = data["items"] + + if namespaced: + return items + + # Cluster-scoped: filter by namespace reference + filtered = [] + for item in items: + meta_ns = item.get("metadata", {}).get("namespace") + spec_ns = item.get("spec", {}).get("namespace") + if meta_ns == NAMESPACE or spec_ns == NAMESPACE: + filtered.append(item) + + if filtered: + return filtered + + # Fallback: try get by name if no filtered items + try: + single_json = subprocess.check_output( + ["kubectl", "get", resource_name, NAMESPACE, "-o", "json", "--ignore-not-found"], + text=True, stderr=subprocess.DEVNULL + ) + if single_json.strip(): + single_data = json.loads(single_json) + if "kind" in single_data and "metadata" in single_data: + meta_ns = single_data.get("metadata", {}).get("namespace") + spec_ns = single_data.get("spec", {}).get("namespace") + if meta_ns == NAMESPACE or spec_ns == NAMESPACE: + filtered.append(single_data) + except subprocess.CalledProcessError: + pass + + return filtered + +# Collect resources +for (kind, plural_name, is_namespaced) in resource_info: + items = get_resources_for_type(plural_name, is_namespaced) for item in items: uid = item["metadata"]["uid"] - kind = item["kind"] - name = item["metadata"]["name"] - namespace = item["metadata"].get("namespace", NAMESPACE) + k = item["kind"] + nm = item["metadata"]["name"] owners = [(o["kind"], o["name"], o["uid"]) for o in item["metadata"].get("ownerReferences", [])] - if kind == "Event" and not SHOW_EVENTS: + if k == "Event" and not SHOW_EVENTS: continue uid_to_resource[uid] = { - "kind": kind, - "name": name, - "namespace": namespace, + "kind": k, + "name": nm, + "namespace": NAMESPACE, "uid": uid, "owners": owners } @@ -80,7 +134,7 @@ def get_resources_for_type(r_type): for (o_kind, o_name, o_uid) in res["owners"]: owner_to_children[o_uid].append(uid) -# Identify top-level resources +# Identify top-level top_level = [] for uid, res in uid_to_resource.items(): if len(res["owners"]) == 0: @@ -94,7 +148,6 @@ def get_resources_for_type(r_type): if not all_known: top_level.append(uid) -# Group top-level resources by kind kind_groups = defaultdict(list) for uid in top_level: r = uid_to_resource[uid] @@ -102,7 +155,6 @@ def get_resources_for_type(r_type): continue kind_groups[r["kind"]].append(uid) -# Create pseudo-nodes for each kind group, using the plural form if available pseudo_nodes = {} for kind, uids in kind_groups.items(): if kind == "Event" and not SHOW_EVENTS: @@ -112,9 +164,7 @@ def get_resources_for_type(r_type): pseudo_uid = f"PSEUDO_{kind.upper()}_NODE" pseudo_nodes[kind] = pseudo_uid uid_to_resource[pseudo_uid] = { - # Use the plural form, capitalized, as the "kind" "kind": plural.capitalize(), - # Empty name so we don't print "(all ...)" "name": "", "namespace": NAMESPACE, "uid": pseudo_uid, @@ -139,7 +189,6 @@ def resource_sort_key(uid): def print_tree(uid, prefix="", is_last=True): r = uid_to_resource[uid] branch = "└── " if is_last else "├── " - # If name is empty, just print kind (which is pluralized) if r['name']: print(prefix + branch + f"{r['kind']}/{r['name']}") else: @@ -150,6 +199,5 @@ def print_tree(uid, prefix="", is_last=True): for i, c_uid in enumerate(children): print_tree(c_uid, prefix=child_prefix, is_last=(i == len(children)-1)) -# Print all top-level kind groupings for i, uid in enumerate(top_level_kinds): print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) \ No newline at end of file From d54170a6ec8441711c12f5b2af82a6f507b80184 Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Fri, 6 Dec 2024 14:10:28 -0500 Subject: [PATCH 6/8] with event message info option Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 56 ++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index b046bbcd0..cf6096c60 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -8,10 +8,12 @@ parser = argparse.ArgumentParser(description="Print a tree of ownership for all resources in a namespace, including cluster-scoped ones that reference the namespace.") parser.add_argument("namespace", help="The namespace to inspect") parser.add_argument("--no-events", action="store_true", help="Do not show Events kind grouping") +parser.add_argument("--with-event-info", action="store_true", help="Show additional info (message) for Events") args = parser.parse_args() NAMESPACE = args.namespace SHOW_EVENTS = not args.no_events +WITH_EVENT_INFO = args.with_event_info def parse_api_resources_line(line): parts = [p for p in line.split(' ') if p] @@ -24,15 +26,9 @@ def parse_api_resources_line(line): namespaced = (namespaced_str == "true") # NAME is first name = parts[0] - # Middle columns could be SHORTNAMES and APIVERSION - # If len(middle) == 1: NAME APIVERSION NAMESPACED KIND - # If len(middle) == 2: NAME SHORTNAMES APIVERSION NAMESPACED KIND - middle = parts[1:-2] - # We don't need these explicitly for logic now, just handle parsing consistently - # APIVERSION unused directly, just ensuring correct parse. - return name, "", "", namespaced, kind - -# Gather resource info + # We don't need SHORTNAMES/APIVERSION for the tree logic. + return name, namespaced, kind + kind_to_plural = {} resource_info = [] @@ -43,14 +39,18 @@ def parse_api_resources_line(line): for line in lines[1:]: if not line.strip(): continue - parsed = parse_api_resources_line(line) - if not parsed: + parts = [p for p in line.split(' ') if p] + if len(parts) < 3: continue - name, _, _, is_namespaced, kind = parsed + # Parse from right: kind=last, namespaced=second-last, name=first + kind = parts[-1] + namespaced_str = parts[-2].lower() + namespaced = (namespaced_str == "true") + name = parts[0] + if kind not in kind_to_plural: kind_to_plural[kind] = name - resource_info.append((kind, name, is_namespaced)) - + resource_info.append((kind, name, namespaced)) except subprocess.CalledProcessError: pass @@ -110,6 +110,10 @@ def get_resources_for_type(resource_name, namespaced): # Collect resources for (kind, plural_name, is_namespaced) in resource_info: + # Skip events if we don't show them at all + if kind == "Event" and not SHOW_EVENTS: + continue + items = get_resources_for_type(plural_name, is_namespaced) for item in items: uid = item["metadata"]["uid"] @@ -117,16 +121,23 @@ def get_resources_for_type(resource_name, namespaced): nm = item["metadata"]["name"] owners = [(o["kind"], o["name"], o["uid"]) for o in item["metadata"].get("ownerReferences", [])] + # If it's an Event and we don't show events, skip if k == "Event" and not SHOW_EVENTS: continue - uid_to_resource[uid] = { + res_entry = { "kind": k, "name": nm, "namespace": NAMESPACE, "uid": uid, "owners": owners } + + # If it's an Event and we want event info, store the message + if k == "Event" and WITH_EVENT_INFO: + res_entry["message"] = item.get("message", "") + + uid_to_resource[uid] = res_entry all_uids.add(uid) owner_to_children = defaultdict(list) @@ -159,7 +170,6 @@ def get_resources_for_type(resource_name, namespaced): for kind, uids in kind_groups.items(): if kind == "Event" and not SHOW_EVENTS: continue - plural = kind_to_plural.get(kind, kind.lower() + "s") pseudo_uid = f"PSEUDO_{kind.upper()}_NODE" pseudo_nodes[kind] = pseudo_uid @@ -170,7 +180,6 @@ def get_resources_for_type(resource_name, namespaced): "uid": pseudo_uid, "owners": [] } - for child_uid in uids: owner_to_children[pseudo_uid].append(child_uid) @@ -189,10 +198,15 @@ def resource_sort_key(uid): def print_tree(uid, prefix="", is_last=True): r = uid_to_resource[uid] branch = "└── " if is_last else "├── " - if r['name']: - print(prefix + branch + f"{r['kind']}/{r['name']}") - else: - print(prefix + branch + f"{r['kind']}") + print(prefix + branch + f"{r['kind']}" + (f"/{r['name']}" if r['name'] else "")) + + # If Event and we want message info + if WITH_EVENT_INFO and r['kind'] == "Event" and "message" in r: + # Print event message as a child line + child_prefix = prefix + (" " if is_last else "│ ") + # message line + print(child_prefix + "└── message: " + r["message"]) + children = owner_to_children.get(uid, []) children.sort(key=resource_sort_key) child_prefix = prefix + (" " if is_last else "│ ") From 3a3f663fca490ce78b16fd4b77526826528d4b5d Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Sat, 7 Dec 2024 10:02:59 -0500 Subject: [PATCH 7/8] Working, prompting in place, not active Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 243 ++++++++++++++++++++++++++++++----- 1 file changed, 209 insertions(+), 34 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index cf6096c60..de34e0bb9 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -1,32 +1,39 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3.11 import json +import os +import openai import subprocess import sys import argparse from collections import defaultdict -parser = argparse.ArgumentParser(description="Print a tree of ownership for all resources in a namespace, including cluster-scoped ones that reference the namespace.") +parser = argparse.ArgumentParser(description="Print a tree of ownership for all resources in a namespace, optionally gather cluster extension state.") parser.add_argument("namespace", help="The namespace to inspect") parser.add_argument("--no-events", action="store_true", help="Do not show Events kind grouping") parser.add_argument("--with-event-info", action="store_true", help="Show additional info (message) for Events") +parser.add_argument("--gather-cluster-extension-state", action="store_true", + help="Gather and save a compressed fingerprint of the cluster extension state to a file.") +parser.add_argument("--no-tree", action="store_true", help="Do not print the tree output (only used if gather-cluster-extension-state is set).") args = parser.parse_args() NAMESPACE = args.namespace -SHOW_EVENTS = not args.no_events -WITH_EVENT_INFO = args.with_event_info + +# If gather-cluster-extension-state is used, we want full info regardless of other flags +if args.gather_cluster_extension_state: + SHOW_EVENTS = True + WITH_EVENT_INFO = True +else: + SHOW_EVENTS = not args.no_events + WITH_EVENT_INFO = args.with_event_info def parse_api_resources_line(line): parts = [p for p in line.split(' ') if p] if len(parts) < 3: return None - # KIND is last kind = parts[-1] - # NAMESPACED is second-last namespaced_str = parts[-2].lower() namespaced = (namespaced_str == "true") - # NAME is first name = parts[0] - # We don't need SHORTNAMES/APIVERSION for the tree logic. return name, namespaced, kind kind_to_plural = {} @@ -39,18 +46,13 @@ def parse_api_resources_line(line): for line in lines[1:]: if not line.strip(): continue - parts = [p for p in line.split(' ') if p] - if len(parts) < 3: + parsed = parse_api_resources_line(line) + if not parsed: continue - # Parse from right: kind=last, namespaced=second-last, name=first - kind = parts[-1] - namespaced_str = parts[-2].lower() - namespaced = (namespaced_str == "true") - name = parts[0] - + name, is_namespaced, kind = parsed if kind not in kind_to_plural: kind_to_plural[kind] = name - resource_info.append((kind, name, namespaced)) + resource_info.append((kind, name, is_namespaced)) except subprocess.CalledProcessError: pass @@ -79,7 +81,7 @@ def get_resources_for_type(resource_name, namespaced): if namespaced: return items - # Cluster-scoped: filter by namespace reference + # cluster-scoped: filter by namespace reference filtered = [] for item in items: meta_ns = item.get("metadata", {}).get("namespace") @@ -90,7 +92,7 @@ def get_resources_for_type(resource_name, namespaced): if filtered: return filtered - # Fallback: try get by name if no filtered items + # fallback by name try: single_json = subprocess.check_output( ["kubectl", "get", resource_name, NAMESPACE, "-o", "json", "--ignore-not-found"], @@ -110,10 +112,9 @@ def get_resources_for_type(resource_name, namespaced): # Collect resources for (kind, plural_name, is_namespaced) in resource_info: - # Skip events if we don't show them at all + # If we are gathering CE state or SHOW_EVENTS is True, we process events, else skip if no events if kind == "Event" and not SHOW_EVENTS: continue - items = get_resources_for_type(plural_name, is_namespaced) for item in items: uid = item["metadata"]["uid"] @@ -121,7 +122,6 @@ def get_resources_for_type(resource_name, namespaced): nm = item["metadata"]["name"] owners = [(o["kind"], o["name"], o["uid"]) for o in item["metadata"].get("ownerReferences", [])] - # If it's an Event and we don't show events, skip if k == "Event" and not SHOW_EVENTS: continue @@ -133,7 +133,6 @@ def get_resources_for_type(resource_name, namespaced): "owners": owners } - # If it's an Event and we want event info, store the message if k == "Event" and WITH_EVENT_INFO: res_entry["message"] = item.get("message", "") @@ -142,7 +141,7 @@ def get_resources_for_type(resource_name, namespaced): owner_to_children = defaultdict(list) for uid, res in uid_to_resource.items(): - for (o_kind, o_name, o_uid) in res["owners"]: + for (_, _, o_uid) in res["owners"]: owner_to_children[o_uid].append(uid) # Identify top-level @@ -167,7 +166,7 @@ def get_resources_for_type(resource_name, namespaced): kind_groups[r["kind"]].append(uid) pseudo_nodes = {} -for kind, uids in kind_groups.items(): +for kind, uids_ in kind_groups.items(): if kind == "Event" and not SHOW_EVENTS: continue plural = kind_to_plural.get(kind, kind.lower() + "s") @@ -180,7 +179,7 @@ def get_resources_for_type(resource_name, namespaced): "uid": pseudo_uid, "owners": [] } - for child_uid in uids: + for child_uid in uids_: owner_to_children[pseudo_uid].append(child_uid) top_level_kinds = list(pseudo_nodes.values()) @@ -198,20 +197,196 @@ def resource_sort_key(uid): def print_tree(uid, prefix="", is_last=True): r = uid_to_resource[uid] branch = "└── " if is_last else "├── " - print(prefix + branch + f"{r['kind']}" + (f"/{r['name']}" if r['name'] else "")) - - # If Event and we want message info + if r['name']: + print(prefix + branch + f"{r['kind']}/{r['name']}") + else: + print(prefix + branch + f"{r['kind']}") if WITH_EVENT_INFO and r['kind'] == "Event" and "message" in r: - # Print event message as a child line child_prefix = prefix + (" " if is_last else "│ ") - # message line print(child_prefix + "└── message: " + r["message"]) - children = owner_to_children.get(uid, []) children.sort(key=resource_sort_key) child_prefix = prefix + (" " if is_last else "│ ") for i, c_uid in enumerate(children): print_tree(c_uid, prefix=child_prefix, is_last=(i == len(children)-1)) -for i, uid in enumerate(top_level_kinds): - print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) \ No newline at end of file + +############################### +# Code for gather fingerprint +############################### +def extract_resource_summary(kind, name, namespace): + is_namespaced = (namespace is not None and namespace != "") + cmd = ["kubectl", "get", kind.lower()+"/"+name] + if is_namespaced: + cmd.extend(["-n", namespace]) + cmd.extend(["-o", "json", "--ignore-not-found"]) + + try: + out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL) + if not out.strip(): + return {} + data = json.loads(out) + except subprocess.CalledProcessError: + return {} + + summary = { + "kind": data.get("kind", kind), + "name": data.get("metadata", {}).get("name", name), + "namespace": data.get("metadata", {}).get("namespace", namespace) + } + + conditions = data.get("status", {}).get("conditions", []) + if conditions: + summary["conditions"] = [ + { + "type": c.get("type"), + "status": c.get("status"), + "reason": c.get("reason"), + "message": c.get("message") + } for c in conditions + ] + + # For pods/deployments, extract container images + if data.get("kind") in ["Pod", "Deployment"]: + images = [] + if data["kind"] == "Pod": + containers = data.get("spec", {}).get("containers", []) + for cont in containers: + images.append({"name": cont.get("name"), "image": cont.get("image")}) + elif data["kind"] == "Deployment": + containers = data.get("spec", {}).get("template", {}).get("spec", {}).get("containers", []) + for cont in containers: + images.append({"name": cont.get("name"), "image": cont.get("image")}) + if images: + summary["containers"] = images + + # For Events, show reason and message + if data.get("kind") == "Event": + summary["reason"] = data.get("reason") + summary["message"] = data.get("message") + + metadata = data.get("metadata", {}) + if metadata.get("labels"): + summary["labels"] = metadata["labels"] + if metadata.get("annotations"): + summary["annotations"] = metadata["annotations"] + + return summary + +def load_fingerprint(file_path): + """Load the JSON fingerprint file from the specified path.""" + with open(file_path, 'r') as f: + return json.load(f) + +def generate_prompt(fingerprint): + """Generate the diagnostic prompt by embedding the fingerprint into the request.""" + prompt = """ +You are an expert in Kubernetes operations and diagnostics. I will provide you with a JSON file that represents a snapshot ("fingerprint") of the entire state of a Kubernetes namespace focusing on a particular ClusterExtension and all related resources. This fingerprint includes: + +- The ClusterExtension itself. +- All resources in the namespace that are either owned by or possibly needed by the ClusterExtension. +- Key details such as resource conditions, event messages, container images (with references), and minimal metadata. + +Your task is: +1. Analyze the provided fingerprint to determine if there are any issues with the ClusterExtension, its related resources, or its configuration. +2. If issues are found, provide a diagnosis of what might be wrong and suggest steps to fix them. +3. If no issues appear, acknowledge that the ClusterExtension and its resources seem healthy. +4. Keep your answer concise and action-focused, as the output will be used by a human operator to troubleshoot or confirm the health of their cluster. + +**Important Details:** +- The fingerprint might contain events that show what happened in the cluster recently. +- Check conditions of deployments, pods, and other resources to see if they indicate errors or warnings. +- Look at event messages for hints about failures, restarts, or other anomalies. +- Consider if all necessary resources (like ServiceAccounts, ConfigMaps, or other dependencies) are present and seemingly functional. + +**BEGIN FINGERPRINT** +{fingerprint} +**END FINGERPRINT** + +Please provide a summarized diagnosis and suggested fixes below: + """.format(fingerprint=json.dumps(fingerprint, indent=2)) + return prompt + +def send_to_openai(prompt, model="gpt-4o"): + """Send the prompt to OpenAI's completions API and get the response.""" + try: + openai.api_key = os.getenv("OPENAI_API_KEY") + if not openai.api_key: + raise ValueError("OPENAI_API_KEY environment variable is not set.") + + response = openai.ChatCompletion.create( + model=model, + messages=[{"role": "user", "content": prompt}] + ) + + # Extract and return the assistant's message + message_content = response['choices'][0]['message']['content'] + return message_content + + except Exception as e: + return f"Error communicating with OpenAI API: {e}" + +def gather_fingerprint(namespace): + # Find cluster extension(s) + ce_uids = [uid for uid, res in uid_to_resource.items() if res["kind"] == "ClusterExtension" and res["namespace"] == namespace] + if not ce_uids: + return [] + + all_images = {} + image_ref_count = 0 + + def process_resource(uid): + nonlocal image_ref_count + r = uid_to_resource[uid] + k = r["kind"] + nm = r["name"] + ns = r["namespace"] + summary = extract_resource_summary(k, nm, ns) + # Deduplicate images + if "containers" in summary: + new_containers = [] + for c in summary["containers"]: + img = c["image"] + if img not in all_images: + image_ref_count += 1 + ref_name = f"image_ref_{image_ref_count}" + all_images[img] = ref_name + c["imageRef"] = all_images[img] + del c["image"] + new_containers.append(c) + summary["containers"] = new_containers + return summary + + results = [] + for ce_uid in ce_uids: + fingerprint = {} + # Include all discovered resources + for uid in uid_to_resource: + r = uid_to_resource[uid] + key = f"{r['kind']}/{r['name']}" + fp = process_resource(uid) + fingerprint[key] = fp + if all_images: + fingerprint["_image_map"] = {v: k for k, v in all_images.items()} + ce_name = uid_to_resource[ce_uid]["name"] + fname = f"{ce_name}-state.json" + with open(fname, "w") as f: + json.dump(fingerprint, f, indent=2) + results.append(fname) + return results + +# If gather-cluster-extension-state, generate state file(s) +state_files = [] +if args.gather_cluster_extension_state: + state_files = gather_fingerprint(NAMESPACE) + +# Print tree unless --no-tree is given AND we are in gather-cluster-extension-state mode +if not (args.gather_cluster_extension_state and args.no_tree): + for i, uid in enumerate(top_level_kinds): + print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) + +if args.gather_cluster_extension_state: + if not state_files: + print("No ClusterExtension found in the namespace, no state file created.", file=sys.stderr) + else: + print("Created state file(s):", ", ".join(state_files)) \ No newline at end of file From c879510833166371d1c15fc25db36e4b4ef56a24 Mon Sep 17 00:00:00 2001 From: Brett Tofel Date: Sat, 7 Dec 2024 10:19:52 -0500 Subject: [PATCH 8/8] Working, prompting working Signed-off-by: Brett Tofel --- hack/tools/ownership_tree.py | 52 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/hack/tools/ownership_tree.py b/hack/tools/ownership_tree.py index de34e0bb9..e4d957364 100755 --- a/hack/tools/ownership_tree.py +++ b/hack/tools/ownership_tree.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3.11 import json import os -import openai +from openai import OpenAI + +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) import subprocess import sys import argparse @@ -14,11 +16,15 @@ parser.add_argument("--gather-cluster-extension-state", action="store_true", help="Gather and save a compressed fingerprint of the cluster extension state to a file.") parser.add_argument("--no-tree", action="store_true", help="Do not print the tree output (only used if gather-cluster-extension-state is set).") +parser.add_argument("--prompt", action="store_true", help="Create the fingerprint file (if needed) and send it to OpenAI for diagnosis.") args = parser.parse_args() NAMESPACE = args.namespace -# If gather-cluster-extension-state is used, we want full info regardless of other flags +# If --prompt is used, we also want full info and to gather fingerprint, regardless of other flags +if args.prompt: + args.gather_cluster_extension_state = True + if args.gather_cluster_extension_state: SHOW_EVENTS = True WITH_EVENT_INFO = True @@ -112,7 +118,6 @@ def get_resources_for_type(resource_name, namespaced): # Collect resources for (kind, plural_name, is_namespaced) in resource_info: - # If we are gathering CE state or SHOW_EVENTS is True, we process events, else skip if no events if kind == "Event" and not SHOW_EVENTS: continue items = get_resources_for_type(plural_name, is_namespaced) @@ -139,6 +144,7 @@ def get_resources_for_type(resource_name, namespaced): uid_to_resource[uid] = res_entry all_uids.add(uid) +from collections import defaultdict owner_to_children = defaultdict(list) for uid, res in uid_to_resource.items(): for (_, _, o_uid) in res["owners"]: @@ -211,9 +217,6 @@ def print_tree(uid, prefix="", is_last=True): print_tree(c_uid, prefix=child_prefix, is_last=(i == len(children)-1)) -############################### -# Code for gather fingerprint -############################### def extract_resource_summary(kind, name, namespace): is_namespaced = (namespace is not None and namespace != "") cmd = ["kubectl", "get", kind.lower()+"/"+name] @@ -274,12 +277,10 @@ def extract_resource_summary(kind, name, namespace): return summary def load_fingerprint(file_path): - """Load the JSON fingerprint file from the specified path.""" with open(file_path, 'r') as f: return json.load(f) def generate_prompt(fingerprint): - """Generate the diagnostic prompt by embedding the fingerprint into the request.""" prompt = """ You are an expert in Kubernetes operations and diagnostics. I will provide you with a JSON file that represents a snapshot ("fingerprint") of the entire state of a Kubernetes namespace focusing on a particular ClusterExtension and all related resources. This fingerprint includes: @@ -308,26 +309,17 @@ def generate_prompt(fingerprint): return prompt def send_to_openai(prompt, model="gpt-4o"): - """Send the prompt to OpenAI's completions API and get the response.""" try: - openai.api_key = os.getenv("OPENAI_API_KEY") - if not openai.api_key: + if os.getenv("OPENAI_API_KEY") is None: raise ValueError("OPENAI_API_KEY environment variable is not set.") - - response = openai.ChatCompletion.create( - model=model, - messages=[{"role": "user", "content": prompt}] - ) - - # Extract and return the assistant's message - message_content = response['choices'][0]['message']['content'] + response = client.chat.completions.create(model=model, + messages=[{"role": "user", "content": prompt}]) + message_content = response.choices[0].message.content return message_content - except Exception as e: return f"Error communicating with OpenAI API: {e}" def gather_fingerprint(namespace): - # Find cluster extension(s) ce_uids = [uid for uid, res in uid_to_resource.items() if res["kind"] == "ClusterExtension" and res["namespace"] == namespace] if not ce_uids: return [] @@ -342,7 +334,6 @@ def process_resource(uid): nm = r["name"] ns = r["namespace"] summary = extract_resource_summary(k, nm, ns) - # Deduplicate images if "containers" in summary: new_containers = [] for c in summary["containers"]: @@ -360,7 +351,6 @@ def process_resource(uid): results = [] for ce_uid in ce_uids: fingerprint = {} - # Include all discovered resources for uid in uid_to_resource: r = uid_to_resource[uid] key = f"{r['kind']}/{r['name']}" @@ -375,12 +365,10 @@ def process_resource(uid): results.append(fname) return results -# If gather-cluster-extension-state, generate state file(s) state_files = [] if args.gather_cluster_extension_state: state_files = gather_fingerprint(NAMESPACE) -# Print tree unless --no-tree is given AND we are in gather-cluster-extension-state mode if not (args.gather_cluster_extension_state and args.no_tree): for i, uid in enumerate(top_level_kinds): print_tree(uid, prefix="", is_last=(i == len(top_level_kinds)-1)) @@ -389,4 +377,16 @@ def process_resource(uid): if not state_files: print("No ClusterExtension found in the namespace, no state file created.", file=sys.stderr) else: - print("Created state file(s):", ", ".join(state_files)) \ No newline at end of file + print("Created state file(s):", ", ".join(state_files)) + +# If --prompt is used, we already created the fingerprint file. Now load and send to OpenAI. +if args.prompt: + if not state_files: + print("No ClusterExtension found, cannot prompt OpenAI.", file=sys.stderr) + sys.exit(1) + # Assume one ClusterExtension, take the first file + fingerprint_data = load_fingerprint(state_files[0]) + prompt = generate_prompt(fingerprint_data) + response = send_to_openai(prompt) + print("\n--- OpenAI Diagnosis ---\n") + print(response) \ No newline at end of file