knotctl 0.1.1__tar.gz → 0.1.3__tar.gz

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.
File without changes
knotctl-0.1.3/Makefile ADDED
@@ -0,0 +1,13 @@
1
+ .PHONY: publish
2
+ publish:
3
+ flit publish
4
+
5
+ .PHONY: deb
6
+ deb:
7
+ briefcase update linux system --target debian:testing
8
+ briefcase build linux system --target debian:testing
9
+ briefcase package linux system --target debian:testing
10
+
11
+ .PHONY: clean
12
+ clean:
13
+ rm -rf build dist
@@ -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
@@ -3,11 +3,15 @@
3
3
  This is a commandline tool for knotapi: https://gitlab.nic.cz/knot/knot-dns-rest
4
4
 
5
5
  ## Build and install
6
+ The preffered method of installation is via pipx:
7
+ ```
8
+ pipx install knotctl
9
+ ```
6
10
 
7
11
  To install using pip, run the following command in a virtual envrionment.
8
12
 
9
13
  ```
10
- python -m pip install "knotctl @ git+https://code.smolnet.org/micke/knotctl
14
+ python -m pip install knotctl
11
15
  ```
12
16
 
13
17
  To build and install as a deb-package
@@ -0,0 +1,41 @@
1
+ publiccodeYmlVersion: "0.4"
2
+
3
+ name: knotctl
4
+ url: "https://platform.sunet.se/SUNET/knotctl"
5
+ platforms:
6
+ - linux
7
+ - mac
8
+
9
+ categories:
10
+ - it-service-management
11
+
12
+ developmentStatus: development
13
+
14
+ softwareType: "standalone/desktop"
15
+
16
+ description:
17
+ en:
18
+ shortDescription: >
19
+ This is a commandline tool for knotapirestapi
20
+ https://gitlab.nic.cz/knot/knot-dns-rest
21
+
22
+ longDescription: >
23
+ This is a commandline tool for knotapirestapi
24
+ https://gitlab.nic.cz/knot/knot-dns-rest
25
+
26
+ features:
27
+ - DNS management
28
+
29
+ legal:
30
+ license: GPL-3.0
31
+
32
+ maintenance:
33
+ type: "community"
34
+
35
+ contacts:
36
+ - name: Micke Nordin <kano@sunet.se>
37
+
38
+ localisation:
39
+ localisationReady: false
40
+ availableLanguages:
41
+ - en
@@ -16,13 +16,14 @@ classifiers=[
16
16
  "Operating System :: OS Independent",
17
17
  ]
18
18
  requires-python= ">=3.9"
19
- version = "0.1.1"
19
+ version = "0.1.3"
20
20
 
21
21
  dependencies = [
22
22
  "argcomplete==2.0.0",
23
23
  "pyyaml==6.0.1",
24
24
  "requests==2.27.1",
25
25
  "simplejson==3.17.6",
26
+ "openstacksdk==4.2.0",
26
27
  ]
27
28
 
28
29
  [project.urls]
@@ -34,4 +35,28 @@ knotctl="knotctl:main"
34
35
 
35
36
 
36
37
  [tool.flit.sdist]
37
- include = ["LICENSE",]
38
+ include = ["LICENSE", "README.md"]
39
+
40
+ [tool.briefcase]
41
+ project_name = "knotctl"
42
+ bundle = "org.smolnet"
43
+ version = "0.1.3"
44
+
45
+ [tool.briefcase.app.knotctl]
46
+ formal_name = "knotctl"
47
+ description = "A CLI for knotapi."
48
+ long_description = "A CLI for knotapi."
49
+ sources = ['src/knotctl']
50
+ console_app = "True"
51
+ requires = [
52
+ "argcomplete==2.0.0",
53
+ "pyyaml==6.0.1",
54
+ "requests==2.27.1",
55
+ "simplejson==3.17.6",
56
+ "openstacksdk==4.2.0",
57
+ ]
58
+
59
+ [tool.briefcase.app.knotctl.linux.system.debian]
60
+ system_runtime_requires = [
61
+ "libpython3.13",
62
+ ]
@@ -2,3 +2,4 @@ argcomplete==2.0.0
2
2
  pyyaml==6.0.1
3
3
  requests==2.27.1
4
4
  simplejson==3.17.6
