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 +0 -0
- kugl/api.py +28 -0
- kugl/builtins/__init__.py +0 -0
- kugl/builtins/helpers.py +156 -0
- kugl/builtins/kubernetes.py +220 -0
- kugl/builtins/kubernetes.yaml +27 -0
- kugl/impl/__init__.py +0 -0
- kugl/impl/config.py +207 -0
- kugl/impl/engine.py +246 -0
- kugl/impl/registry.py +117 -0
- kugl/impl/tables.py +182 -0
- kugl/main.py +116 -0
- kugl/util/__init__.py +23 -0
- kugl/util/age.py +101 -0
- kugl/util/clock.py +73 -0
- kugl/util/misc.py +125 -0
- kugl/util/size.py +63 -0
- kugl/util/sqlite.py +70 -0
- kugl-0.3.0.dist-info/LICENSE +21 -0
- kugl-0.3.0.dist-info/METADATA +28 -0
- kugl-0.3.0.dist-info/RECORD +36 -0
- kugl-0.3.0.dist-info/WHEEL +5 -0
- kugl-0.3.0.dist-info/entry_points.txt +2 -0
- kugl-0.3.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +30 -0
- tests/test_cache.py +45 -0
- tests/test_cli.py +69 -0
- tests/test_config.py +148 -0
- tests/test_extra.py +111 -0
- tests/test_jobs.py +53 -0
- tests/test_misc.py +65 -0
- tests/test_nodes.py +76 -0
- tests/test_pods.py +141 -0
- tests/test_utils.py +147 -0
- tests/testing.py +179 -0
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
|
kugl/builtins/helpers.py
ADDED
|
@@ -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
|
+
|