knotctl 0.1.1__py3-none-any.whl → 0.1.3__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.
knotctl/__init__.py CHANGED
@@ -1,128 +1,76 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
- import argparse
4
3
  import getpass
5
- import json
6
4
  import os
7
5
  import sys
8
- import urllib.parse
9
- from os import environ, mkdir
10
- from os.path import isdir, isfile, join
11
6
  from typing import Union
12
- from urllib.parse import urlparse
7
+ from urllib.parse import quote
13
8
 
14
- import argcomplete
15
9
  import requests
16
- import yaml
17
- from requests.models import HTTPBasicAuth
18
10
  from simplejson.errors import JSONDecodeError as SimplejsonJSONDecodeError
19
11
 
12
+ from .config import Config
13
+ from .runners import Run
14
+ from .utils import error, get_parser, output, setup_url
15
+
20
16
  try:
21
17
  from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
22
18
  except ImportError:
23
19
  from requests.exceptions import InvalidJSONError as RequestsJSONDecodeError
24
20
 
25
21
 
26
- # Helper functions
27
- def error(description: str, error: str) -> list[dict]:
28
- response = []
29
- reply = {}
30
- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406
31
- reply["Code"] = 406
32
- reply["Description"] = description
33
- reply["Error"] = error
34
- response.append(reply)
35
- return response
36
-
37
-
38
- def get_config(config_filename: str):
39
- if not isfile(config_filename):
40
- print("You need to configure knotctl before proceeding")
41
- run_config(config_filename)
42
- with open(config_filename, "r") as fh:
43
- return yaml.safe_load(fh.read())
44
-
45
-
46
- def nested_out(input, tabs="") -> str:
47
- string = ""
48
- if isinstance(input, str) or isinstance(input, int):
49
- string += "{}\n".format(input)
50
- elif isinstance(input, dict):
51
- for key, value in input.items():
52
- string += "{}{}: {}".format(tabs, key,
53
- nested_out(value, tabs + " "))
54
- elif isinstance(input, list):
55
- for entry in input:
56
- string += "{}\n{}".format(tabs, nested_out(entry, tabs + " "))
57
- return string
58
-
59
-
60
- def output(response: list[dict], jsonout: bool = False):
61
- try:
62
- if jsonout:
63
- print(json.dumps(response))
64
- else:
65
- print(nested_out(response))
66
- except BrokenPipeError:
67
- pass
22
+ class Knotctl:
68
23
 
24
+ def __init__(self):
25
+ self.conf = Config()
26
+ self.config = self.get_config()
27
+ self.config_filename = self.conf.config_filename
28
+ self.runner = Run()
69
29
 
70
- # Define the runner for each command
71
- def run_add(url: str, jsonout: bool, headers: dict):
72
- parsed = split_url(url)
73
- response = requests.put(url, headers=headers)
74
- out = response.json()
75
- if isinstance(out, list):
76
- for record in out:
77
- if (record["data"] == parsed["data"]
78
- and record["name"] == parsed["name"]
79
- and record["rtype"] == parsed["rtype"]):
80
- output(record, jsonout)
81
- break
82
- else:
83
- output(out, jsonout)
30
+ def get_config(self):
31
+ config = self.conf.get_config()
32
+ if not config:
33
+ print("You need to configure knotctl before proceeding")
34
+ run_config()
84
35
 
36
+ return config
85
37
 
86
- def run_log(url: str, jsonout: bool, headers: dict):
87
- response = requests.get(url, headers=headers)
88
- string = response.content.decode("utf-8")
89
- if jsonout:
90
- out = []
91
- lines = string.splitlines()
92
- index = 0
93
- text = ""
94
- timestamp = ""
95
- while index < len(lines):
96
- line = lines[index]
97
- index += 1
98
- cur_has_timestamp = line.startswith("[")
99
- next_has_timestamp = index < len(
100
- lines) and lines[index].startswith("[")
101
- # Simple case, just one line with timestamp
102
- if cur_has_timestamp and next_has_timestamp:
103
- timestamp = line.split("]")[0].split("[")[1]
104
- text = line.split("]")[1].lstrip(":").strip()
105
- out.append({"timestamp": timestamp, "text": text})
106
- text = ""
107
- timestamp = ""
108
- # Start of multiline
109
- elif cur_has_timestamp:
110
- timestamp = line.split("]")[0].split("[")[1]
111
- text = line.split("]")[1].lstrip(":").strip()
112
- # End of multiline
113
- elif next_has_timestamp:
114
- text += f"\n{line.strip()}"
115
- out.append({"timestamp": timestamp, "text": text})
116
- text = ""
117
- timestamp = ""
118
- # Middle of multiline
38
+ def run(self, url: str, args: dict, baseurl: str, parser: dict,
39
+ username: str):
40
+ try:
41
+ if args.command == "add":
42
+ self.runner.add(url, args.json)
43
+ elif args.command == "delete":
44
+ self.runner.delete(url, args.json)
45
+ elif args.command == "list":
46
+ self.runner.lister(url, args.json)
47
+ elif args.command == "update":
48
+ self.runner.update(url, args.json)
49
+ elif args.command == "user":
50
+ url = baseurl + f"/user/info/{username}"
51
+ self.runner.lister(url, args.json)
52
+ elif args.command == "auditlog":
53
+ url = baseurl + "/user/auditlog"
54
+ self.runner.log(url, args.json)
55
+ elif args.command == "changelog":
56
+ url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
57
+ self.runner.log(url, args.json)
58
+ elif args.command == "zone":
59
+ url = baseurl + "/zones"
60
+ self.runner.zone(url, args.json)
61
+ elif args.command == "openstack-sync":
62
+ self.runner.openstack_sync(args.cloud, args.name, args.zone,
63
+ baseurl, args.json)
119
64
  else:
120
- text += f"\n{line.strip()}"
121
-
122
- else:
123
- out = string
124
-
125
- output(out, jsonout)
65
+ parser.print_help(sys.stderr)
66
+ return 2
67
+ except requests.exceptions.RequestException as e:
68
+ output(error(e, "Could not connect to server"))
69
+ except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
70
+ output(
71
+ error("Could not decode api response as JSON",
72
+ "Could not decode"))
73
+ return 0
126
74
 