5
+ openstacksdk==4.2.0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import getpass
4
+ import os
5
+ import sys
6
+ from typing import Union
7
+ from urllib.parse import quote
8
+
9
+ import requests
10
+ from simplejson.errors import JSONDecodeError as SimplejsonJSONDecodeError
11
+
12
+ from .config import Config
13
+ from .runners import Run
14
+ from .utils import error, get_parser, output, setup_url
15
+
16
+ try:
17
+ from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
18
+ except ImportError:
19
+ from requests.exceptions import InvalidJSONError as RequestsJSONDecodeError
20
+
21
+
22
+ class Knotctl:
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()
29
+
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()
35
+
36
+ return config
37
+
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)
64
+ else:
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
74
+
75
+
76
+ def run_complete(shell: Union[None, str]):
77
+ if not shell or shell in ["bash", "zsh"]:
78
+ os.system("register-python-argcomplete knotctl")
79
+ elif shell == "fish":
80
+ os.system("register-python-argcomplete --shell fish knotctl")
81
+ elif shell == "tcsh":
82
+ os.system("register-python-argcomplete --shell tcsh knotctl")
83
+
84
+
85
+ def run_config(
86
+ context: Union[None, str] = None,
87
+ baseurl: Union[None, str] = None,
88
+ list_config: bool = False,
89
+ username: Union[None, str] = None,
90
+ password: Union[None, str] = None,
91
+ current: Union[None, str] = None,
92
+ ):
93
+ conf = Config()
94
+ if current:
95
+ print(conf.get_current())
96
+ return
97
+ config = {"baseurl": baseurl, "username": username, "password": password}
98
+ needed = []
99
+ if context:
100
+ found = conf.set_context(context)
101
+ if found:
102
+ return
103
+ if list_config:
104
+ config_data = conf.get_config_data()
105
+ output(config_data)
106
+ return
107
+ if not baseurl:
108
+ needed.append("baseurl")
109
+ if not username:
110
+ needed.append("username")
111
+ for need in needed:
112
+ if need == "":
113
+ output(
114
+ error(
115
+ "Can not configure without {}".format(need),
116
+ "No {}".format(need),
117
+ ))
118
+ sys.exit(1)
119
+ config[need] = input("Enter {}: ".format(need))
120
+
121
+ if not password:
122
+ try:
123
+ config["password"] = getpass.getpass()
124
+ except EOFError:
125
+ output(error("Can not configure without password", "No password"))
126
+ sys.exit(1)
127
+
128
+ conf.set_config(config)
129
+
130
+
131
+ # Entry point to program
132
+ def main() -> int:
133
+ parser = get_parser()
134
+ args = parser.parse_args()
135
+ if args.command == "completion":
136
+ run_complete(args.shell)
137
+ return 0
138
+
139
+ knotctl = Knotctl()
140
+
141
+ if args.command == "config":
142
+ run_config(
143
+ args.context,
144
+ args.baseurl,
145
+ args.list_config,
146
+ args.username,
147
+ args.password,
148
+ args.current,
149
+ )
150
+ return 0
151
+
152
+ config = knotctl.get_config()
153
+ baseurl = config["baseurl"]
154
+ token = knotctl.conf.get_token()
155
+ if token == "":
156
+ print("Could not get token, exiting")
157
+ return 1
158
+
159
+ # Route based on command
160
+ url = ""
161
+ ttl = None
162
+ quotedata = None
163
+ user = config["username"]
164
+ if "ttl" in args:
165
+ ttl = args.ttl
166
+ if "data" in args:
167
+ quotedata = quote(args.data, safe="")
168
+ if args.command != "update":
169
+ args.argument = None
170
+ if args.command == "add" and not ttl:
171
+ if args.zone.endswith("."):
172
+ zname = args.zone
173
+ else:
174
+ zname = args.zone + "."
175
+ soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
176
+ soa_json = knotctl.runner.lister(soa_url, True, ret=True)
177
+ ttl = soa_json[0]["ttl"]
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
+ ]:
184
+ pass
185
+ else:
186
+ try:
187
+ url = setup_url(
188
+ baseurl,
189
+ args.argument,
190
+ quotedata,
191
+ args.name,
192
+ args.rtype,
193
+ ttl,
194
+ args.zone,
195
+ )
196
+ except AttributeError:
197
+ parser.print_help(sys.stderr)
198
+ return 1
199
+
200
+ return knotctl.run(url, args, baseurl, parser, user)
201
+
202
+
203
+ if __name__ == "__main__":
204
+ sys.exit(main())
@@ -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)
@@ -1,318 +1,11 @@
1
- #!/usr/bin/env python3
2
-
3
1
  import argparse
4
- import getpass
2
+ import argcomplete
5
3
  import json
6
- import os
7
4
  import sys
8
5
  import urllib.parse
9
- from os import environ, mkdir
10
- from os.path import isdir, isfile, join
11
6
  from typing import Union
12
7
  from urllib.parse import urlparse
13
8
 
