knotctl 0.1.0__py3-none-any.whl → 0.1.2__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,127 +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, islink, join, split
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
- with open(config_filename, "r") as fh:
40
- return yaml.safe_load(fh.read())
41
-
42
-
43
- def nested_out(input, tabs="") -> str:
44
- string = ""
45
- if isinstance(input, str) or isinstance(input, int):
46
- string += "{}\n".format(input)
47
- elif isinstance(input, dict):
48
- for key, value in input.items():
49
- string += "{}{}: {}".format(tabs, key, nested_out(value, tabs + " "))
50
- elif isinstance(input, list):
51
- for entry in input:
52
- string += "{}\n{}".format(tabs, nested_out(entry, tabs + " "))
53
- return string
54
-
55
-
56
- def output(response: list[dict], jsonout: bool = False):
57
- try:
58
- if jsonout:
59
- print(json.dumps(response))
60
- else:
61
- print(nested_out(response))
62
- except BrokenPipeError:
63
- pass
22
+ class Knotctl:
64
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()
65
29
 
66
- # Define the runner for each command
67
- def run_add(url: str, jsonout: bool, headers: dict):
68
- parsed = split_url(url)
69
- response = requests.put(url, headers=headers)
70
- out = response.json()
71
- if isinstance(out, list):
72
- for record in out:
73
- if (
74
- record["data"] == parsed["data"]
75
- and record["name"] == parsed["name"]
76
- and record["rtype"] == parsed["rtype"]
77
- ):
78
- output(record, jsonout)
79
- break
80
- else:
81
- 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()
82
35
 
36
+ return config
83
37
 
84
- def run_log(url: str, jsonout: bool, headers: dict):
85
- response = requests.get(url, headers=headers)
86
- string = response.content.decode("utf-8")
87
- if jsonout:
88
- out = []
89
- lines = string.splitlines()
90
- index = 0
91
- text = ""
92
- timestamp = ""
93
- while index < len(lines):
94
- line = lines[index]
95
- index += 1
96
- cur_has_timestamp = line.startswith("[")
97
- next_has_timestamp = index < len(lines) and lines[index].startswith(
98
- "["
99
- )
100
- # Simple case, just one line with timestamp
101
- if cur_has_timestamp and next_has_timestamp:
102
- timestamp = line.split(']')[0].split('[')[1]
103
- text = line.split(']')[1].lstrip(':').strip()
104
- out.append({'timestamp': timestamp, 'text': text})
105
- text = ""
106
- timestamp = ""
107
- # Start of multiline
108
- elif cur_has_timestamp:
109
- timestamp = line.split(']')[0].split('[')[1]
110
- text = line.split(']')[1].lstrip(':').strip()
111
- # End of multiline
112
- elif next_has_timestamp:
113
- text += f'\n{line.strip()}'
114
- out.append({'timestamp': timestamp, 'text': text})
115
- text = ""
116
- timestamp = ""
117
- # 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)
118
64
  else:
119
- text += f'\n{line.strip()}'
120
-
121
- else:
122
- out = string
123
-
124
- 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
125
74
 
126
75
 
127
76
  def run_complete(shell: Union[None, str]):
@@ -134,33 +83,27 @@ def run_complete(shell: Union[None, str]):
134
83
 
135
84
 
136
85
  def run_config(
137
- config_filename: str,
138
86
  context: Union[None, str] = None,
139
87
  baseurl: Union[None, str] = None,
88
+ list_config: bool = False,
140
89
  username: Union[None, str] = None,
141
90
  password: Union[None, str] = None,
142
91
  current: Union[None, str] = None,
143
92
  ):
93
+ conf = Config()
144
94
  if current:
145
- if os.path.islink(config_filename):
146
- actual_path = os.readlink(config_filename)
147
- print(actual_path.split('-')[-1])
148
- else:
149
- print("none")
95
+ print(conf.get_current())
150
96
  return
151
97
  config = {"baseurl": baseurl, "username": username, "password": password}
152
98
  needed = []
153
99
  if context:
