kugl 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kugl/__init__.py ADDED
File without changes
kugl/api.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Imports usable by user-defined tables in Python (once we have those.)
3
+ """
4
+
5
+ from kugl.impl.registry import Registry
6
+
7
+ from kugl.util import (
8
+ fail,
9
+ parse_age,
10
+ parse_utc,
11
+ to_age,
12
+ to_utc,
13
+ )
14
+
15
+
16
+ def schema(name: str):
17
+ def wrap(cls):
18
+ Registry.get().add_schema(name, cls)
19
+ return cls
20
+ return wrap
21
+
22
+
23
+ def table(**kwargs):
24
+ def wrap(cls):
25
+ Registry.get().add_table(cls, **kwargs)
26
+ return cls
27
+ return wrap
28
+
File without changes
@@ -0,0 +1,156 @@
1
+ """
2
+ Wrappers to make JSON returned by kubectl easier to work with.
3
+ """
4
+
5
+ from abc import abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ import funcy as fn
10
+
11
+ from kugl.util import parse_size, parse_cpu
12
+
13
+ # What container name is considered the "main" container, if present
14
+ MAIN_CONTAINERS = ["main", "notebook", "app"]
15
+
16
+
17
+ @dataclass
18
+ class Limits:
19
+ """
20
+ A class to hold CPU, GPU and memory resources. This is called "Limits" although it's used for both requests
21
+ and limits, so as not to confuse "resources" with Kubernetes resources in general.
22
+ """
23
+ cpu: Optional[float]
24
+ gpu: Optional[float]
25
+ mem: Optional[int]
26
+
27
+ def __add__(self, other):
28
+ if self.cpu is None and other.cpu is None:
29
+ cpu = None
30
+ else:
31
+ cpu = (self.cpu or 0) + (other.cpu or 0)
32
+ if self.gpu is None and other.gpu is None:
33
+ gpu = None
34
+ else:
35
+ gpu = (self.gpu or 0) + (other.gpu or 0)
36
+ if self.mem is None and other.mem is None:
37
+ mem = None
38
+ else:
39
+ mem = (self.mem or 0) + (other.mem or 0)
40
+ return Limits(cpu, gpu, mem)
41
+
42
+ def __radd__(self, other):
43
+ """Needed to support sum() -- handles 0 as a starting value"""
44
+ return self if other == 0 else self.__add__(other)
45
+
46
+ def as_tuple(self):
47
+ return (self.cpu, self.gpu, self.mem)
48
+
49
+ @classmethod
50
+ def extract(cls, obj):
51
+ """Extract a Limits object from a dictionary, or return an empty one if the dictionary is None.
52
+
53
+ :param obj: A dictionary with keys "cpu", "nvidia.com/gpu" and "memory" """
54
+ if obj is None:
55
+ return Limits(None, None, None)
56
+ cpu = parse_cpu(obj.get("cpu"))
57
+ gpu = parse_cpu(obj.get("nvidia.com/gpu"))
58
+ mem = parse_size(obj.get("memory"))
59
+ return Limits(cpu, gpu, mem)
60
+
61
+
62
+ class ItemHelper:
63
+ """Some common code for wrappers on JSON for pods, nodes et cetera."""
64
+
65
+ def __init__(self, obj):
66
+ self.obj = obj
67
+ self.metadata = self.obj.get("metadata", {})
68
+ self.labels = self.metadata.get("labels", {})
69
+
70
+ def __getitem__(self, key):
71
+ """Return a key from the object; no default, will error if not present"""
72
+ return self.obj[key]
73
+
74
+ @property
75
+ def name(self):
76
+ """Return the name of the object from the metadata, or none if unavailable."""
77
+ return self.metadata.get("name") or self.obj.get("name")
78
+
79
+ @property
80
+ def namespace(self):
81
+ """Return the name of the object from the metadata, or none if unavailable."""
82
+ return self.metadata.get("namespace")
83
+
84
+ def label(self, name):
85
+ """Return one of the labels from the object, or None if it doesn't have that label."""
86
+ return self.labels.get(name)
87
+
88
+
89
+ class Containerized:
90
+
91
+ @abstractmethod
92
+ def containers(self):
93
+ raise NotImplementedError()
94
+
95
+ def resources(self, tag):
96
+ return sum(Limits.extract(c.get("resources", {}).get(tag)) for c in self.containers)
97
+
98
+
99
+ class PodHelper(ItemHelper, Containerized):
100
+
101
+ @property
102
+ def command(self):
103
+ return " ".join((self.main or {}).get("command", []))
104
+
105
+ @property
106
+ def is_daemon(self):
107
+ return any(ref.get("kind") == "DaemonSet" for ref in self.metadata.get("ownerReferences", []))
108
+
109
+ @property
110
+ def containers(self):
111
+ """Return the containers in the pod, if any, else an empty list."""
112
+ return self["spec"].get("containers", [])
113
+
114
+ @property
115
+ def main(self):
116
+ """Return the main container in the pod, if any, defined as the first container with a name
117
+ in MAIN_CONTAINERS. If there are none of those, return the first one.
118
+ """
119
+ if not self.containers:
120
+ return None
121
+ main = fn.first(fn.filter(lambda c: c["name"] in MAIN_CONTAINERS, self.containers))
122
+ return main or self.containers[0]
123
+
124
+
125
+ class JobHelper(ItemHelper, Containerized):
126
+
127
+ @property
128
+ def status(self):
129
+ status = self.obj.get("status", {})
130
+ if len(status) == 0:
131
+ return "Unknown"
132
+ # Per
133
+ # https://github.com/kubernetes-client/python/blob/master/kubernetes/docs/V1JobStatus.md
134
+ # and https://kubernetes.io/docs/concepts/workloads/controllers/job/
135
+ for c in status.get("conditions", []):
136
+ if c["status"] == "True":
137
+ if c["type"] == "Failed":
138
+ # TODO use a separate column
139
+ return c.get("reason") or "Failed"
140
+ if c["type"] == "Suspended":
141
+ return "Suspended"
142
+ if c["type"] == "Complete":
143
+ return "Complete"
144
+ if c["type"] == "FailureTarget":
145
+ return "Failed"
146
+ if c["type"] == "SuccessCriteriaMet":
147
+ return "Complete"
148
+ if status.get("active", 0) > 0:
149
+ return "Running"
150
+ return "Unknown"
151
+
152
+ @property
153
+ def containers(self):
154
+ """Return the containers in the job, if any, else an empty list."""
155
+ return self["spec"]["template"]["spec"].get("containers", [])
156
+
@@ -0,0 +1,220 @@
1
+ """
2
+ Built-in table definitions for Kubernetes.
3
+
4
+ NOTE: This is not a good example of how to write user-defined tables.
5
+ FIXME: Remove references to non-API imports.
6
+ FIXME: Don't use ArgumentParser in the API.
7
+ """
8
+ import json
9
+ from argparse import ArgumentParser
10
+ from threading import Thread
11
+
12
+ from .helpers import Limits, ItemHelper, PodHelper, JobHelper
13
+ from kugl.api import schema, table, fail
14
+ from kugl.util import parse_utc, run, WHITESPACE
15
+
16
+
17
+ @schema("kubernetes")
18
+ class KubernetesData: # FIXME: this should be a resource type, not a schema
19
+
20
+ def add_cli_options(self, ap: ArgumentParser):
21
+ ap.add_argument("-a", "--all-namespaces", default=False, action="store_true")
22
+ ap.add_argument("-n", "--namespace", type=str)
23
+
24
+ def handle_cli_options(self, args):
25
+ if args.all_namespaces and args.namespace:
26
+ fail("Cannot use both -a/--all-namespaces and -n/--namespace")
27
+ self.set_namespace(args.all_namespaces, args.namespace)
28
+
29
+ def set_namespace(self, all_namespaces: bool, namespace: str):
30
+ if all_namespaces:
31
+ # FIXME: engine.py and testing.py still use this
32
+ self.ns = "__all"
33
+ self.all_ns = True
34
+ else:
35
+ self.ns = namespace or "default"
36
+ self.all_ns = False
37
+
38
+ def get_objects(self, kind: str, namespaced: bool)-> dict:
39
+ """Fetch resources from Kubernetes using kubectl.
40
+
41
+ :param kind: Kubernetes resource type e.g. "pods"
42
+ :return: JSON as output by "kubectl get {kind} -o json"
43
+ """
44
+ namespace_flag = ["--all-namespaces"] if self.ns else ["-n", self.ns]
45
+ if kind == "pods":
46
+ pod_statuses = {}
47
+ # Kick off a thread to get pod statuses
48
+ def _fetch():
49
+ _, output, _ = run(["kubectl", "get", "pods", *namespace_flag])
50
+ pod_statuses.update(self._pod_status_from_pod_list(output))
51
+ status_thread = Thread(target=_fetch, daemon=True)
52
+ status_thread.start()
53
+ if namespaced:
54
+ _, output, _= run(["kubectl", "get", kind, *namespace_flag, "-o", "json"])
55
+ else:
56
+ _, output, _ = run(["kubectl", "get", kind, "-o", "json"])
57
+ data = json.loads(output)
58
+ if kind == "pods":
59
+ # Add pod status to pods
60
+ status_thread.join()
61
+ def pod_with_updated_status(pod):
62
+ metadata = pod["metadata"]
63
+ status = pod_statuses.get(f"{metadata['namespace']}/{metadata['name']}")
64
+ if status:
65
+ pod["kubectl_status"] = status
66
+ return pod
67
+ return None
68
+ data["items"] = list(filter(None, map(pod_with_updated_status, data["items"])))
69
+ return data
70
+
71
+ def _pod_status_from_pod_list(self, output) -> dict[str, str]:
72
+ """
73
+ Convert the tabular output of 'kubectl get pods' to JSON.
74
+ :return: a dict mapping "namespace/name" to status
75
+ """
76
+ rows = [WHITESPACE.split(line.strip()) for line in output.strip().split("\n")]
77
+ if len(rows) < 2:
78
+ return {}
79
+ header, rows = rows[0], rows[1:]
80
+ name_index = header.index("NAME")
81
+ status_index = header.index("STATUS")
82
+ # It would be nice if 'kubectl get pods' printed the UID, but it doesn't, so use
83
+ # "namespace/name" as the key. (Can't use a tuple since this has to be JSON-dumped.)
84
+ if self.all_ns:
85
+ namespace_index = header.index("NAMESPACE")
86
+ return {f"{row[namespace_index]}/{row[name_index]}": row[status_index] for row in rows}
87
+ else:
88
+ return {f"{self.ns}/{row[name_index]}": row[status_index] for row in rows}
89
+
90
+
91
+ @table(schema="kubernetes", name="nodes", resource="nodes")
92
+ class NodesTable:
93
+
94
+ @property
95
+ def schema(self):
96
+ return """
97
+ name TEXT,
98
+ uid TEXT,
99
+ cpu_alloc REAL,
100
+ gpu_alloc REAL,
101
+ mem_alloc INTEGER,
102
+ cpu_cap REAL,
103
+ gpu_cap REAL,
104
+ mem_cap INTEGER
105
+ """
106
+
107
+ def make_rows(self, context) -> list[tuple[dict, tuple]]:
108
+ for item in context.data["items"]:
109
+ node = ItemHelper(item)
110
+ yield item, (
111
+ node.name,
112
+ node.metadata.get("uid"),
113
+ *Limits.extract(node["status"]["allocatable"]).as_tuple(),
114
+ *Limits.extract(node["status"]["capacity"]).as_tuple(),
115
+ )
116
+
117
+
118
+ @table(schema="kubernetes", name="pods", resource="pods")
119
+ class PodsTable:
120
+
121
+ @property
122
+ def schema(self):
123
+ return """
124
+ name TEXT,
125
+ uid TEXT,
126
+ is_daemon INTEGER,
127
+ namespace TEXT,
128
+ node_name TEXT,
129
+ creation_ts INTEGER,
130
+ command TEXT,
131
+ phase TEXT,
132
+ status TEXT,
133
+ cpu_req REAL,
134
+ gpu_req REAL,
135
+ mem_req INTEGER,
136
+ cpu_lim REAL,
137
+ gpu_lim REAL,
138
+ mem_lim INTEGER
139
+ """
140
+
141
+ def make_rows(self, context) -> list[tuple[dict, tuple]]:
142
+ for item in context.data["items"]:
143
+ pod = PodHelper(item)
144
+ yield item, (
145
+ pod.name,
146
+ pod.metadata.get("uid"),
147
+ 1 if pod.is_daemon else 0,
148
+ pod.namespace,
149
+ pod["spec"].get("nodeName"),
150
+ parse_utc(pod.metadata["creationTimestamp"]),
151
+ pod.command,
152
+ pod["status"]["phase"],
153
+ pod["kubectl_status"],
154
+ *pod.resources("requests").as_tuple(),
155
+ *pod.resources("limits").as_tuple(),
156
+ )
157
+
158
+
159
+ @table(schema="kubernetes", name="jobs", resource="jobs")
160
+ class JobsTable:
161
+
162
+ @property
163
+ def schema(self):
164
+ return """
165
+ name TEXT,
166
+ uid TEXT,
167
+ namespace TEXT,
168
+ status TEXT,
169
+ cpu_req REAL,
170
+ gpu_req REAL,
171
+ mem_req INTEGER,
172
+ cpu_lim REAL,
173
+ gpu_lim REAL,
174
+ mem_lim INTEGER
175
+ """
176
+
177
+ def make_rows(self, context) -> list[tuple[dict, tuple]]:
178
+ for item in context.data["items"]:
179
+ job = JobHelper(item)
180
+ yield item, (
181
+ job.name,
182
+ job.metadata.get("uid"),
183
+ job.namespace,
184
+ job.status,
185
+ *job.resources("requests").as_tuple(),
186
+ *job.resources("limits").as_tuple(),
187
+ )
188
+
189
+
190
+ class LabelsTable:
191
+ """Base class for all built-in label tables; subclasses need only define UID_FIELD."""
192
+
193
+ @property
194
+ def schema(self):
195
+ return f"""
196
+ {self.UID_FIELD} TEXT,
197
+ key TEXT,
198
+ value TEXT
199
+ """
200
+
201
+ def make_rows(self, context) -> list[tuple[dict, tuple]]:
202
+ for item in context.data["items"]:
203
+ thing = ItemHelper(item)
204
+ for key, value in thing.labels.items():
205
+ yield item, (thing.metadata.get("uid"), key, value)
206
+
207
+
208
+ @table(schema="kubernetes", name="node_labels", resource="nodes")
209
+ class NodeLabelsTable(LabelsTable):
210
+ UID_FIELD = "node_uid"
211
+
212
+
213
+ @table(schema="kubernetes", name="pod_labels", resource="pods")
214
+ class PodLabelsTable(LabelsTable):
215
+ UID_FIELD = "pod_uid"
216
+
217
+
218
+ @table(schema="kubernetes", name="job_labels", resource="jobs")
219
+ class JobLabelsTable(LabelsTable):
220
+ UID_FIELD = "job_uid"
@@ -0,0 +1,27 @@
1
+
2
+ resources:
3
+ - name: pods
4
+ namespaced: true
5
+ - name: pod_statuses
6
+ namespaced: true
7
+ - name: jobs
8
+ namespaced: true
9
+ - name: nodes
10
+ namespaced: false
11
+
12
+ # node_taints builtin is defined here because it doesn't have any special column extraction
13
+ # logic, and because it serves as a good unit test.
14
+
15
+ create:
16
+ - table: node_taints
17
+ resource: nodes
18
+ row_source:
19
+ - items
20
+ - spec.taints
21
+ columns:
22
+ - name: node_uid
23
+ path: ^metadata.uid
24
+ - name: key
25
+ path: key
26
+ - name: effect
27
+ path: effect
kugl/impl/__init__.py ADDED
File without changes
kugl/impl/config.py ADDED
@@ -0,0 +1,207 @@
1
+ """
2
+ Pydantic models for configuration files.
3
+ """
4
+ import json
5
+ import re
6
+ from typing import Literal, Optional, Tuple, Callable, Union
7
+
8
+ import jmespath
9
+ from pydantic import BaseModel, ConfigDict, ValidationError
10
+ from pydantic.functional_validators import model_validator
11
+
12
+ from kugl.util import Age, parse_utc, parse_size, KPath, ConfigPath, parse_age, parse_cpu, fail
13
+
14
+ PARENTED_PATH = re.compile(r"^(\^*)(.*)")
15
+
16
+
17
+ class Settings(BaseModel):
18
+ """Holds the settings: entry from a user config file."""
19
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
20
+ cache_timeout: Age = Age(120)
21
+ reckless: bool = False
22
+
23
+
24
+ class UserInit(BaseModel):
25
+ """The root model for init.yaml; holds the entire file content."""
26
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
27
+ settings: Optional[Settings] = Settings()
28
+ shortcuts: dict[str, list[str]] = {}
29
+
30
+
31
+ class ColumnDef(BaseModel):
32
+ """Holds one entry from a columns: list in a user config file."""
33
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
34
+ name: str
35
+ type: Literal["text", "integer", "real", "date", "age", "size", "cpu"] = "text"
36
+ path: Optional[str] = None
37
+ label: Optional[Union[str, list[str]]] = None
38
+ # Function to extract a column value from an object.
39
+ _extract: Callable[[object], object]
40
+ # Function to convert the extracted value to the SQL type
41
+ _convert: type
42
+ # Parsed value of self.path
43
+ _finder: jmespath.parser.Parser
44
+ # Number of ^ in self.path
45
+ _parents: int
46
+ # SQL type for this column
47
+ _sqltype: str
48
+
49
+ @model_validator(mode="after")
50
+ @classmethod
51
+ def gen_extractor(cls, config: 'ColumnDef') -> 'ColumnDef':
52
+ """
53
+ Generate the extract function for a column definition; given an object, it will
54
+ return a column value of the appropriate type.
55
+ """
56
+ if config.path and config.label:
57
+ raise ValueError("cannot specify both path and label")
58
+ elif config.path:
59
+ m = PARENTED_PATH.match(config.path)
60
+ config._parents = len(m.group(1))
61
+ try:
62
+ config._finder = jmespath.compile(m.group(2))
63
+ except jmespath.exceptions.ParseError as e:
64
+ raise ValueError(f"invalid JMESPath expression {m.group(2)} in column {config.name}") from e
65
+ config._extract = config._extract_jmespath
66
+ elif config.label:
67
+ if not isinstance(config.label, list):
68
+ config.label = [config.label]
69
+ config._extract = config._extract_label
70
+ else:
71
+ raise ValueError("must specify either path or label")
72
+ config._sqltype = KUGL_TYPE_TO_SQL_TYPE[config.type]
73
+ config._convert = KUGL_TYPE_CONVERTERS[config.type]
74
+ return config
75
+
76
+ def extract(self, obj: object, context) -> object:
77
+ """Extract the column value from an object and convert to the correct type."""
78
+ if obj is None:
79
+ if context.debug:
80
+ print(f"No object provided to extractor {self}")
81
+ return None
82
+ if context.debug:
83
+ print(f"Extract {self} from {self._abbreviate(obj)}")
84
+ value = self._extract(obj, context)
85
+ if context.debug:
86
+ print(f"Extracted {value}")
87
+ return None if value is None else self._convert(value)
88
+
89
+ def _extract_jmespath(self, obj: object, context) -> object:
90
+ """Extract a value from an object using a JMESPath finder."""
91
+ if self._parents > 0:
92
+ obj = context.get_parent(obj, self._parents)
93
+ if obj is None:
94
+ fail(f"Missing parent or too many ^ while evaluating {self.path}")
95
+ return self._finder.search(obj)
96
+
97
+ def _extract_label(self, obj: object, context) -> object:
98
+ """Extract a value from an object using a label."""
99
+ obj = context.get_root(obj)
100
+ if available := obj.get("metadata", {}).get("labels", {}):
101
+ for label in self.label:
102
+ if (value := available.get(label)) is not None:
103
+ return value
104
+
105
+ def __str__(self):
106
+ if self.path:
107
+ return f"{self.name} path={self.path}"
108
+ return f"{self.name} label={','.join(self.label)}"
109
+
110
+ def _abbreviate(self, obj):
111
+ text = json.dumps(obj)
112
+ if len(text) > 100:
113
+ return text[:100] + "..."
114
+ return text
115
+
116
+
117
+ KUGL_TYPE_CONVERTERS = {
118
+ "integer": int,
119
+ "real" : float,
120
+ "text": str,
121
+ "date": parse_utc,
122
+ "age": parse_age,
123
+ "size": parse_size,
124
+ "cpu": parse_cpu,
125
+ }
126
+
127
+ KUGL_TYPE_TO_SQL_TYPE = {
128
+ "integer": "integer",
129
+ "real": "real",
130
+ "text": "text",
131
+ "date": "integer",
132
+ "age": "integer",
133
+ "size": "integer",
134
+ "cpu": "real",
135
+ }
136
+
137
+
138
+ class ExtendTable(BaseModel):
139
+ """Holds the extend: section from a user config file."""
140
+ model_config = ConfigDict(extra="forbid")
141
+ table: str
142
+ columns: list[ColumnDef] = []
143
+
144
+
145
+ class ResourceDef(BaseModel):
146
+ """Holds one entry from the resources: list in a user config file."""
147
+ name: str
148
+ # FIXME: Don't conflate all resource attributes in one class
149
+ namespaced: bool = True
150
+ cacheable: bool = True
151
+ file: Optional[str] = None
152
+ exec: Optional[Union[str, list[str]]] = None
153
+
154
+ @model_validator(mode="after")
155
+ @classmethod
156
+ def validate(cls, config: 'ResourceDef') -> 'ResourceDef':
157
+ if config.file and config.exec:
158
+ raise ValueError("Resource cannot specify both file and exec")
159
+ if config.file:
160
+ config.cacheable = False
161
+ return config
162
+
163
+ def __hash__(self):
164
+ return hash(self.name)
165
+
166
+ def __eq__(self, other):
167
+ return self.name == other.name
168
+
169
+
170
+ class CreateTable(ExtendTable):
171
+ """Holds the create: section from a user config file."""
172
+ resource: str
173
+ row_source: Optional[list[str]] = None
174
+
175
+
176
+ class UserConfig(BaseModel):
177
+ """The root model for a user config file; holds the complete file content."""
178
+ model_config = ConfigDict(extra="forbid")
179
+ resources: list[ResourceDef] = []
180
+ extend: list[ExtendTable] = []
181
+ create: list[CreateTable] = []
182
+
183
+
184
+ # FIXME use typevars
185
+ def parse_model(model_class, root: dict) -> Tuple[object, list[str]]:
186
+ """Parse a dict into a model instance (typically a UserConfig).
187
+
188
+ :return: A tuple of (parsed object, list of errors). On success, the error list is None.
189
+ On failure, the parsed object is None.
190
+ """
191
+ try:
192
+ return model_class.model_validate(root), None
193
+ except ValidationError as e:
194
+ error_location = lambda err: '.'.join(str(x) for x in err['loc'])
195
+ return None, [f"{error_location(err)}: {err['msg']}" for err in e.errors()]
196
+
197
+ # FIXME use typevars
198
+ def parse_file(model_class, path: ConfigPath) -> Tuple[object, list[str]]:
199
+ """Parse a configuration file into a model instance, handling edge cases.
200
+
201
+ :return: Same as parse_model."""
202
+ if not path.exists():
203
+ return model_class(), None
204
+ if path.is_world_writeable():
205
+ return None, [f"{path} is world writeable, refusing to run"]
206
+ return parse_model(model_class, path.parse_yaml() or {})
207
+