14
- import argcomplete
15
- import requests
16
- import yaml
17
- from requests.models import HTTPBasicAuth
18
- from simplejson.errors import JSONDecodeError as SimplejsonJSONDecodeError
19
-
20
- try:
21
- from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
22
- except ImportError:
23
- from requests.exceptions import InvalidJSONError as RequestsJSONDecodeError
24
-
25
-
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
68
-
69
-
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)
84
-
85
-
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
119
- else:
120
- text += f"\n{line.strip()}"
121
-
122
- else:
123
- out = string
124
-
125
- output(out, jsonout)
126
-
127
-
128
- def run_complete(shell: Union[None, str]):
129
- if not shell or shell in ["bash", "zsh"]:
130
- os.system("register-python-argcomplete knotctl")
131
- elif shell == "fish":
132
- os.system("register-python-argcomplete --shell fish knotctl")
133
- elif shell == "tcsh":
134
- os.system("register-python-argcomplete --shell tcsh knotctl")
135
-
136
-
137
- def run_config(
138
- config_filename: str,
139
- context: Union[None, str] = None,
140
- baseurl: Union[None, str] = None,
141
- username: Union[None, str] = None,
142
- password: Union[None, str] = None,
143
- current: Union[None, str] = None,
144
- ):
145
- 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")
151
- return
152
- config = {"baseurl": baseurl, "username": username, "password": password}
153
- needed = []
154
- 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
163
- if found:
164
- return
165
- if not baseurl:
166
- needed.append("baseurl")
167
- if not username:
168
- needed.append("username")
169
- for need in needed:
170
- if need == "":
171
- output(
172
- error(
173
- "Can not configure without {}".format(need),
174
- "No {}".format(need),
175
- ))
176
- sys.exit(1)
177
- config[need] = input("Enter {}: ".format(need))
178
-
179
- if not password:
180
- try:
181
- config["password"] = getpass.getpass()
182
- except EOFError:
183
- output(error("Can not configure without password", "No password"))
184
- sys.exit(1)
185
-
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
9
 
317
10
  def get_parser() -> dict:
318
11
  description = """Manage DNS records with knot dns rest api:
@@ -398,6 +91,10 @@ def get_parser() -> dict:
398
91
  configcmd.add_argument("-C",
399
92
  "--current",
400
93
  action=argparse.BooleanOptionalAction)
94
+ configcmd.add_argument("-l",
95
+ "--list",
96
+ action=argparse.BooleanOptionalAction,
97
+ dest="list_config")
401
98
  configcmd.add_argument("-p", "--password")
402
99
  configcmd.add_argument("-u", "--username")
403
100
 
@@ -415,6 +112,13 @@ def get_parser() -> dict:
415
112
  listcmd.add_argument("-r", "--rtype")
416
113
  listcmd.add_argument("-z", "--zone", required=False)
417
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
+
418
122
  user_description = "View user information."
419
123
  usercmd = subparsers.add_parser("user", description=user_description)
420
124
  usercmd.add_argument("-u", "--username", default=None)
@@ -460,129 +164,119 @@ def get_parser() -> dict:
460
164
  return parser
461
165
 
462
166
 
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
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
512
176
 
513
177
 
514
- # Entry point to program
515
- def main() -> int:
516
- parser = get_parser()
517
- args = parser.parse_args()
518
- if args.command == "completion":
519
- run_complete(args.shell)
520
- return 0
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
521
189
 
522
- # Make sure we have config
523
- config_basepath = join(environ["HOME"], ".knot")
524
- config_filename = join(config_basepath, "config")
525
190
 
526
- if not isdir(config_basepath):
527
- mkdir(config_basepath)
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
528
199
 
529
- if args.command == "config":
530
- run_config(
531
- config_filename,
532
- args.context,
533
- args.baseurl,
534
- args.username,
535
- args.password,
536
- args.current,
537
- )
538
- return 0
539
200
 
540
- config = get_config(config_filename)
541
- baseurl = config["baseurl"]
542
- token = get_token(config)
543
- if token == "":
544
- print("Could not get token, exiting")
545
- return 1
546
- headers = {"Authorization": "Bearer {}".format(token)}
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)
547
232
 
548
- # Route based on command
549
- url = ""
550
- ttl = None
551
- user = config["username"]
552
- if "ttl" in args:
553
- ttl = args.ttl
554
- if args.command != "update":
555
- args.argument = None
556
- if args.command == "add" and not ttl:
557
- if args.zone.endswith("."):
558
- zname = args.zone
559
- else:
560
- zname = args.zone + "."
561
- soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
562
- soa_json = run_list(soa_url, True, headers, ret=True)
563
- ttl = soa_json[0]["ttl"]
564
- if args.command == "user":
565
- if args.username:
566
- user = args.username
567
- if args.command in ["auditlog", "changelog", "user", "zone"]:
568
- pass
569
- else:
570
- try:
571
- url = setup_url(
572
- baseurl,
573
- args.argument,
574
- args.data,
575
- args.name,
576
- args.rtype,
577
- ttl,
578
- args.zone,
579
- )
580
- except AttributeError:
581
- parser.print_help(sys.stderr)
582
- return 1
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
583
251
 
584
- return run(url, args, headers, baseurl, parser, user)
585
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]
586
274
 
587
- if __name__ == "__main__":
588
- sys.exit(main())
275
+ return {
276
+ "arguments": arguments,
277
+ "data": data,
278
+ "name": name,
279
+ "rtype": rtype,
280
+ "ttl": ttl,
281
+ "zone": zone,
282
+ }
File without changes
File without changes
File without changes