154
- symlink = f"{config_filename}-{context}"
155
- found = os.path.isfile(symlink)
156
- if os.path.islink(config_filename):
157
- os.remove(config_filename)
158
- elif os.path.isfile(config_filename):
159
- os.rename(config_filename, symlink)
160
- os.symlink(symlink, config_filename)
161
- config_filename = symlink
100
+ found = conf.set_context(context)
162
101
  if found:
163
102
  return
103
+ if list_config:
104
+ config_data = conf.get_config_data()
105
+ output(config_data)
106
+ return
164
107
  if not baseurl:
165
108
  needed.append("baseurl")
166
109
  if not username:
@@ -171,8 +114,7 @@ def run_config(
171
114
  error(
172
115
  "Can not configure without {}".format(need),
173
116
  "No {}".format(need),
174
- )
175
- )
117
+ ))
176
118
  sys.exit(1)
177
119
  config[need] = input("Enter {}: ".format(need))
178
120
 
@@ -183,299 +125,46 @@ 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, jsonout: bool, headers: dict, ret=False) -> Union[None, str]:
200
- response = requests.get(url, headers=headers)
201
- string = response.json()
202
- if ret:
203
- return string
204
- else:
205
- output(string, jsonout)
206
-
207
-
208
- def run_update(url: str, jsonout: bool, headers: dict):
209
- response = requests.patch(url, headers=headers)
210
- output(response.json(), jsonout)
211
-
212
-
213
- # Set up the url
214
- def setup_url(
215
- baseurl: str,
216
- arguments: Union[None, list[str]],
217
- data: Union[None, str],
218
- name: Union[None, str],
219
- rtype: Union[None, str],
220
- ttl: Union[None, str],
221
- zone: Union[None, str],
222
- ) -> str:
223
- url = baseurl + "/zones"
224
- if zone:
225
- if not zone.endswith("."):
226
- zone += "."
227
- url += "/{}".format(zone)
228
- if name and zone:
229
- if name.endswith(zone.rstrip(".")):
230
- name += "."
231
- url += "/records/{}".format(name)
232
- if zone and name and rtype:
233
- url += "/{}".format(rtype)
234
- if data and zone and name and rtype:
235
- url += "/{}".format(data)
236
- if ttl and data and zone and name and rtype:
237
- url += "/{}".format(ttl)
238
- if data and zone and name and rtype and arguments:
239
- url += "?"
240
- for arg in arguments:
241
- if not url.endswith("?"):
242
- url += "&"
243
- key, value = arg.split("=")
244
- url += key + "=" + urllib.parse.quote_plus(value)
245
-
246
- if ttl and (not rtype or not name or not zone):
247
- output(
248
- error(
249
- "ttl only makes sense with rtype, name and zone",
250
- "Missing parameter",
251
- )
252
- )
253
- sys.exit(1)
254
- if rtype and (not name or not zone):
255
- output(
256
- error(
257
- "rtype only makes sense with name and zone",
258
- "Missing parameter",
259
- )
260
- )
261
- sys.exit(1)
262
- if name and not zone:
263
- output(error("name only makes sense with a zone", "Missing parameter"))
264
- sys.exit(1)
265
- return url
266
-
267
-
268
- def split_url(url: str) -> dict:
269
- parsed = urlparse(url, allow_fragments=False)
270
- path = parsed.path
271
- query = parsed.query
272
- arguments: Union[None, list[str]] = query.split("&")
273
- path_arr = path.split("/")
274
- data: Union[None, str] = None
275
- name: Union[None, str] = None
276
- rtype: Union[None, str] = None
277
- ttl: Union[None, str] = None
278
- zone: Union[None, str] = None
279
- if len(path_arr) > 2:
280
- zone = path_arr[2]
281
- if len(path_arr) > 4:
282
- name = path_arr[4]
283
- if len(path_arr) > 5:
284
- rtype = path_arr[5]
285
- if len(path_arr) > 6:
286
- data = path_arr[6]
287
- if len(path_arr) > 7:
288
- ttl = path_arr[7]
289
-
290
- return {
291
- "arguments": arguments,
292
- "data": data,
293
- "name": name,
294
- "rtype": rtype,
295
- "ttl": ttl,
296
- "zone": zone,
297
- }
128
+ conf.set_config(config)
298
129
 