127
75
 
128
76
  def run_complete(shell: Union[None, str]):
@@ -135,33 +83,27 @@ def run_complete(shell: Union[None, str]):
135
83
 
136
84
 
137
85
  def run_config(
138
- config_filename: str,
139
86
  context: Union[None, str] = None,
140
87
  baseurl: Union[None, str] = None,
88
+ list_config: bool = False,
141
89
  username: Union[None, str] = None,
142
90
  password: Union[None, str] = None,
143
91
  current: Union[None, str] = None,
144
92
  ):
93
+ conf = Config()
145
94
  if current:
146
- if os.path.islink(config_filename):
147
- actual_path = os.readlink(config_filename)
148
- print(actual_path.split("-")[-1])
149
- else:
150
- print("none")
95
+ print(conf.get_current())
151
96
  return
152
97
  config = {"baseurl": baseurl, "username": username, "password": password}
153
98
  needed = []
154
99
  if context:
155
- symlink = f"{config_filename}-{context}"
156
- found = os.path.isfile(symlink)
157
- if os.path.islink(config_filename):
158
- os.remove(config_filename)
159
- elif os.path.isfile(config_filename):
160
- os.rename(config_filename, symlink)
161
- os.symlink(symlink, config_filename)
162
- config_filename = symlink
100
+ found = conf.set_context(context)
163
101
  if found:
164
102
  return
103
+ if list_config:
104
+ config_data = conf.get_config_data()
105
+ output(config_data)
106
+ return
165
107
  if not baseurl:
166
108
  needed.append("baseurl")
167
109
  if not username:
