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.
- knotctl-0.1.3/CHANGELOG +0 -0
- knotctl-0.1.3/Makefile +13 -0
- {knotctl-0.1.1 → knotctl-0.1.3}/PKG-INFO +9 -3
- {knotctl-0.1.1 → knotctl-0.1.3}/README.md +5 -1
- knotctl-0.1.3/publiccode.yml +41 -0
- {knotctl-0.1.1 → knotctl-0.1.3}/pyproject.toml +27 -2
- {knotctl-0.1.1 → knotctl-0.1.3}/requirements.txt +1 -0
- knotctl-0.1.3/src/knotctl/__init__.py +204 -0
- knotctl-0.1.3/src/knotctl/__main__.py +4 -0
- knotctl-0.1.3/src/knotctl/config/__init__.py +84 -0
- knotctl-0.1.3/src/knotctl/openstack/__init__.py +17 -0
- knotctl-0.1.3/src/knotctl/runners/__init__.py +219 -0
- {knotctl-0.1.1/knotctl → knotctl-0.1.3/src/knotctl/utils}/__init__.py +118 -424
- {knotctl-0.1.1 → knotctl-0.1.3}/.gitignore +0 -0
- {knotctl-0.1.1 → knotctl-0.1.3}/LICENSE +0 -0
- {knotctl-0.1.1 → knotctl-0.1.3}/stdeb.cfg +0 -0
knotctl-0.1.3/CHANGELOG
ADDED
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: knotctl
|
|
3
|
-
Version: 0.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
|
|
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
|
|
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.
|
|
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
|
+
]
|
|
@@ -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,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
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
541
|
-
baseurl
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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
|