299
130
 
300
131
  # Entry point to program
301
132
  def main() -> int:
302
- description = """Manage DNS records with knot dns rest api:
303
- * https://gitlab.nic.cz/knot/knot-dns-rest"""
304
-
305
- epilog = """
306
- The Domain Name System specifies a database of information
307
- elements for network resources. The types of information
308
- elements are categorized and organized with a list of DNS
309
- record types, the resource records (RRs). Each record has a
310
- name, a type, an expiration time (time to live), and
311
- type-specific data.
312
-
313
- The following is a list of terms used in this program:
314
- ----------------------------------------------------------------
315
- | Vocabulary | Description |
316
- ----------------------------------------------------------------
317
- | zone | A DNS zone is a specific portion of the DNS |
318
- | | namespace in the Domain Name System (DNS), |
319
- | | which a specific organization or administrator |
320
- | | manages. |
321
- ----------------------------------------------------------------
322
- | name | In the Internet, a domain name is a string that |
323
- | | identifies a realm of administrative autonomy, |
324
- | | authority or control. Domain names are often |
325
- | | used to identify services provided through the |
326
- | | Internet, such as websites, email services and |
327
- | | more. |
328
- ----------------------------------------------------------------
329
- | rtype | A record type indicates the format of the data |
330
- | | and it gives a hint of its intended use. For |
331
- | | example, the A record is used to translate from |
332
- | | a domain name to an IPv4 address, the NS record |
333
- | | lists which name servers can answer lookups on |
334
- | | a DNS zone, and the MX record specifies the |
335
- | | mail server used to handle mail for a domain |
336
- | | specified in an e-mail address. |
337
- ----------------------------------------------------------------
338
- | data | A records data is of type-specific relevance, |
339
- | | such as the IP address for address records, or |
340
- | | the priority and hostname for MX records. |
341
- ----------------------------------------------------------------
342
-
343
- This information was compiled from Wikipedia:
344
- * https://en.wikipedia.org/wiki/DNS_zone
345
- * https://en.wikipedia.org/wiki/Domain_Name_System
346
- * https://en.wikipedia.org/wiki/Zone_file
347
- """
348
- # Grab user input
349
- parser = argparse.ArgumentParser(
350
- description=description,
351
- epilog=epilog,
352
- formatter_class=argparse.RawDescriptionHelpFormatter,
353
- )
354
- parser.add_argument("--json", action=argparse.BooleanOptionalAction)
355
- subparsers = parser.add_subparsers(dest="command")
356
-
357
- add_description = "Add a new record to the zone."
358
- addcmd = subparsers.add_parser("add", description=add_description)
359
- addcmd.add_argument("-d", "--data", required=True)
360
- addcmd.add_argument("-n", "--name", required=True)
361
- addcmd.add_argument("-r", "--rtype", required=True)
362
- addcmd.add_argument("-t", "--ttl")
363
- addcmd.add_argument("-z", "--zone", required=True)
364
-
365
- auditlog_description = "Audit the log file for errors."
366
- subparsers.add_parser("auditlog", description=auditlog_description)
367
-
368
- changelog_description = "View the changelog of a zone."
369
- changelogcmd = subparsers.add_parser("changelog", description=changelog_description)
370
- changelogcmd.add_argument("-z", "--zone", required=True)
371
-
372
- complete_description = "Generate shell completion script."
373
- completecmd = subparsers.add_parser("completion", description=complete_description)
374
- completecmd.add_argument("-s", "--shell")
375
-
376
- config_description = "Configure access to knot-dns-rest-api."
377
- configcmd = subparsers.add_parser("config", description=config_description)
378
- configcmd.add_argument("-b", "--baseurl")
379
- configcmd.add_argument("-c", "--context")
380
- configcmd.add_argument("-C", "--current", action=argparse.BooleanOptionalAction)
381
- configcmd.add_argument("-p", "--password")
382
- configcmd.add_argument("-u", "--username")
383
-
384
- delete_description = "Delete a record from the zone."
385
- deletecmd = subparsers.add_parser("delete", description=delete_description)
386
- deletecmd.add_argument("-d", "--data")
387
- deletecmd.add_argument("-n", "--name")
388
- deletecmd.add_argument("-r", "--rtype")
389
- deletecmd.add_argument("-z", "--zone", required=True)
390
-
391
- list_description = "List records in the zone."
392
- listcmd = subparsers.add_parser("list", description=list_description)
393
- listcmd.add_argument("-d", "--data")
394
- listcmd.add_argument("-n", "--name")
395
- listcmd.add_argument("-r", "--rtype")
396
- listcmd.add_argument("-z", "--zone", required=True)
397
-
398
- update_description = (
399
- "Update a record in the zone. The record must exist in the zone.\n"
400
- )
401
- update_description += (
402
- "In this case --data, --name, --rtype and --ttl switches are used\n"
403
- )
404
- update_description += (
405
- "for searching for the appropriate record, while the --argument\n"
406
- )
407
- update_description += "switches are used for updating the record."
408
- update_epilog = """Available arguments are:
409
- data: New record data.
410
- name: New record domain name.
411
- rtype: New record type.
412
- ttl: New record time to live (TTL)."""
413
- updatecmd = subparsers.add_parser(
414
- "update",
415
- description=update_description,
416
- epilog=update_epilog,
417
- formatter_class=argparse.RawDescriptionHelpFormatter,
418
- )
419
- updatecmd.add_argument(
420
- "-a",
421
- "--argument",
422
- action="append",
423
- metavar="KEY=VALUE",
424
- help="Specify key - value pairs to be updated: name=dns1.example.com. or data=127.0.0.1 for example. --argument can be repeated",
425
- required=True,
426
- )
427
- updatecmd.add_argument("-d", "--data", required=True)
428
- updatecmd.add_argument("-n", "--name", required=True)
429
- updatecmd.add_argument("-r", "--rtype", required=True)
430
- updatecmd.add_argument("-t", "--ttl")
431
- updatecmd.add_argument("-z", "--zone", required=True)
432
-
433
- argcomplete.autocomplete(parser)
133
+ parser = get_parser()
434
134
  args = parser.parse_args()