@@ -183,332 +125,7 @@ def run_config(
183
125
  output(error("Can not configure without password", "No password"))
184
126
  sys.exit(1)
185
127
 
186
- with open(config_filename, "w") as fh:
187
- fh.write(yaml.dump(config))
188
-
189
-
190
- def run_delete(url: str, jsonout: bool, headers: dict):
191
- response = requests.delete(url, headers=headers)
192
- reply = response.json()
193
- if not reply and response.status_code == requests.codes.ok:
194
- reply = [{"Code": 200, "Description": "{} deleted".format(url)}]
195
-
196
- output(reply, jsonout)
197
-
198
-
199
- def run_list(url: str,
200
- jsonout: bool,
201
- headers: dict,
202
- ret=False) -> Union[None, str]:
203
- response = requests.get(url, headers=headers)
204
- string = response.json()
205
- if ret:
206
- return string
207
- else:
208
- output(string, jsonout)
209
-
210
-
211
- def run_update(url: str, jsonout: bool, headers: dict):
212
- response = requests.patch(url, headers=headers)
213
- output(response.json(), jsonout)
214
-
215
-
216
- def run_zone(url: str,
217
- jsonout: bool,
218
- headers: dict,
219
- ret=False) -> Union[None, str]:
220
- response = requests.get(url, headers=headers)
221
- zones = response.json()
222
- for zone in zones:
223
- del zone["records"]
224
- string = zones
225
-
226
- if ret:
227
- return string
228
- else:
229
- output(string, jsonout)
230
-
231
-
232
- # Set up the url
233
- def setup_url(
234
- baseurl: str,
235
- arguments: Union[None, list[str]],
236
- data: Union[None, str],
237
- name: Union[None, str],
238
- rtype: Union[None, str],
239
- ttl: Union[None, str],
240
- zone: Union[None, str],
241
- ) -> str:
242
- url = baseurl + "/zones"
243
- if zone:
244
- if not zone.endswith("."):
245
- zone += "."
246
- url += "/{}".format(zone)
247
- if name and zone:
248
- if name.endswith(zone.rstrip(".")):
249
- name += "."
250
- url += "/records/{}".format(name)
251
- if zone and name and rtype:
252
- url += "/{}".format(rtype)
253
- if data and zone and name and rtype:
254
- url += "/{}".format(data)
255
- if ttl and data and zone and name and rtype:
256
- url += "/{}".format(ttl)
257
- if data and zone and name and rtype and arguments:
258
- url += "?"
259
- for arg in arguments:
260
- if not url.endswith("?"):
261
- url += "&"
262
- key, value = arg.split("=")
263
- url += key + "=" + urllib.parse.quote_plus(value)
264
-
265
- if ttl and (not rtype or not name or not zone):
266
- output(
267
- error(
268
- "ttl only makes sense with rtype, name and zone",
269
- "Missing parameter",
270
- ))
271
- sys.exit(1)
272
- if rtype and (not name or not zone):
273
- output(
274
- error(
275
- "rtype only makes sense with name and zone",
276
- "Missing parameter",
277
- ))
278
- sys.exit(1)
279
- if name and not zone:
280
- output(error("name only makes sense with a zone", "Missing parameter"))
281
- sys.exit(1)
282
- return url
283
-
284
-
285
- def split_url(url: str) -> dict:
286
- parsed = urlparse(url, allow_fragments=False)
287
- path = parsed.path
288
- query = parsed.query
289
- arguments: Union[None, list[str]] = query.split("&")
290
- path_arr = path.split("/")
291
- data: Union[None, str] = None
292
- name: Union[None, str] = None
293
- rtype: Union[None, str] = None
294
- ttl: Union[None, str] = None
295
- zone: Union[None, str] = None
296
- if len(path_arr) > 2:
297
- zone = path_arr[2]
298
- if len(path_arr) > 4:
299
- name = path_arr[4]
300
- if len(path_arr) > 5:
301
- rtype = path_arr[5]
302
- if len(path_arr) > 6:
303
- data = path_arr[6]
304
- if len(path_arr) > 7:
305
- ttl = path_arr[7]
306
-
307
- return {
308
- "arguments": arguments,
309
- "data": data,
310
- "name": name,
311
- "rtype": rtype,
312
- "ttl": ttl,
313
- "zone": zone,
314
- }
315
-
316
-
317
- def get_parser() -> dict:
318
- description = """Manage DNS records with knot dns rest api:
319
- * https://gitlab.nic.cz/knot/knot-dns-rest"""
320
-
321
- epilog = """
322
- The Domain Name System specifies a database of information
323
- elements for network resources. The types of information
324
- elements are categorized and organized with a list of DNS
325
- record types, the resource records (RRs). Each record has a
326
- name, a type, an expiration time (time to live), and
327
- type-specific data.
328
-
329
- The following is a list of terms used in this program:
330
- ----------------------------------------------------------------
331
- | Vocabulary | Description |
332
- ----------------------------------------------------------------
333
- | zone | A DNS zone is a specific portion of the DNS |
334
- | | namespace in the Domain Name System (DNS), |
335
- | | which a specific organization or administrator |
336
- | | manages. |
337
- ----------------------------------------------------------------
338
- | name | In the Internet, a domain name is a string that |
339
- | | identifies a realm of administrative autonomy, |
340
- | | authority or control. Domain names are often |
341
- | | used to identify services provided through the |
342
- | | Internet, such as websites, email services and |
343
- | | more. |
344
- ----------------------------------------------------------------
345
- | rtype | A record type indicates the format of the data |
346
- | | and it gives a hint of its intended use. For |
347
- | | example, the A record is used to translate from |
348
- | | a domain name to an IPv4 address, the NS record |
349
- | | lists which name servers can answer lookups on |
350
- | | a DNS zone, and the MX record specifies the |
351
- | | mail server used to handle mail for a domain |
352
- | | specified in an e-mail address. |
353
- ----------------------------------------------------------------
354
- | data | A records data is of type-specific relevance, |
355
- | | such as the IP address for address records, or |
356
- | | the priority and hostname for MX records. |
357
- ----------------------------------------------------------------
358
-
359
- This information was compiled from Wikipedia:
360
- * https://en.wikipedia.org/wiki/DNS_zone
361
- * https://en.wikipedia.org/wiki/Domain_Name_System
362
- * https://en.wikipedia.org/wiki/Zone_file
363
- """
364
- # Grab user input
365
- parser = argparse.ArgumentParser(
366
- description=description,
367
- epilog=epilog,
368
- formatter_class=argparse.RawDescriptionHelpFormatter,
369
- )
370
- parser.add_argument("--json", action=argparse.BooleanOptionalAction)
371
- subparsers = parser.add_subparsers(dest="command")
372
-
373
- add_description = "Add a new record to the zone."
374
- addcmd = subparsers.add_parser("add", description=add_description)
375
- addcmd.add_argument("-d", "--data", required=True)
376
- addcmd.add_argument("-n", "--name", required=True)
377
- addcmd.add_argument("-r", "--rtype", required=True)
378
- addcmd.add_argument("-t", "--ttl")
379
- addcmd.add_argument("-z", "--zone", required=True)
380
-
381
- auditlog_description = "Audit the log file for errors."
382
- subparsers.add_parser("auditlog", description=auditlog_description)
383
-
384
- changelog_description = "View the changelog of a zone."
385
- changelogcmd = subparsers.add_parser("changelog",
386
- description=changelog_description)
387
- changelogcmd.add_argument("-z", "--zone", required=True)
388
-
389
- complete_description = "Generate shell completion script."
390
- completecmd = subparsers.add_parser("completion",
391
- description=complete_description)
392
- completecmd.add_argument("-s", "--shell")
393
-
394
- config_description = "Configure access to knot-dns-rest-api."
395
- configcmd = subparsers.add_parser("config", description=config_description)
396
- configcmd.add_argument("-b", "--baseurl")
397
- configcmd.add_argument("-c", "--context")
398
- configcmd.add_argument("-C",
399
- "--current",
400
- action=argparse.BooleanOptionalAction)
401
- configcmd.add_argument("-p", "--password")
402
- configcmd.add_argument("-u", "--username")
403
-
404
- delete_description = "Delete a record from the zone."
405
- deletecmd = subparsers.add_parser("delete", description=delete_description)
406
- deletecmd.add_argument("-d", "--data")
407
- deletecmd.add_argument("-n", "--name")
408
- deletecmd.add_argument("-r", "--rtype")
409
- deletecmd.add_argument("-z", "--zone", required=True)
410
-
411
- list_description = "List records."
412
- listcmd = subparsers.add_parser("list", description=list_description)
413
- listcmd.add_argument("-d", "--data")
414
- listcmd.add_argument("-n", "--name")
415
- listcmd.add_argument("-r", "--rtype")
416
- listcmd.add_argument("-z", "--zone", required=False)
417
-
418
- user_description = "View user information."
419
- usercmd = subparsers.add_parser("user", description=user_description)
420
- usercmd.add_argument("-u", "--username", default=None)
421
-
422
- update_description = (
423
- "Update a record in the zone. The record must exist in the zone.\n")
424
- update_description += (
425
- "In this case --data, --name, --rtype and --ttl switches are used\n")
426
- update_description += (
427
- "for searching for the appropriate record, while the --argument\n")
428
- update_description += "switches are used for updating the record."
429
- update_epilog = """Available arguments are:
430
- data: New record data.
431
- name: New record domain name.
432
- rtype: New record type.
433
- ttl: New record time to live (TTL)."""
434
- updatecmd = subparsers.add_parser(
435
- "update",
436
- description=update_description,
437
- epilog=update_epilog,
438
- formatter_class=argparse.RawDescriptionHelpFormatter,
439
- )
440
- updatecmd.add_argument(
441
- "-a",
442
- "--argument",
443
- action="append",
444
- metavar="KEY=VALUE",
445
- help="Specify key - value pairs to be updated: name=dns1.example.com."
446
- + " or data=127.0.0.1 for example. --argument can be repeated",
447
- required=True,
448
- )
449
- updatecmd.add_argument("-d", "--data", required=True)
450
- updatecmd.add_argument("-n", "--name", required=True)
451
- updatecmd.add_argument("-r", "--rtype", required=True)
452
- updatecmd.add_argument("-t", "--ttl")
453
- updatecmd.add_argument("-z", "--zone", required=True)
454
-
455
- zone_description = "View zones."
456
- subparsers.add_parser("zone", description=zone_description)
457
-
458
- argcomplete.autocomplete(parser)
459
-
460
- return parser
461
-
462
-
463
- def get_token(config) -> str:
464
- # Authenticate
465
- baseurl = config["baseurl"]
466
- username = config["username"]
467
- password = config["password"]
468
- basic = HTTPBasicAuth(username, password)
469
- response = requests.get(baseurl + "/user/login", auth=basic)
470
- token = ""
471
- try:
472
- token = response.json()["token"]
473
- except KeyError:
474
- output(response.json())
475
- except requests.exceptions.JSONDecodeError:
476
- output(
477
- error("Could not decode api response as JSON", "Could not decode"))
478
- return token
479
-
480
-
481
- def run(url, args, headers, baseurl, parser, username):
482
- try:
483
- if args.command == "add":
484
- run_add(url, args.json, headers)
485
- elif args.command == "delete":
486
- run_delete(url, args.json, headers)
487
- elif args.command == "list":
488
- run_list(url, args.json, headers)
489
- elif args.command == "update":
490
- run_update(url, args.json, headers)
491
- elif args.command == "user":
492
- url = baseurl + f"/user/info/{username}"
493
- run_list(url, args.json, headers)
494
- elif args.command == "auditlog":
495
- url = baseurl + "/user/auditlog"
496
- run_log(url, args.json, headers)
497
- elif args.command == "changelog":
498
- url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
499
- run_log(url, args.json, headers)
500
- elif args.command == "zone":
501
- url = baseurl + "/zones"
502
- run_zone(url, args.json, headers)
503
- else:
504
- parser.print_help(sys.stderr)
505
- return 2
506
- except requests.exceptions.RequestException as e:
507
- output(error(e, "Could not connect to server"))
508
- except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
509
- output(
510
- error("Could not decode api response as JSON", "Could not decode"))
511
- return 0
128
+ conf.set_config(config)
512
129
 
513
130
 
514
131
  # Entry point to program
@@ -519,38 +136,35 @@ def main() -> int:
519
136
  run_complete(args.shell)
520
137
  return 0
521
138
 
522
- # Make sure we have config
523
- config_basepath = join(environ["HOME"], ".knot")
524
- config_filename = join(config_basepath, "config")
525
-
526
- if not isdir(config_basepath):
527
- mkdir(config_basepath)
139
+ knotctl = Knotctl()
528
140
 
529
141
  if args.command == "config":
530
142
  run_config(
531
- config_filename,
532
143
  args.context,
533
144
  args.baseurl,
145
+ args.list_config,
534
146
  args.username,
535
147
  args.password,
536
148
  args.current,
537
149
  )
538
150
  return 0
539
151
 
540
- config = get_config(config_filename)
152
+ config = knotctl.get_config()
541
153
  baseurl = config["baseurl"]
542
- token = get_token(config)
154
+ token = knotctl.conf.get_token()
543
155
  if token == "":
544
156
  print("Could not get token, exiting")
545
157
  return 1
546
- headers = {"Authorization": "Bearer {}".format(token)}
547
158
 
548
159
  # Route based on command
549
160
  url = ""
550
161
  ttl = None
162
+ quotedata = None
551
163
  user = config["username"]
552
164
  if "ttl" in args:
553
165
  ttl = args.ttl
166
+ if "data" in args:
167
+ quotedata = quote(args.data, safe="")
554
168
  if args.command != "update":
555
169
  args.argument = None
556
170
  if args.command == "add" and not ttl:
@@ -559,19 +173,21 @@ def main() -> int:
559
173
  else:
560
174
  zname = args.zone + "."
561
175
  soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
562
- soa_json = run_list(soa_url, True, headers, ret=True)
176
+ soa_json = knotctl.runner.lister(soa_url, True, ret=True)
563
177
  ttl = soa_json[0]["ttl"]
564
178
  if args.command == "user":
565
179
  if args.username:
566
180
  user = args.username
567
- if args.command in ["auditlog", "changelog", "user", "zone"]:
181
+ if args.command in [
182
+ "auditlog", "changelog", "openstack-sync", "user", "zone"
183
+ ]:
568
184
  pass
569
185
  else:
570
186
  try:
571
187
  url = setup_url(
572
188
  baseurl,
573
189
  args.argument,
574
- args.data,
190
+ quotedata,
575
191
  args.name,
576
192
  args.rtype,
577
193
  ttl,
@@ -581,7 +197,7 @@ def main() -> int:
581
197
  parser.print_help(sys.stderr)
582
198
  return 1
583
199
 
584
- return run(url, args, headers, baseurl, parser, user)
200
+ return knotctl.run(url, args, baseurl, parser, user)
585
201
 
586
202
 
587
203
  if __name__ == "__main__":
knotctl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from knotctl import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ from os import mkdir
4
+ from os.path import isdir, isfile, join
5
+ from typing import Union
6
+
7
+ import requests
8
+ import yaml
9
+ from requests.models import HTTPBasicAuth
10
+
11
+ from ..utils import error, output
12
+
13
+
14
+ class Config:
15
+
16
+ def __init__(self):
17
+ # Make sure we have config
18
+ self.config_basepath = join(os.environ["HOME"], ".knot")
19
+ self.config_filename = join(self.config_basepath, "config")
20
+ if not isdir(self.config_basepath):
21
+ mkdir(self.config_basepath)
22
+
23
+ def get_config(self) -> Union[None, dict]:
24
+ if not isfile(self.config_filename):
25
+ return None
26
+ with open(self.config_filename, "r") as fh:
27
+ return yaml.safe_load(fh.read())
28
+
29
+ def get_config_data(self) -> dict:
30
+ config_data = self.get_config()
31
+ config_data.pop("password", None)
32
+ return config_data
33
+
34
+ def get_current(self) -> str:
35
+ if os.path.islink(self.config_filename):
36
+ actual_path = os.readlink(self.config_filename)
37
+ return actual_path.split("-")[-1]
38
+ else:
39
+ return "none"
40
+
41
+ def get_token(self) -> str:
42
+ # Authenticate
43
+ config = self.get_config()
44
+ baseurl = config["baseurl"]
45
+ username = config["username"]
46
+ password = config["password"]
47
+ basic = HTTPBasicAuth(username, password)
48
+ response = requests.get(baseurl + "/user/login", auth=basic)
49
+ token = ""
50
+ try:
51
+ token = response.json()["token"]
52
+ except KeyError:
53
+ output(response.json())
54
+ except requests.exceptions.JSONDecodeError:
55
+ output(
56
+ error("Could not decode api response as JSON",
57
+ "Could not decode"))
58
+ return token
59
+
60
+ def set_context(self, context) -> bool:
61
+ symlink = f"{self.config_filename}-{context}"
62
+ found = os.path.isfile(symlink)
63
+ if os.path.islink(self.config_filename):
64
+ os.remove(self.config_filename)
65
+ elif os.path.isfile(self.config_filename):
66
+ os.rename(self.config_filename, symlink)
67
+ os.symlink(symlink, self.config_filename)
68
+ self.config_filename = symlink
69
+ return found
70
+
71
+ def set_config(
72
+ self,
73
+ baseurl: str,
74
+ username: str,
75
+ password: str,
76
+ ):
77
+ config = {
78
+ "baseurl": baseurl,
79
+ "username": username,
80
+ "password": password
81
+ }
82
+
83
+ with open(self.config_filename, "w") as fh:
84
+ fh.write(yaml.dump(config))
@@ -0,0 +1,17 @@
1
+ import openstack
2
+ import openstack.config.loader
3
+
4
+
5
+ def get_openstack_addresses(cloud: str, name: str):
6
+ conn = openstack.connect(cloud=cloud)
7
+
8
+ # List the servers
9
+ server = conn.compute.find_server(name)
10
+ if server is None:
11
+ print("Server not found")
12
+ exit(1)
13
+ openstack_addresses = []
14
+ for network in server.addresses:
15
+ for address in server.addresses[network]:
16
+ openstack_addresses.append(address)
17
+ return openstack_addresses
@@ -0,0 +1,219 @@
1
+ from typing import Union
2
+
3
+ import requests
4
+
5
+ from ..config import Config
6
+ from ..openstack import get_openstack_addresses
7
+ from ..utils import output, setup_url, split_url
8
+
9
+
10
+ class Run():
11
+
12
+ def __init__(self):
13
+ conf = Config()
14
+ self.headers = {"Authorization": f"Bearer {conf.get_token()}"}
15
+
16
+ def add(self, url: str, jsonout: bool):
17
+ parsed = split_url(url)
18
+ response = requests.put(url, headers=self.headers)
19
+ out = response.json()
20
+ if isinstance(out, list):
21
+ for record in out:
22
+ if (record["data"] == parsed["data"]
23
+ and record["name"] == parsed["name"]
24
+ and record["rtype"] == parsed["rtype"]):
25
+ output(record, jsonout)
26
+ break
27
+ else:
28
+ output(out, jsonout)
29
+
30
+ def delete(self, url: str, jsonout: bool):
31
+ response = requests.delete(url, headers=self.headers)
32
+ reply = response.json()
33
+ if not reply and response.status_code == requests.codes.ok:
34
+ reply = [{"Code": 200, "Description": "{} deleted".format(url)}]
35
+
36
+ output(reply, jsonout)
37
+
38
+ def log(self, url: str, jsonout: bool):
39
+ response = requests.get(url, headers=self.headers)
40
+ string = response.content.decode("utf-8")
41
+ if jsonout:
42
+ out = []
43
+ lines = string.splitlines()
44
+ index = 0
45
+ text = ""
46
+ timestamp = ""
47
+ while index < len(lines):
48
+ line = lines[index]
49
+ index += 1
50
+ cur_has_timestamp = line.startswith("[")
51
+ next_has_timestamp = index < len(
52
+ lines) and lines[index].startswith("[")
53
+ # Simple case, just one line with timestamp
54
+ if cur_has_timestamp and next_has_timestamp:
55
+ timestamp = line.split("]")[0].split("[")[1]
56
+ text = line.split("]")[1].lstrip(":").strip()
57
+ out.append({"timestamp": timestamp, "text": text})
58
+ text = ""
59
+ timestamp = ""
60
+ # Start of multiline
61
+ elif cur_has_timestamp:
62
+ timestamp = line.split("]")[0].split("[")[1]
63
+ text = line.split("]")[1].lstrip(":").strip()
64
+ # End of multiline
65
+ elif next_has_timestamp:
66
+ text += f"\n{line.strip()}"
67
+ out.append({"timestamp": timestamp, "text": text})
68
+ text = ""
69
+ timestamp = ""
70
+ # Middle of multiline
71
+ else:
72
+ text += f"\n{line.strip()}"
73
+
74
+ else:
75
+ out = string
76
+
77
+ output(out, jsonout)
78
+
79
+ def update(self, url: str, jsonout: bool):
80
+ response = requests.patch(url, headers=self.headers)
81
+ output(response.json(), jsonout)
82
+
83
+ def zone(self, url: str, jsonout: bool, ret=False) -> Union[None, str]:
84
+ response = requests.get(url, headers=self.headers)
85
+ zones = response.json()
86
+ for zone in zones:
87
+ del zone["records"]
88
+ string = zones
89
+
90
+ if ret:
91
+ return string
92
+ else:
93
+ output(string, jsonout)
94
+
95
+ def lister(self, url: str, jsonout: bool, ret=False) -> Union[None, str]:
96
+ response = requests.get(url, headers=self.headers)
97
+ string = response.json()
98
+ if ret:
99
+ return string
100
+ else:
101
+ output(string, jsonout)
102
+
103
+ def add_records(self, openstack_addresses, baseurl, name, zone, url,
104
+ jsonout):
105
+ for address in openstack_addresses:
106
+ rtype = None
107
+ if address["version"] == 4:
108
+ rtype = "A"
109
+ elif address["version"] == 6:
110
+ rtype = "AAAA"
111
+ if rtype:
112
+ url = setup_url(
113
+ baseurl,
114
+ None, # arguments,
115
+ address["addr"], # data,
116
+ name,
117
+ rtype,
118
+ None, # ttl,
119
+ zone,
120
+ )
121
+ self.add(url, jsonout)
122
+
123
+ def update_records(self, openstack_addresses, current_records, baseurl,
124
+ name, zone, url, jsonout):
125
+ previpv4 = False
126
+ previpv6 = False
127
+ curripv4 = False
128
+ curripv6 = False
129
+ for record in current_records:
130
+ if record.type == "A":
131
+ previpv4 = record.data
132
+ elif record.type == "AAAA":
133
+ previpv6 = record.data
134
+ for address in openstack_addresses:
135
+ rtype = None
136
+ if address.version == 4:
137
+ rtype = "A"
138
+ curripv4 = True
139
+ elif address.version == 6:
140
+ rtype = "AAAA"
141
+ curripv6 = True
142
+ if rtype and record.type == rtype:
143
+ if record.data == address.addr:
144
+ continue
145
+ else:
146
+ url = setup_url(
147
+ baseurl,
148
+ None, # arguments,
149
+ address.addr, # data,
150
+ name,
151
+ record.type,
152
+ None, # ttl,
153
+ zone,
154
+ )
155
+ self.update(url, jsonout)
156
+ if previpv4 and not curripv4:
157
+ url = setup_url(
158
+ baseurl,
159
+ None, # arguments,
160
+ previpv4, # data,
161
+ name,
162
+ "A",
163
+ None, # ttl,
164
+ zone,
165
+ )
166
+ self.delete(url, jsonout)
167
+ if previpv6 and not curripv6:
168
+ url = setup_url(
169
+ baseurl,
170
+ None, # arguments,
171
+ previpv6, # data,
172
+ name,
173
+ "AAAA",
174
+ None, # ttl,
175
+ zone,
176
+ )
177
+ self.delete(url, jsonout)
178
+ if curripv4 and not previpv4:
179
+ url = setup_url(
180
+ baseurl,
181
+ None, # arguments,
182
+ curripv4, # data,
183
+ name,
184
+ "A",
185
+ None, # ttl,
186
+ zone,
187
+ )
188
+ self.add(url, jsonout)
189
+ if curripv6 and not previpv6:
190
+ url = setup_url(
191
+ baseurl,
192
+ None, # arguments,
193
+ curripv6, # data,
194
+ name,
195
+ "AAAA",
196
+ None, # ttl,
197
+ zone,
198
+ )
199
+ self.add(url, jsonout)
200
+
201
+ def openstack_sync(self, cloud: str, name: str, zone: str, baseurl: str,
202
+ jsonout: bool):
203
+ url = setup_url(
204
+ baseurl,
205
+ None, # arguments,
206
+ None, # data,
207
+ name,
208
+ None, # rtype,
209
+ None, # ttl,
210
+ zone,
211
+ )
212
+ current_records = self.lister(url, jsonout=True, ret=True)
213
+ openstack_addresses = get_openstack_addresses(cloud, name)
214
+ if current_records["Code"] == 404:
215
+ self.add_records(openstack_addresses, baseurl, name, zone, url,
216
+ jsonout)
217
+ else:
218
+ self.update_records(openstack_addresses, current_records, baseurl,
219
+ name, zone, url, jsonout)
@@ -0,0 +1,282 @@
1
+ import argparse
2
+ import argcomplete
3
+ import json
4
+ import sys
5
+ import urllib.parse
6
+ from typing import Union
7
+ from urllib.parse import urlparse
8
+
9
+
10
+ def get_parser() -> dict:
11
+ description = """Manage DNS records with knot dns rest api:
12
+ * https://gitlab.nic.cz/knot/knot-dns-rest"""
13
+
14
+ epilog = """
15
+ The Domain Name System specifies a database of information
16
+ elements for network resources. The types of information
17
+ elements are categorized and organized with a list of DNS
18
+ record types, the resource records (RRs). Each record has a
19
+ name, a type, an expiration time (time to live), and
20
+ type-specific data.
21
+
22
+ The following is a list of terms used in this program:
23
+ ----------------------------------------------------------------
24
+ | Vocabulary | Description |
25
+ ----------------------------------------------------------------
26
+ | zone | A DNS zone is a specific portion of the DNS |
27
+ | | namespace in the Domain Name System (DNS), |
28
+ | | which a specific organization or administrator |
29
+ | | manages. |
30
+ ----------------------------------------------------------------
31
+ | name | In the Internet, a domain name is a string that |
32
+ | | identifies a realm of administrative autonomy, |
33
+ | | authority or control. Domain names are often |
34
+ | | used to identify services provided through the |
35
+ | | Internet, such as websites, email services and |
36
+ | | more. |
37
+ ----------------------------------------------------------------
38
+ | rtype | A record type indicates the format of the data |
39
+ | | and it gives a hint of its intended use. For |
40
+ | | example, the A record is used to translate from |
41
+ | | a domain name to an IPv4 address, the NS record |
42
+ | | lists which name servers can answer lookups on |
43
+ | | a DNS zone, and the MX record specifies the |
44
+ | | mail server used to handle mail for a domain |
45
+ | | specified in an e-mail address. |
46
+ ----------------------------------------------------------------
47
+ | data | A records data is of type-specific relevance, |
48
+ | | such as the IP address for address records, or |
49
+ | | the priority and hostname for MX records. |
50
+ ----------------------------------------------------------------
51
+
52
+ This information was compiled from Wikipedia:
53
+ * https://en.wikipedia.org/wiki/DNS_zone
54
+ * https://en.wikipedia.org/wiki/Domain_Name_System
55
+ * https://en.wikipedia.org/wiki/Zone_file
56
+ """
57
+ # Grab user input
58
+ parser = argparse.ArgumentParser(
59
+ description=description,
60
+ epilog=epilog,
61
+ formatter_class=argparse.RawDescriptionHelpFormatter,
62
+ )
63
+ parser.add_argument("--json", action=argparse.BooleanOptionalAction)
64
+ subparsers = parser.add_subparsers(dest="command")
65
+
66
+ add_description = "Add a new record to the zone."
67
+ addcmd = subparsers.add_parser("add", description=add_description)
68
+ addcmd.add_argument("-d", "--data", required=True)
69
+ addcmd.add_argument("-n", "--name", required=True)
70
+ addcmd.add_argument("-r", "--rtype", required=True)
71
+ addcmd.add_argument("-t", "--ttl")
72
+ addcmd.add_argument("-z", "--zone", required=True)
73
+
74
+ auditlog_description = "Audit the log file for errors."
75
+ subparsers.add_parser("auditlog", description=auditlog_description)
76
+
77
+ changelog_description = "View the changelog of a zone."
78
+ changelogcmd = subparsers.add_parser("changelog",
79
+ description=changelog_description)
80
+ changelogcmd.add_argument("-z", "--zone", required=True)
81
+
82
+ complete_description = "Generate shell completion script."
83
+ completecmd = subparsers.add_parser("completion",
84
+ description=complete_description)
85
+ completecmd.add_argument("-s", "--shell")
86
+
87
+ config_description = "Configure access to knot-dns-rest-api."
88
+ configcmd = subparsers.add_parser("config", description=config_description)
89
+ configcmd.add_argument("-b", "--baseurl")
90
+ configcmd.add_argument("-c", "--context")
91
+ configcmd.add_argument("-C",
92
+ "--current",
93
+ action=argparse.BooleanOptionalAction)
94
+ configcmd.add_argument("-l",
95
+ "--list",
96
+ action=argparse.BooleanOptionalAction,
97
+ dest="list_config")
98
+ configcmd.add_argument("-p", "--password")
99
+ configcmd.add_argument("-u", "--username")
100
+
101
+ delete_description = "Delete a record from the zone."
102
+ deletecmd = subparsers.add_parser("delete", description=delete_description)
103
+ deletecmd.add_argument("-d", "--data")
104
+ deletecmd.add_argument("-n", "--name")
105
+ deletecmd.add_argument("-r", "--rtype")
106
+ deletecmd.add_argument("-z", "--zone", required=True)
107
+
108
+ list_description = "List records."
109
+ listcmd = subparsers.add_parser("list", description=list_description)
110
+ listcmd.add_argument("-d", "--data")
111
+ listcmd.add_argument("-n", "--name")
112
+ listcmd.add_argument("-r", "--rtype")
113
+ listcmd.add_argument("-z", "--zone", required=False)
114
+
115
+ openstack_description = "Sync records with openstack."
116
+ openstackcmd = subparsers.add_parser("openstack-sync",
117
+ description=openstack_description)
118
+ openstackcmd.add_argument("-n", "--name", required=True)
119
+ openstackcmd.add_argument("-c", "--cloud", required=True)
120
+ openstackcmd.add_argument("-z", "--zone", required=True)
121
+
122
+ user_description = "View user information."
123
+ usercmd = subparsers.add_parser("user", description=user_description)
124
+ usercmd.add_argument("-u", "--username", default=None)
125
+
126
+ update_description = (
127
+ "Update a record in the zone. The record must exist in the zone.\n")
128
+ update_description += (
129
+ "In this case --data, --name, --rtype and --ttl switches are used\n")
130
+ update_description += (
131
+ "for searching for the appropriate record, while the --argument\n")
132
+ update_description += "switches are used for updating the record."
133
+ update_epilog = """Available arguments are:
134
+ data: New record data.
135
+ name: New record domain name.
136
+ rtype: New record type.
137
+ ttl: New record time to live (TTL)."""
138
+ updatecmd = subparsers.add_parser(
139
+ "update",
140
+ description=update_description,
141
+ epilog=update_epilog,
142
+ formatter_class=argparse.RawDescriptionHelpFormatter,
143
+ )
144
+ updatecmd.add_argument(
145
+ "-a",
146
+ "--argument",
147
+ action="append",
148
+ metavar="KEY=VALUE",
149
+ help="Specify key - value pairs to be updated: name=dns1.example.com."
150
+ + " or data=127.0.0.1 for example. --argument can be repeated",
151
+ required=True,
152
+ )
153
+ updatecmd.add_argument("-d", "--data", required=True)
154
+ updatecmd.add_argument("-n", "--name", required=True)
155
+ updatecmd.add_argument("-r", "--rtype", required=True)
156
+ updatecmd.add_argument("-t", "--ttl")
157
+ updatecmd.add_argument("-z", "--zone", required=True)
158
+
159
+ zone_description = "View zones."
160
+ subparsers.add_parser("zone", description=zone_description)
161
+
162
+ argcomplete.autocomplete(parser)
163
+
164
+ return parser
165
+
166
+
167
+ def error(description: str, error: str):
168
+ response = []
169
+ reply = {}
170
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406
171
+ reply["Code"] = 406
172
+ reply["Description"] = description
173
+ reply["Error"] = error
174
+ response.append(reply)
175
+ return response
176
+
177
+
178
+ def nested_out(input, tabs="") -> str:
179
+ string = ""
180
+ if isinstance(input, str) or isinstance(input, int):
181
+ string += f"{input}\n"
182
+ elif isinstance(input, dict):
183
+ for key, value in input.items():
184
+ string += f"{tabs}{key}: {nested_out(value, tabs + ' ')}"
185
+ elif isinstance(input, list):
186
+ for entry in input:
187
+ string += f"{tabs}\n{nested_out(entry, tabs + ' ')}"
188
+ return string
189
+
190
+
191
+ def output(response: list[dict], jsonout: bool = False):
192
+ try:
193
+ if jsonout:
194
+ print(json.dumps(response))
195
+ else:
196
+ print(nested_out(response))
197
+ except BrokenPipeError:
198
+ pass
199
+
200
+
201
+ def setup_url(
202
+ baseurl: str,
203
+ arguments: Union[None, list[str]],
204
+ data: Union[None, str],
205
+ name: Union[None, str],
206
+ rtype: Union[None, str],
207
+ ttl: Union[None, str],
208
+ zone: Union[None, str],
209
+ ) -> str:
210
+ url = baseurl + "/zones"
211
+ if zone:
212
+ if not zone.endswith("."):
213
+ zone += "."
214
+ url += "/{}".format(zone)
215
+ if name and zone:
216
+ if name.endswith(zone.rstrip(".")):
217
+ name += "."
218
+ url += "/records/{}".format(name)
219
+ if zone and name and rtype:
220
+ url += "/{}".format(rtype)
221
+ if data and zone and name and rtype:
222
+ url += "/{}".format(data)
223
+ if ttl and data and zone and name and rtype:
224
+ url += "/{}".format(ttl)
225
+ if data and zone and name and rtype and arguments:
226
+ url += "?"
227
+ for arg in arguments:
228
+ if not url.endswith("?"):
229
+ url += "&"
230
+ key, value = arg.split("=")
231
+ url += key + "=" + urllib.parse.quote_plus(value)
232
+
233
+ if ttl and (not rtype or not name or not zone):
234
+ output(
235
+ error(
236
+ "ttl only makes sense with rtype, name and zone",
237
+ "Missing parameter",
238
+ ))
239
+ sys.exit(1)
240
+ if rtype and (not name or not zone):
241
+ output(
242
+ error(
243
+ "rtype only makes sense with name and zone",
244
+ "Missing parameter",
245
+ ))
246
+ sys.exit(1)
247
+ if name and not zone:
248
+ output(error("name only makes sense with a zone", "Missing parameter"))
249
+ sys.exit(1)
250
+ return url
251
+
252
+
253
+ def split_url(url: str) -> dict:
254
+ parsed = urlparse(url, allow_fragments=False)
255
+ path = parsed.path
256
+ query = parsed.query
257
+ arguments: Union[None, list[str]] = query.split("&")
258
+ path_arr = path.split("/")
259
+ data: Union[None, str] = None
260
+ name: Union[None, str] = None
261
+ rtype: Union[None, str] = None
262
+ ttl: Union[None, str] = None
263
+ zone: Union[None, str] = None
264
+ if len(path_arr) > 2:
265
+ zone = path_arr[2]
266
+ if len(path_arr) > 4:
267
+ name = path_arr[4]
268
+ if len(path_arr) > 5:
269
+ rtype = path_arr[5]
270
+ if len(path_arr) > 6:
271
+ data = path_arr[6]
272
+ if len(path_arr) > 7:
273
+ ttl = path_arr[7]
274
+
275
+ return {
276
+ "arguments": arguments,
277
+ "data": data,
278
+ "name": name,
279
+ "rtype": rtype,
280
+ "ttl": ttl,
281
+ "zone": zone,
282
+ }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: knotctl
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A CLI for knotapi.
5
5
  Author-email: Micke Nordin <hej@mic.ke>
6
6
  Requires-Python: >=3.9
@@ -8,10 +8,12 @@ Description-Content-Type: text/markdown
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
10
10
  Classifier: Operating System :: OS Independent
11
+ License-File: LICENSE
11
12
  Requires-Dist: argcomplete==2.0.0
12
13
  Requires-Dist: pyyaml==6.0.1
13
14
  Requires-Dist: requests==2.27.1
14
15
  Requires-Dist: simplejson==3.17.6
16
+ Requires-Dist: openstacksdk==4.2.0
15
17
  Project-URL: Documentation, https://code.smolnet.org/micke/knotctl
16
18
  Project-URL: Source, https://code.smolnet.org/micke/knotctl
17
19
 
@@ -20,11 +22,15 @@ Project-URL: Source, https://code.smolnet.org/micke/knotctl
20
22
  This is a commandline tool for knotapi: https://gitlab.nic.cz/knot/knot-dns-rest
21
23
 
22
24
  ## Build and install
25
+ The preffered method of installation is via pipx:
26
+ ```
27
+ pipx install knotctl
28
+ ```
23
29
 
24
30
  To install using pip, run the following command in a virtual envrionment.
25
31
 
26
32
  ```
27
- python -m pip install "knotctl @ git+https://code.smolnet.org/micke/knotctl
33
+ python -m pip install knotctl
28
34
  ```
29
35
 
30
36
  To build and install as a deb-package
@@ -0,0 +1,11 @@
1
+ knotctl/__init__.py,sha256=XBYNdvml8P_GiElo6xMi9Lpw1foCiNwzDVQHWgqFpuY,6122
2
+ knotctl/__main__.py,sha256=-Y8KgiSSg0QF7YZRuMCR-vyMxQ-PizDSZ2bqUbQSFSk,64
3
+ knotctl/config/__init__.py,sha256=2CKbH_ZseBDbbzYL_N96o62BNRNvbHWJ0DRK0pvCqpI,2547
4
+ knotctl/openstack/__init__.py,sha256=ywKUSjiwHtqrVUMg1iJvd5wiW4CmycjlTU_DoqlOOhE,480
5
+ knotctl/runners/__init__.py,sha256=dFYhmnS9RyBoAdgGXph2NzUKRiFPpX7z-U9IEpV6XcU,7456
6
+ knotctl/utils/__init__.py,sha256=y8P74G_Ox1Y74iNDs_udYyJtBc-_WW-mtzj9hsxo6Q0,10808
7
+ knotctl-0.1.3.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
8
+ knotctl-0.1.3.dist-info/licenses/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
9
+ knotctl-0.1.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
10
+ knotctl-0.1.3.dist-info/METADATA,sha256=HEpnUzsSAawU5ez2QJ7UxAbEuF7LJ1xGZp0iQSc0f8s,7618
11
+ knotctl-0.1.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: flit 3.9.0
2
+ Generator: flit 3.12.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,6 +0,0 @@
1
- knotctl/__init__.py,sha256=Z2qrtQAzH-V6uPCCaPGwbV2QXhzCO49T5SHGyoFanVI,20018
2
- knotctl-0.1.1.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
3
- knotctl-0.1.1.dist-info/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
4
- knotctl-0.1.1.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
5
- knotctl-0.1.1.dist-info/METADATA,sha256=S1ap70TUQ3ClrEbyz_gylivlIMq1y4QmpPWWOONeoaw,7528
6
- knotctl-0.1.1.dist-info/RECORD,,