435
135
  if args.command == "completion":
436
136
  run_complete(args.shell)
437
137
  return 0
438
138
 
439
- # Make sure we have config
440
- config_basepath = join(environ["HOME"], ".knot")
441
- config_filename = join(config_basepath, "config")
442
-
443
- if not isdir(config_basepath):
444
- mkdir(config_basepath)
139
+ knotctl = Knotctl()
445
140
 
446
141
  if args.command == "config":
447
142
  run_config(
448
- config_filename, args.context, args.baseurl, args.username, args.password, args.current
143
+ args.context,
144
+ args.baseurl,
145
+ args.list_config,
146
+ args.username,
147
+ args.password,
148
+ args.current,
449
149
  )
450
150
  return 0
451
151
 
452
- if not isfile(config_filename):
453
- print("You need to configure knotctl before proceeding")
454
- run_config(config_filename)
455
-
456
- config = get_config(config_filename)
152
+ config = knotctl.get_config()
457
153
  baseurl = config["baseurl"]
458
- username = config["username"]
459
- password = config["password"]
460
-
461
- # Authenticate
462
- basic = HTTPBasicAuth(username, password)
463
- response = requests.get(baseurl + "/user/login", auth=basic)
464
- try:
465
- token = response.json()["token"]
466
- except KeyError:
467
- output(response.json())
154
+ token = knotctl.conf.get_token()
155
+ if token == "":
156
+ print("Could not get token, exiting")
468
157
  return 1
469
- except requests.exceptions.JSONDecodeError:
470
- output(error("Could not decode api response as JSON", "Could not decode"))
471
- return 1
472
- headers = {"Authorization": "Bearer {}".format(token)}
473
158
 
474
159
  # Route based on command
475
160
  url = ""
476
161
  ttl = None
162
+ quotedata = None
163
+ user = config["username"]
477
164
  if "ttl" in args:
478
165
  ttl = args.ttl
166
+ if "data" in args:
167
+ quotedata = quote(args.data, safe="")
479
168
  if args.command != "update":
480
169
  args.argument = None
481
170
  if args.command == "add" and not ttl:
@@ -484,16 +173,21 @@ def main() -> int:
484
173
  else:
485
174
  zname = args.zone + "."
486
175
  soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
487
- soa_json = run_list(soa_url, True, headers, ret=True)
176
+ soa_json = knotctl.runner.lister(soa_url, True, ret=True)
488
177
  ttl = soa_json[0]["ttl"]
489
- if args.command in ["auditlog", "changelog"]:
178
+ if args.command == "user":
179
+ if args.username:
180
+ user = args.username
181
+ if args.command in [
182
+ "auditlog", "changelog", "openstack-sync", "user", "zone"
183
+ ]:
490
184
  pass
491
185
  else:
492
186
  try:
493
187
  url = setup_url(
494
188
  baseurl,
495
189
  args.argument,
496
- args.data,
190
+ quotedata,
497
191
  args.name,
498
192
  args.rtype,
499
193
  ttl,
@@ -503,29 +197,7 @@ def main() -> int:
503
197
  parser.print_help(sys.stderr)
504
198
  return 1
505
199
 
506
- try:
507
- if args.command == "add":
508
- run_add(url, args.json, headers)
509
- elif args.command == "delete":
510
- run_delete(url, args.json, headers)
511
- elif args.command == "list":
512
- run_list(url, args.json, headers)
513
- elif args.command == "update":
514
- run_update(url, args.json, headers)
515
- elif args.command == "auditlog":
516
- url = baseurl + "/user/auditlog"
517
- run_log(url, args.json, headers)
518
- elif args.command == "changelog":
519
- url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
520
- run_log(url, args.json, headers)
521
- else:
522
- parser.print_help(sys.stderr)
523
- return 2
524
- except requests.exceptions.RequestException as e:
525
- output(error(e, "Could not connect to server"))
526
- except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
527
- output(error("Could not decode api response as JSON", "Could not decode"))
528
- return 0
200
+ return knotctl.run(url, args, baseurl, parser, user)
529
201
 
530
202
 
531
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.0
3
+ Version: 0.1.2
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
@@ -208,9 +214,9 @@ options:
208
214
  ### LIST
209
215
 
210
216
  ```
211
- usage: knotctl list [-h] [-d DATA] [-n NAME] [-r RTYPE] -z ZONE
217
+ usage: knotctl list [-h] [-d DATA] [-n NAME] [-r RTYPE] [-z ZONE]
212
218
 
213
- List records in the zone.
219
+ List records.
214
220
 
215
221
  options:
216
222
  -h, --help show this help message and exit
@@ -249,3 +255,26 @@ Available arguments are:
249
255
  ttl: New record time to live (TTL).
250
256
  ```
251
257
 
258
+ ### USER
259
+ ```
260
+ usage: knotctl user [-h] [-u USERNAME]
261
+
262
+ View user information.
263
+
264
+ options:
265
+ -h, --help show this help message and exit
266
+ -u USERNAME, --username USERNAME
267
+ ```
268
+
269
+ ### ZONE
270
+
271
+ ```
272
+ usage: knotctl zone
273
+
274
+ List zones.
275
+
276
+ options:
277
+ -h, --help show this help message and exit
278
+ ```
279
+
280
+
@@ -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.2.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
8
+ knotctl-0.1.2.dist-info/licenses/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
9
+ knotctl-0.1.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
10
+ knotctl-0.1.2.dist-info/METADATA,sha256=1GEPN-B9n81Fwg6CO-RWKNwmjeeDHkalnyaWOBeRcck,7618
11
+ knotctl-0.1.2.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=kqE3iZEX2pxMSfXTSDyJUCayRanSpHLgdd4EqVR1kV0,18422
2
- knotctl-0.1.0.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
3
- knotctl-0.1.0.dist-info/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
4
- knotctl-0.1.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
5
- knotctl-0.1.0.dist-info/METADATA,sha256=yqUFEkuHQ_GgpuV2TJox78ujwdKagC0dYEtrCUGbwBk,7236
6
- knotctl-0.1.0.dist-info/RECORD,,