dt-extensions-sdk 1.2.5__py3-none-any.whl → 1.2.7__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.
- {dt_extensions_sdk-1.2.5.dist-info → dt_extensions_sdk-1.2.7.dist-info}/METADATA +3 -5
- dt_extensions_sdk-1.2.7.dist-info/RECORD +34 -0
- {dt_extensions_sdk-1.2.5.dist-info → dt_extensions_sdk-1.2.7.dist-info}/licenses/LICENSE.txt +9 -9
- dynatrace_extension/__about__.py +5 -5
- dynatrace_extension/__init__.py +27 -27
- dynatrace_extension/cli/__init__.py +5 -5
- dynatrace_extension/cli/create/__init__.py +1 -1
- dynatrace_extension/cli/create/create.py +76 -76
- dynatrace_extension/cli/create/extension_template/.gitignore.template +160 -160
- dynatrace_extension/cli/create/extension_template/README.md.template +33 -33
- dynatrace_extension/cli/create/extension_template/activation.json.template +15 -15
- dynatrace_extension/cli/create/extension_template/extension/activationSchema.json.template +118 -118
- dynatrace_extension/cli/create/extension_template/extension/extension.yaml.template +17 -17
- dynatrace_extension/cli/create/extension_template/extension_name/__main__.py.template +40 -43
- dynatrace_extension/cli/create/extension_template/setup.py.template +28 -28
- dynatrace_extension/cli/main.py +437 -437
- dynatrace_extension/cli/schema.py +129 -129
- dynatrace_extension/sdk/__init__.py +3 -3
- dynatrace_extension/sdk/activation.py +43 -43
- dynatrace_extension/sdk/callback.py +145 -145
- dynatrace_extension/sdk/communication.py +483 -483
- dynatrace_extension/sdk/event.py +19 -19
- dynatrace_extension/sdk/extension.py +1076 -1070
- dynatrace_extension/sdk/helper.py +191 -191
- dynatrace_extension/sdk/metric.py +118 -118
- dynatrace_extension/sdk/runtime.py +67 -67
- dynatrace_extension/sdk/snapshot.py +198 -198
- dynatrace_extension/sdk/vendor/mureq/LICENSE +13 -13
- dynatrace_extension/sdk/vendor/mureq/mureq.py +448 -448
- dt_extensions_sdk-1.2.5.dist-info/RECORD +0 -34
- {dt_extensions_sdk-1.2.5.dist-info → dt_extensions_sdk-1.2.7.dist-info}/WHEEL +0 -0
- {dt_extensions_sdk-1.2.5.dist-info → dt_extensions_sdk-1.2.7.dist-info}/entry_points.txt +0 -0
dynatrace_extension/cli/main.py
CHANGED
@@ -1,437 +1,437 @@
|
|
1
|
-
import os
|
2
|
-
import shutil
|
3
|
-
import stat
|
4
|
-
import subprocess
|
5
|
-
import sys
|
6
|
-
from pathlib import Path
|
7
|
-
from typing import List, Optional
|
8
|
-
|
9
|
-
import typer
|
10
|
-
from dtcli.server_api import upload as dt_cli_upload # type: ignore
|
11
|
-
from dtcli.server_api import validate as dt_cli_validate
|
12
|
-
from rich.console import Console
|
13
|
-
|
14
|
-
from ..__about__ import __version__
|
15
|
-
from .create import generate_extension, is_pep8_compliant
|
16
|
-
from .schema import ExtensionYaml
|
17
|
-
|
18
|
-
app = typer.Typer(pretty_exceptions_show_locals=False, pretty_exceptions_enable=False)
|
19
|
-
console = Console()
|
20
|
-
|
21
|
-
# if we are not python 3.10.X, exit with an error
|
22
|
-
if sys.version_info < (3, 10) or sys.version_info >= (3, 11):
|
23
|
-
console.print(f"Python 3.10.X is required to build extensions, you are using {sys.version_info}", style="bold red")
|
24
|
-
sys.exit(1)
|
25
|
-
|
26
|
-
CERT_DIR_ENVIRONMENT_VAR = "DT_CERTIFICATES_FOLDER"
|
27
|
-
CERTIFICATE_DEFAULT_PATH = Path.home() / ".dynatrace" / "certificates"
|
28
|
-
|
29
|
-
|
30
|
-
# show version
|
31
|
-
@app.command()
|
32
|
-
def version():
|
33
|
-
"""
|
34
|
-
Show the version of the CLI
|
35
|
-
"""
|
36
|
-
console.print(f"dt-extensions-sdk version {__version__}", style="bold green")
|
37
|
-
|
38
|
-
|
39
|
-
@app.command()
|
40
|
-
def run(
|
41
|
-
extension_dir: Path = typer.Argument("."),
|
42
|
-
activation_config: str = "activation.json",
|
43
|
-
fast_check: bool = typer.Option(False, "--fastcheck"),
|
44
|
-
local_ingest: bool = typer.Option(False, "--local-ingest"),
|
45
|
-
local_ingest_port: int = typer.Option(14499, "--local-ingest-port"),
|
46
|
-
print_metrics: bool = typer.Option(True),
|
47
|
-
):
|
48
|
-
"""
|
49
|
-
Runs an extension, this is used during development to locally run and test an extension
|
50
|
-
|
51
|
-
:param extension_dir: The directory of the extension, by default this is the current directory
|
52
|
-
:param activation_config: The activation config file, by default this is activation.json
|
53
|
-
:param fast_check: If true, run a fastcheck and exits
|
54
|
-
:param local_ingest: If true, send metrics to localhost:14499 on top of printing them
|
55
|
-
:param local_ingest_port: The port to send metrics to, by default this is 14499
|
56
|
-
:param print_metrics: If true, print metrics to the console
|
57
|
-
"""
|
58
|
-
|
59
|
-
# This parses the yaml, which validates it before running
|
60
|
-
extension_yaml = ExtensionYaml(extension_dir / "extension/extension.yaml")
|
61
|
-
try:
|
62
|
-
command = [sys.executable, "-m", extension_yaml.python.runtime.module, "--activationconfig", activation_config]
|
63
|
-
if fast_check:
|
64
|
-
command.append("--fastcheck")
|
65
|
-
if local_ingest:
|
66
|
-
command.append("--local-ingest")
|
67
|
-
command.append(f"--local-ingest-port={local_ingest_port}")
|
68
|
-
if not print_metrics:
|
69
|
-
command.append("--no-print-metrics")
|
70
|
-
run_process(command, cwd=extension_dir)
|
71
|
-
except KeyboardInterrupt:
|
72
|
-
console.print("\nRun interrupted with a KeyboardInterrupt, stopping", style="bold yellow")
|
73
|
-
|
74
|
-
|
75
|
-
@app.command(help="Runs wheel, assemble and sign. Downloads dependencies, creates and signs the extension zip file")
|
76
|
-
def build(
|
77
|
-
extension_dir: Path = typer.Argument(Path("."), help="Path to the python extension"),
|
78
|
-
private_key: Path = typer.Option(
|
79
|
-
Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
|
80
|
-
"--private-key",
|
81
|
-
"-k",
|
82
|
-
help="Path to the dev fused key-certificate",
|
83
|
-
),
|
84
|
-
target_directory: Optional[Path] = typer.Option(None, "--target-directory", "-t"),
|
85
|
-
extra_platforms: Optional[list[str]] = typer.Option(
|
86
|
-
None, "--extra-platform", "-e", help="Download wheels for an extra platform"
|
87
|
-
),
|
88
|
-
extra_index_url: Optional[str] = typer.Option(
|
89
|
-
None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
|
90
|
-
),
|
91
|
-
find_links: Optional[str] = typer.Option(
|
92
|
-
None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
|
93
|
-
),
|
94
|
-
):
|
95
|
-
"""
|
96
|
-
Builds and signs an extension using the developer fused key-certificate
|
97
|
-
|
98
|
-
:param extension_dir: The directory of the extension, by default this is the current directory
|
99
|
-
:param private_key: The path to the developer fused key-certificate, if not specified, we try to locate the file developer.pem under the environment
|
100
|
-
variable DT_CERTIFICATES_FOLDER
|
101
|
-
:param target_directory: The directory to put the extension zip file in, if not specified, we put it in the dist
|
102
|
-
folder
|
103
|
-
:param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
|
104
|
-
:param extra_index_url: Extra index url to use when downloading dependencies
|
105
|
-
:param find_links: Extra index url to use when downloading dependencies
|
106
|
-
"""
|
107
|
-
console.print(f"Building and signing extension from {extension_dir} to {target_directory}", style="cyan")
|
108
|
-
if target_directory is None:
|
109
|
-
target_directory = extension_dir / "dist"
|
110
|
-
if not target_directory.exists():
|
111
|
-
target_directory.mkdir()
|
112
|
-
|
113
|
-
console.print("Stage 1 - Download and build dependencies", style="bold blue")
|
114
|
-
wheel(extension_dir, extra_platforms, extra_index_url, find_links)
|
115
|
-
|
116
|
-
console.print("Stage 2 - Create the extension zip file", style="bold blue")
|
117
|
-
built_zip = assemble(extension_dir, target_directory)
|
118
|
-
|
119
|
-
console.print("Stage 3 - Sign the extension", style="bold blue")
|
120
|
-
extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
|
121
|
-
output = target_directory / extension_yaml.zip_file_name()
|
122
|
-
sign(built_zip, private_key, output)
|
123
|
-
|
124
|
-
console.print(f"Stage 4 - Delete {built_zip}", style="bold blue")
|
125
|
-
built_zip.unlink()
|
126
|
-
|
127
|
-
|
128
|
-
@app.command(help="Creates the extension zip file, without signing it yet")
|
129
|
-
def assemble(
|
130
|
-
extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
|
131
|
-
output: Path = typer.Option(None, "--output", "-o"),
|
132
|
-
force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
|
133
|
-
) -> Path:
|
134
|
-
"""
|
135
|
-
Creates the extension zip file (not yet signed)
|
136
|
-
|
137
|
-
:param extension_dir: The directory of the extension, by default this is the current directory
|
138
|
-
:param output: The path to the output zip file, if not specified, we put it in the dist folder
|
139
|
-
:param force: If true, overwrite the output zip file if it exists
|
140
|
-
"""
|
141
|
-
|
142
|
-
# This checks if the yaml is valid, because it parses it
|
143
|
-
# Also validates that the schema files are valid and exist
|
144
|
-
extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
|
145
|
-
extension_yaml.validate()
|
146
|
-
|
147
|
-
# Checks that the module name is valid and exists in the filesystem
|
148
|
-
module_folder = Path(extension_dir) / extension_yaml.python.runtime.module
|
149
|
-
if not module_folder.exists():
|
150
|
-
msg = f"Extension module folder {module_folder} not found"
|
151
|
-
raise FileNotFoundError(msg)
|
152
|
-
|
153
|
-
# This is the zip file that will contain the extension
|
154
|
-
if output is None:
|
155
|
-
dist_dir = Path(extension_dir) / "dist"
|
156
|
-
if not dist_dir.exists():
|
157
|
-
dist_dir.mkdir()
|
158
|
-
output = dist_dir / "extension.zip"
|
159
|
-
elif output.exists() and output.is_dir():
|
160
|
-
output = output / "extension.zip"
|
161
|
-
|
162
|
-
command = ["dt", "ext", "assemble", "--source", f"{Path(extension_dir) / 'extension'}", "--output", f"{output}"]
|
163
|
-
if force:
|
164
|
-
command.append("--force")
|
165
|
-
run_process(command)
|
166
|
-
console.print(f"Built the extension zip file to {output}", style="bold green")
|
167
|
-
return output
|
168
|
-
|
169
|
-
|
170
|
-
@app.command(help="Downloads the dependencies of the extension to the lib folder")
|
171
|
-
def wheel(
|
172
|
-
extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
|
173
|
-
extra_platforms: Optional[list[str]] = typer.Option(
|
174
|
-
None, "--extra-platform", "-e", help="Download wheels for an extra platform"
|
175
|
-
),
|
176
|
-
extra_index_url: Optional[str] = typer.Option(
|
177
|
-
None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
|
178
|
-
),
|
179
|
-
find_links: Optional[str] = typer.Option(
|
180
|
-
None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
|
181
|
-
),
|
182
|
-
):
|
183
|
-
"""
|
184
|
-
Builds the extension and it's dependencies into wheel files
|
185
|
-
Places these files in the lib folder
|
186
|
-
|
187
|
-
:param extension_dir: The directory of the extension, by default this is the current directory
|
188
|
-
:param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
|
189
|
-
:param extra_index_url: Extra index url to use when downloading dependencies
|
190
|
-
:param find_links: Extra index url to use when downloading dependencies
|
191
|
-
"""
|
192
|
-
relative_lib_folder_dir = "extension/lib"
|
193
|
-
lib_folder: Path = extension_dir / relative_lib_folder_dir
|
194
|
-
_clean_directory(lib_folder)
|
195
|
-
|
196
|
-
console.print(f"Downloading dependencies to {lib_folder}", style="cyan")
|
197
|
-
|
198
|
-
# Downloads the dependencies and places them in the lib folder
|
199
|
-
command = [sys.executable, "-m", "pip", "wheel", "-w", relative_lib_folder_dir]
|
200
|
-
if extra_index_url is not None:
|
201
|
-
command.extend(["--extra-index-url", extra_index_url])
|
202
|
-
if find_links is not None:
|
203
|
-
command.extend(["--find-links", find_links])
|
204
|
-
command.append(".")
|
205
|
-
run_process(command, cwd=extension_dir)
|
206
|
-
|
207
|
-
if extra_platforms:
|
208
|
-
for extra_platform in extra_platforms:
|
209
|
-
console.print(f"Downloading wheels for platform {extra_platform}", style="cyan")
|
210
|
-
command = [
|
211
|
-
sys.executable,
|
212
|
-
"-m",
|
213
|
-
"pip",
|
214
|
-
"download",
|
215
|
-
"-d",
|
216
|
-
relative_lib_folder_dir,
|
217
|
-
"--only-binary=:all:",
|
218
|
-
"--platform",
|
219
|
-
extra_platform,
|
220
|
-
]
|
221
|
-
if extra_index_url:
|
222
|
-
command.extend(["--extra-index-url", extra_index_url])
|
223
|
-
if find_links:
|
224
|
-
command.extend(["--find-links", find_links])
|
225
|
-
command.append(".")
|
226
|
-
|
227
|
-
run_process(command, cwd=extension_dir)
|
228
|
-
|
229
|
-
console.print(f"Installed dependencies to {lib_folder}", style="bold green")
|
230
|
-
|
231
|
-
|
232
|
-
@app.command()
|
233
|
-
def sign(
|
234
|
-
zip_file: Path = typer.Argument(Path("dist/extension.zip"), help="Path to the extension zip file"),
|
235
|
-
certificate: Path = typer.Option(
|
236
|
-
Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
|
237
|
-
"--certificate",
|
238
|
-
"-c",
|
239
|
-
help="Path to the dev fused key-certificate",
|
240
|
-
),
|
241
|
-
output: Path = typer.Option(None, "--output", "-o"),
|
242
|
-
force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
|
243
|
-
):
|
244
|
-
"""
|
245
|
-
Signs the extension zip file using the provided fused key-certificate
|
246
|
-
|
247
|
-
:param zip_file: The path to the extension zip file to sign
|
248
|
-
:param certificate: The developer fused key-certificate to use for signing
|
249
|
-
:param output: The path to the output zip file, if not specified, we put it in the dist folder
|
250
|
-
:param force: If true, overwrite the output zip file if it exists
|
251
|
-
"""
|
252
|
-
if not certificate:
|
253
|
-
# If the user doesn't specify a certificate, we try to find it in the default directory
|
254
|
-
# This directory can be set by the environment variable DT_CERTIFICATES_FOLDER
|
255
|
-
if CERT_DIR_ENVIRONMENT_VAR not in os.environ:
|
256
|
-
console.print(
|
257
|
-
f"{CERT_DIR_ENVIRONMENT_VAR} not found in environment variables. Using {CERTIFICATE_DEFAULT_PATH} instead.",
|
258
|
-
style="yellow",
|
259
|
-
)
|
260
|
-
certificate = _find_certificate(CERTIFICATE_DEFAULT_PATH)
|
261
|
-
else:
|
262
|
-
certificate = _find_certificate(Path(certificate))
|
263
|
-
|
264
|
-
combined_cert_and_key = certificate
|
265
|
-
|
266
|
-
if output is None:
|
267
|
-
output = zip_file.parent / f"signed_{zip_file.name}"
|
268
|
-
|
269
|
-
console.print(f"Signing file {zip_file} to {output} with certificate {certificate}", style="cyan")
|
270
|
-
command = [
|
271
|
-
"dt",
|
272
|
-
"ext",
|
273
|
-
"sign",
|
274
|
-
"--src",
|
275
|
-
f"{zip_file}",
|
276
|
-
"--output",
|
277
|
-
f"{output}",
|
278
|
-
"--key",
|
279
|
-
f"{combined_cert_and_key}",
|
280
|
-
]
|
281
|
-
|
282
|
-
if force:
|
283
|
-
command.append("--force")
|
284
|
-
run_process(command)
|
285
|
-
console.print(f"Created signed extension file {output}", style="bold green")
|
286
|
-
|
287
|
-
|
288
|
-
@app.command(help="Upload the extension to a Dynatrace environment")
|
289
|
-
def upload(
|
290
|
-
extension_path: Path = typer.Argument(None, help="Path to the extension folder or built zip file"),
|
291
|
-
tenant_url: str = typer.Option(None, "--tenant-url", "-u", help="Dynatrace tenant URL"),
|
292
|
-
api_token: str = typer.Option(None, "--api-token", "-t", help="Dynatrace API token"),
|
293
|
-
validate: bool = typer.Option(None, "--validate", "-v", help="Validate only"),
|
294
|
-
):
|
295
|
-
"""
|
296
|
-
Uploads the extension to a Dynatrace environment
|
297
|
-
|
298
|
-
:param extension_path: The path to the extension folder or built zip file
|
299
|
-
:param tenant_url: The Dynatrace tenant URL
|
300
|
-
:param api_token: The Dynatrace API token
|
301
|
-
:param validate: If true, only validate the extension and exit
|
302
|
-
"""
|
303
|
-
|
304
|
-
zip_file_path = extension_path
|
305
|
-
if extension_path is None:
|
306
|
-
extension_path = Path(".")
|
307
|
-
else:
|
308
|
-
extension_path = Path(extension_path)
|
309
|
-
if extension_path.is_dir():
|
310
|
-
yaml_path = Path(extension_path, "extension", "extension.yaml")
|
311
|
-
extension_yaml = ExtensionYaml(yaml_path)
|
312
|
-
zip_file_name = extension_yaml.zip_file_name()
|
313
|
-
zip_file_path = Path(extension_path, "dist", zip_file_name)
|
314
|
-
|
315
|
-
api_url = tenant_url or os.environ.get("DT_API_URL", "")
|
316
|
-
api_url = api_url.rstrip("/")
|
317
|
-
|
318
|
-
if not api_url:
|
319
|
-
console.print("Set the --tenant-url parameter or the DT_API_URL environment variable", style="bold red")
|
320
|
-
sys.exit(1)
|
321
|
-
|
322
|
-
api_token = api_token or os.environ.get("DT_API_TOKEN", "")
|
323
|
-
if not api_token:
|
324
|
-
console.print("Set the --api-token parameter or the DT_API_TOKEN environment variable", style="bold red")
|
325
|
-
sys.exit(1)
|
326
|
-
|
327
|
-
if validate:
|
328
|
-
dt_cli_validate(f"{zip_file_path}", api_url, api_token)
|
329
|
-
else:
|
330
|
-
dt_cli_upload(f"{zip_file_path}", api_url, api_token)
|
331
|
-
console.print(f"Extension {zip_file_path} uploaded to {api_url}", style="bold green")
|
332
|
-
|
333
|
-
|
334
|
-
@app.command(help="Generate root and developer certificates and key")
|
335
|
-
def gencerts(
|
336
|
-
output: Path = typer.Option(CERTIFICATE_DEFAULT_PATH, "--output", "-o", help="Path to the output directory"),
|
337
|
-
force: bool = typer.Option(False, "--force", "-f", help="Force overwriting the certificates"),
|
338
|
-
):
|
339
|
-
developer_pem = output / "developer.pem"
|
340
|
-
command_gen_ca = [
|
341
|
-
"dt",
|
342
|
-
"ext",
|
343
|
-
"genca",
|
344
|
-
"--ca-cert",
|
345
|
-
f"{output / 'ca.pem'}",
|
346
|
-
"--ca-key",
|
347
|
-
f"{output / 'ca.key'}",
|
348
|
-
"--no-ca-passphrase",
|
349
|
-
]
|
350
|
-
|
351
|
-
command_gen_dev_pem = [
|
352
|
-
"dt",
|
353
|
-
"ext",
|
354
|
-
"generate-developer-pem",
|
355
|
-
"--output",
|
356
|
-
f"{developer_pem}",
|
357
|
-
"--name",
|
358
|
-
"Acme",
|
359
|
-
"--ca-crt",
|
360
|
-
f"{output / 'ca.pem'}",
|
361
|
-
"--ca-key",
|
362
|
-
f"{output / 'ca.key'}",
|
363
|
-
]
|
364
|
-
|
365
|
-
if output.exists():
|
366
|
-
if developer_pem.exists() and force:
|
367
|
-
command_gen_ca.append("--force")
|
368
|
-
developer_pem.chmod(stat.S_IREAD | stat.S_IWRITE)
|
369
|
-
developer_pem.unlink(missing_ok=True)
|
370
|
-
elif developer_pem.exists() and not force:
|
371
|
-
msg = f"Certificates were NOT generated! {developer_pem} already exists. Use --force option to overwrite the certificates"
|
372
|
-
console.print(msg, style="bold red")
|
373
|
-
sys.exit(1)
|
374
|
-
else:
|
375
|
-
output.mkdir(parents=True)
|
376
|
-
|
377
|
-
run_process(command_gen_ca)
|
378
|
-
run_process(command_gen_dev_pem)
|
379
|
-
|
380
|
-
|
381
|
-
@app.command(help="Creates a new python extension")
|
382
|
-
def create(extension_name: str, output: Path = typer.Option(None, "--output", "-o")):
|
383
|
-
"""
|
384
|
-
Creates a new python extension
|
385
|
-
|
386
|
-
:param extension_name: The name of the extension
|
387
|
-
:param output: The path to the output directory, if not specified, we will use the extension name
|
388
|
-
"""
|
389
|
-
|
390
|
-
if not is_pep8_compliant(extension_name):
|
391
|
-
msg = f"Extension name {extension_name} is not valid, should be short, all-lowercase and underscores can be used if it improves readability"
|
392
|
-
raise Exception(msg)
|
393
|
-
|
394
|
-
if output is None:
|
395
|
-
output = Path.cwd() / extension_name
|
396
|
-
else:
|
397
|
-
output = output / extension_name
|
398
|
-
extension_path = generate_extension(extension_name, output)
|
399
|
-
console.print(f"Extension created at {extension_path}", style="bold green")
|
400
|
-
|
401
|
-
|
402
|
-
def run_process(
|
403
|
-
command: List[str], cwd: Optional[Path] = None, env: Optional[dict] = None, print_message: Optional[str] = None
|
404
|
-
):
|
405
|
-
friendly_command = " ".join(command)
|
406
|
-
if print_message is not None:
|
407
|
-
console.print(print_message, style="cyan")
|
408
|
-
else:
|
409
|
-
console.print(f"Running: {friendly_command}", style="cyan")
|
410
|
-
return subprocess.run(command, cwd=cwd, env=env, check=True) # noqa: S603
|
411
|
-
|
412
|
-
|
413
|
-
def _clean_directory(directory: Path):
|
414
|
-
if directory.exists():
|
415
|
-
console.print(f"Cleaning {directory}", style="cyan")
|
416
|
-
shutil.rmtree(directory)
|
417
|
-
|
418
|
-
|
419
|
-
def _find_certificate(path: Path) -> Path:
|
420
|
-
"""
|
421
|
-
Verifies the existence of the file in given path or returns the default file
|
422
|
-
"""
|
423
|
-
|
424
|
-
# If the user specified the path as a directory, we try to find developer.pem in this directory
|
425
|
-
if path.is_dir():
|
426
|
-
certificate = path / "developer.pem"
|
427
|
-
else:
|
428
|
-
certificate = path
|
429
|
-
|
430
|
-
if not certificate.exists():
|
431
|
-
msg = f"Certificate {certificate} not found"
|
432
|
-
raise FileNotFoundError(msg)
|
433
|
-
return certificate
|
434
|
-
|
435
|
-
|
436
|
-
if __name__ == "__main__":
|
437
|
-
app()
|
1
|
+
import os
|
2
|
+
import shutil
|
3
|
+
import stat
|
4
|
+
import subprocess
|
5
|
+
import sys
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import List, Optional
|
8
|
+
|
9
|
+
import typer
|
10
|
+
from dtcli.server_api import upload as dt_cli_upload # type: ignore
|
11
|
+
from dtcli.server_api import validate as dt_cli_validate
|
12
|
+
from rich.console import Console
|
13
|
+
|
14
|
+
from ..__about__ import __version__
|
15
|
+
from .create import generate_extension, is_pep8_compliant
|
16
|
+
from .schema import ExtensionYaml
|
17
|
+
|
18
|
+
app = typer.Typer(pretty_exceptions_show_locals=False, pretty_exceptions_enable=False)
|
19
|
+
console = Console()
|
20
|
+
|
21
|
+
# if we are not python 3.10.X, exit with an error
|
22
|
+
if sys.version_info < (3, 10) or sys.version_info >= (3, 11):
|
23
|
+
console.print(f"Python 3.10.X is required to build extensions, you are using {sys.version_info}", style="bold red")
|
24
|
+
sys.exit(1)
|
25
|
+
|
26
|
+
CERT_DIR_ENVIRONMENT_VAR = "DT_CERTIFICATES_FOLDER"
|
27
|
+
CERTIFICATE_DEFAULT_PATH = Path.home() / ".dynatrace" / "certificates"
|
28
|
+
|
29
|
+
|
30
|
+
# show version
|
31
|
+
@app.command()
|
32
|
+
def version():
|
33
|
+
"""
|
34
|
+
Show the version of the CLI
|
35
|
+
"""
|
36
|
+
console.print(f"dt-extensions-sdk version {__version__}", style="bold green")
|
37
|
+
|
38
|
+
|
39
|
+
@app.command()
|
40
|
+
def run(
|
41
|
+
extension_dir: Path = typer.Argument("."),
|
42
|
+
activation_config: str = "activation.json",
|
43
|
+
fast_check: bool = typer.Option(False, "--fastcheck"),
|
44
|
+
local_ingest: bool = typer.Option(False, "--local-ingest"),
|
45
|
+
local_ingest_port: int = typer.Option(14499, "--local-ingest-port"),
|
46
|
+
print_metrics: bool = typer.Option(True),
|
47
|
+
):
|
48
|
+
"""
|
49
|
+
Runs an extension, this is used during development to locally run and test an extension
|
50
|
+
|
51
|
+
:param extension_dir: The directory of the extension, by default this is the current directory
|
52
|
+
:param activation_config: The activation config file, by default this is activation.json
|
53
|
+
:param fast_check: If true, run a fastcheck and exits
|
54
|
+
:param local_ingest: If true, send metrics to localhost:14499 on top of printing them
|
55
|
+
:param local_ingest_port: The port to send metrics to, by default this is 14499
|
56
|
+
:param print_metrics: If true, print metrics to the console
|
57
|
+
"""
|
58
|
+
|
59
|
+
# This parses the yaml, which validates it before running
|
60
|
+
extension_yaml = ExtensionYaml(extension_dir / "extension/extension.yaml")
|
61
|
+
try:
|
62
|
+
command = [sys.executable, "-m", extension_yaml.python.runtime.module, "--activationconfig", activation_config]
|
63
|
+
if fast_check:
|
64
|
+
command.append("--fastcheck")
|
65
|
+
if local_ingest:
|
66
|
+
command.append("--local-ingest")
|
67
|
+
command.append(f"--local-ingest-port={local_ingest_port}")
|
68
|
+
if not print_metrics:
|
69
|
+
command.append("--no-print-metrics")
|
70
|
+
run_process(command, cwd=extension_dir)
|
71
|
+
except KeyboardInterrupt:
|
72
|
+
console.print("\nRun interrupted with a KeyboardInterrupt, stopping", style="bold yellow")
|
73
|
+
|
74
|
+
|
75
|
+
@app.command(help="Runs wheel, assemble and sign. Downloads dependencies, creates and signs the extension zip file")
|
76
|
+
def build(
|
77
|
+
extension_dir: Path = typer.Argument(Path("."), help="Path to the python extension"),
|
78
|
+
private_key: Path = typer.Option(
|
79
|
+
Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
|
80
|
+
"--private-key",
|
81
|
+
"-k",
|
82
|
+
help="Path to the dev fused key-certificate",
|
83
|
+
),
|
84
|
+
target_directory: Optional[Path] = typer.Option(None, "--target-directory", "-t"),
|
85
|
+
extra_platforms: Optional[list[str]] = typer.Option(
|
86
|
+
None, "--extra-platform", "-e", help="Download wheels for an extra platform"
|
87
|
+
),
|
88
|
+
extra_index_url: Optional[str] = typer.Option(
|
89
|
+
None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
|
90
|
+
),
|
91
|
+
find_links: Optional[str] = typer.Option(
|
92
|
+
None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
|
93
|
+
),
|
94
|
+
):
|
95
|
+
"""
|
96
|
+
Builds and signs an extension using the developer fused key-certificate
|
97
|
+
|
98
|
+
:param extension_dir: The directory of the extension, by default this is the current directory
|
99
|
+
:param private_key: The path to the developer fused key-certificate, if not specified, we try to locate the file developer.pem under the environment
|
100
|
+
variable DT_CERTIFICATES_FOLDER
|
101
|
+
:param target_directory: The directory to put the extension zip file in, if not specified, we put it in the dist
|
102
|
+
folder
|
103
|
+
:param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
|
104
|
+
:param extra_index_url: Extra index url to use when downloading dependencies
|
105
|
+
:param find_links: Extra index url to use when downloading dependencies
|
106
|
+
"""
|
107
|
+
console.print(f"Building and signing extension from {extension_dir} to {target_directory}", style="cyan")
|
108
|
+
if target_directory is None:
|
109
|
+
target_directory = extension_dir / "dist"
|
110
|
+
if not target_directory.exists():
|
111
|
+
target_directory.mkdir()
|
112
|
+
|
113
|
+
console.print("Stage 1 - Download and build dependencies", style="bold blue")
|
114
|
+
wheel(extension_dir, extra_platforms, extra_index_url, find_links)
|
115
|
+
|
116
|
+
console.print("Stage 2 - Create the extension zip file", style="bold blue")
|
117
|
+
built_zip = assemble(extension_dir, target_directory)
|
118
|
+
|
119
|
+
console.print("Stage 3 - Sign the extension", style="bold blue")
|
120
|
+
extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
|
121
|
+
output = target_directory / extension_yaml.zip_file_name()
|
122
|
+
sign(built_zip, private_key, output)
|
123
|
+
|
124
|
+
console.print(f"Stage 4 - Delete {built_zip}", style="bold blue")
|
125
|
+
built_zip.unlink()
|
126
|
+
|
127
|
+
|
128
|
+
@app.command(help="Creates the extension zip file, without signing it yet")
|
129
|
+
def assemble(
|
130
|
+
extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
|
131
|
+
output: Path = typer.Option(None, "--output", "-o"),
|
132
|
+
force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
|
133
|
+
) -> Path:
|
134
|
+
"""
|
135
|
+
Creates the extension zip file (not yet signed)
|
136
|
+
|
137
|
+
:param extension_dir: The directory of the extension, by default this is the current directory
|
138
|
+
:param output: The path to the output zip file, if not specified, we put it in the dist folder
|
139
|
+
:param force: If true, overwrite the output zip file if it exists
|
140
|
+
"""
|
141
|
+
|
142
|
+
# This checks if the yaml is valid, because it parses it
|
143
|
+
# Also validates that the schema files are valid and exist
|
144
|
+
extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
|
145
|
+
extension_yaml.validate()
|
146
|
+
|
147
|
+
# Checks that the module name is valid and exists in the filesystem
|
148
|
+
module_folder = Path(extension_dir) / extension_yaml.python.runtime.module
|
149
|
+
if not module_folder.exists():
|
150
|
+
msg = f"Extension module folder {module_folder} not found"
|
151
|
+
raise FileNotFoundError(msg)
|
152
|
+
|
153
|
+
# This is the zip file that will contain the extension
|
154
|
+
if output is None:
|
155
|
+
dist_dir = Path(extension_dir) / "dist"
|
156
|
+
if not dist_dir.exists():
|
157
|
+
dist_dir.mkdir()
|
158
|
+
output = dist_dir / "extension.zip"
|
159
|
+
elif output.exists() and output.is_dir():
|
160
|
+
output = output / "extension.zip"
|
161
|
+
|
162
|
+
command = ["dt", "ext", "assemble", "--source", f"{Path(extension_dir) / 'extension'}", "--output", f"{output}"]
|
163
|
+
if force:
|
164
|
+
command.append("--force")
|
165
|
+
run_process(command)
|
166
|
+
console.print(f"Built the extension zip file to {output}", style="bold green")
|
167
|
+
return output
|
168
|
+
|
169
|
+
|
170
|
+
@app.command(help="Downloads the dependencies of the extension to the lib folder")
|
171
|
+
def wheel(
|
172
|
+
extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
|
173
|
+
extra_platforms: Optional[list[str]] = typer.Option(
|
174
|
+
None, "--extra-platform", "-e", help="Download wheels for an extra platform"
|
175
|
+
),
|
176
|
+
extra_index_url: Optional[str] = typer.Option(
|
177
|
+
None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
|
178
|
+
),
|
179
|
+
find_links: Optional[str] = typer.Option(
|
180
|
+
None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
|
181
|
+
),
|
182
|
+
):
|
183
|
+
"""
|
184
|
+
Builds the extension and it's dependencies into wheel files
|
185
|
+
Places these files in the lib folder
|
186
|
+
|
187
|
+
:param extension_dir: The directory of the extension, by default this is the current directory
|
188
|
+
:param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
|
189
|
+
:param extra_index_url: Extra index url to use when downloading dependencies
|
190
|
+
:param find_links: Extra index url to use when downloading dependencies
|
191
|
+
"""
|
192
|
+
relative_lib_folder_dir = "extension/lib"
|
193
|
+
lib_folder: Path = extension_dir / relative_lib_folder_dir
|
194
|
+
_clean_directory(lib_folder)
|
195
|
+
|
196
|
+
console.print(f"Downloading dependencies to {lib_folder}", style="cyan")
|
197
|
+
|
198
|
+
# Downloads the dependencies and places them in the lib folder
|
199
|
+
command = [sys.executable, "-m", "pip", "wheel", "-w", relative_lib_folder_dir]
|
200
|
+
if extra_index_url is not None:
|
201
|
+
command.extend(["--extra-index-url", extra_index_url])
|
202
|
+
if find_links is not None:
|
203
|
+
command.extend(["--find-links", find_links])
|
204
|
+
command.append(".")
|
205
|
+
run_process(command, cwd=extension_dir)
|
206
|
+
|
207
|
+
if extra_platforms:
|
208
|
+
for extra_platform in extra_platforms:
|
209
|
+
console.print(f"Downloading wheels for platform {extra_platform}", style="cyan")
|
210
|
+
command = [
|
211
|
+
sys.executable,
|
212
|
+
"-m",
|
213
|
+
"pip",
|
214
|
+
"download",
|
215
|
+
"-d",
|
216
|
+
relative_lib_folder_dir,
|
217
|
+
"--only-binary=:all:",
|
218
|
+
"--platform",
|
219
|
+
extra_platform,
|
220
|
+
]
|
221
|
+
if extra_index_url:
|
222
|
+
command.extend(["--extra-index-url", extra_index_url])
|
223
|
+
if find_links:
|
224
|
+
command.extend(["--find-links", find_links])
|
225
|
+
command.append(".")
|
226
|
+
|
227
|
+
run_process(command, cwd=extension_dir)
|
228
|
+
|
229
|
+
console.print(f"Installed dependencies to {lib_folder}", style="bold green")
|
230
|
+
|
231
|
+
|
232
|
+
@app.command()
|
233
|
+
def sign(
|
234
|
+
zip_file: Path = typer.Argument(Path("dist/extension.zip"), help="Path to the extension zip file"),
|
235
|
+
certificate: Path = typer.Option(
|
236
|
+
Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
|
237
|
+
"--certificate",
|
238
|
+
"-c",
|
239
|
+
help="Path to the dev fused key-certificate",
|
240
|
+
),
|
241
|
+
output: Path = typer.Option(None, "--output", "-o"),
|
242
|
+
force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
|
243
|
+
):
|
244
|
+
"""
|
245
|
+
Signs the extension zip file using the provided fused key-certificate
|
246
|
+
|
247
|
+
:param zip_file: The path to the extension zip file to sign
|
248
|
+
:param certificate: The developer fused key-certificate to use for signing
|
249
|
+
:param output: The path to the output zip file, if not specified, we put it in the dist folder
|
250
|
+
:param force: If true, overwrite the output zip file if it exists
|
251
|
+
"""
|
252
|
+
if not certificate:
|
253
|
+
# If the user doesn't specify a certificate, we try to find it in the default directory
|
254
|
+
# This directory can be set by the environment variable DT_CERTIFICATES_FOLDER
|
255
|
+
if CERT_DIR_ENVIRONMENT_VAR not in os.environ:
|
256
|
+
console.print(
|
257
|
+
f"{CERT_DIR_ENVIRONMENT_VAR} not found in environment variables. Using {CERTIFICATE_DEFAULT_PATH} instead.",
|
258
|
+
style="yellow",
|
259
|
+
)
|
260
|
+
certificate = _find_certificate(CERTIFICATE_DEFAULT_PATH)
|
261
|
+
else:
|
262
|
+
certificate = _find_certificate(Path(certificate))
|
263
|
+
|
264
|
+
combined_cert_and_key = certificate
|
265
|
+
|
266
|
+
if output is None:
|
267
|
+
output = zip_file.parent / f"signed_{zip_file.name}"
|
268
|
+
|
269
|
+
console.print(f"Signing file {zip_file} to {output} with certificate {certificate}", style="cyan")
|
270
|
+
command = [
|
271
|
+
"dt",
|
272
|
+
"ext",
|
273
|
+
"sign",
|
274
|
+
"--src",
|
275
|
+
f"{zip_file}",
|
276
|
+
"--output",
|
277
|
+
f"{output}",
|
278
|
+
"--key",
|
279
|
+
f"{combined_cert_and_key}",
|
280
|
+
]
|
281
|
+
|
282
|
+
if force:
|
283
|
+
command.append("--force")
|
284
|
+
run_process(command)
|
285
|
+
console.print(f"Created signed extension file {output}", style="bold green")
|
286
|
+
|
287
|
+
|
288
|
+
@app.command(help="Upload the extension to a Dynatrace environment")
|
289
|
+
def upload(
|
290
|
+
extension_path: Path = typer.Argument(None, help="Path to the extension folder or built zip file"),
|
291
|
+
tenant_url: str = typer.Option(None, "--tenant-url", "-u", help="Dynatrace tenant URL"),
|
292
|
+
api_token: str = typer.Option(None, "--api-token", "-t", help="Dynatrace API token"),
|
293
|
+
validate: bool = typer.Option(None, "--validate", "-v", help="Validate only"),
|
294
|
+
):
|
295
|
+
"""
|
296
|
+
Uploads the extension to a Dynatrace environment
|
297
|
+
|
298
|
+
:param extension_path: The path to the extension folder or built zip file
|
299
|
+
:param tenant_url: The Dynatrace tenant URL
|
300
|
+
:param api_token: The Dynatrace API token
|
301
|
+
:param validate: If true, only validate the extension and exit
|
302
|
+
"""
|
303
|
+
|
304
|
+
zip_file_path = extension_path
|
305
|
+
if extension_path is None:
|
306
|
+
extension_path = Path(".")
|
307
|
+
else:
|
308
|
+
extension_path = Path(extension_path)
|
309
|
+
if extension_path.is_dir():
|
310
|
+
yaml_path = Path(extension_path, "extension", "extension.yaml")
|
311
|
+
extension_yaml = ExtensionYaml(yaml_path)
|
312
|
+
zip_file_name = extension_yaml.zip_file_name()
|
313
|
+
zip_file_path = Path(extension_path, "dist", zip_file_name)
|
314
|
+
|
315
|
+
api_url = tenant_url or os.environ.get("DT_API_URL", "")
|
316
|
+
api_url = api_url.rstrip("/")
|
317
|
+
|
318
|
+
if not api_url:
|
319
|
+
console.print("Set the --tenant-url parameter or the DT_API_URL environment variable", style="bold red")
|
320
|
+
sys.exit(1)
|
321
|
+
|
322
|
+
api_token = api_token or os.environ.get("DT_API_TOKEN", "")
|
323
|
+
if not api_token:
|
324
|
+
console.print("Set the --api-token parameter or the DT_API_TOKEN environment variable", style="bold red")
|
325
|
+
sys.exit(1)
|
326
|
+
|
327
|
+
if validate:
|
328
|
+
dt_cli_validate(f"{zip_file_path}", api_url, api_token)
|
329
|
+
else:
|
330
|
+
dt_cli_upload(f"{zip_file_path}", api_url, api_token)
|
331
|
+
console.print(f"Extension {zip_file_path} uploaded to {api_url}", style="bold green")
|
332
|
+
|
333
|
+
|
334
|
+
@app.command(help="Generate root and developer certificates and key")
|
335
|
+
def gencerts(
|
336
|
+
output: Path = typer.Option(CERTIFICATE_DEFAULT_PATH, "--output", "-o", help="Path to the output directory"),
|
337
|
+
force: bool = typer.Option(False, "--force", "-f", help="Force overwriting the certificates"),
|
338
|
+
):
|
339
|
+
developer_pem = output / "developer.pem"
|
340
|
+
command_gen_ca = [
|
341
|
+
"dt",
|
342
|
+
"ext",
|
343
|
+
"genca",
|
344
|
+
"--ca-cert",
|
345
|
+
f"{output / 'ca.pem'}",
|
346
|
+
"--ca-key",
|
347
|
+
f"{output / 'ca.key'}",
|
348
|
+
"--no-ca-passphrase",
|
349
|
+
]
|
350
|
+
|
351
|
+
command_gen_dev_pem = [
|
352
|
+
"dt",
|
353
|
+
"ext",
|
354
|
+
"generate-developer-pem",
|
355
|
+
"--output",
|
356
|
+
f"{developer_pem}",
|
357
|
+
"--name",
|
358
|
+
"Acme",
|
359
|
+
"--ca-crt",
|
360
|
+
f"{output / 'ca.pem'}",
|
361
|
+
"--ca-key",
|
362
|
+
f"{output / 'ca.key'}",
|
363
|
+
]
|
364
|
+
|
365
|
+
if output.exists():
|
366
|
+
if developer_pem.exists() and force:
|
367
|
+
command_gen_ca.append("--force")
|
368
|
+
developer_pem.chmod(stat.S_IREAD | stat.S_IWRITE)
|
369
|
+
developer_pem.unlink(missing_ok=True)
|
370
|
+
elif developer_pem.exists() and not force:
|
371
|
+
msg = f"Certificates were NOT generated! {developer_pem} already exists. Use --force option to overwrite the certificates"
|
372
|
+
console.print(msg, style="bold red")
|
373
|
+
sys.exit(1)
|
374
|
+
else:
|
375
|
+
output.mkdir(parents=True)
|
376
|
+
|
377
|
+
run_process(command_gen_ca)
|
378
|
+
run_process(command_gen_dev_pem)
|
379
|
+
|
380
|
+
|
381
|
+
@app.command(help="Creates a new python extension")
|
382
|
+
def create(extension_name: str, output: Path = typer.Option(None, "--output", "-o")):
|
383
|
+
"""
|
384
|
+
Creates a new python extension
|
385
|
+
|
386
|
+
:param extension_name: The name of the extension
|
387
|
+
:param output: The path to the output directory, if not specified, we will use the extension name
|
388
|
+
"""
|
389
|
+
|
390
|
+
if not is_pep8_compliant(extension_name):
|
391
|
+
msg = f"Extension name {extension_name} is not valid, should be short, all-lowercase and underscores can be used if it improves readability"
|
392
|
+
raise Exception(msg)
|
393
|
+
|
394
|
+
if output is None:
|
395
|
+
output = Path.cwd() / extension_name
|
396
|
+
else:
|
397
|
+
output = output / extension_name
|
398
|
+
extension_path = generate_extension(extension_name, output)
|
399
|
+
console.print(f"Extension created at {extension_path}", style="bold green")
|
400
|
+
|
401
|
+
|
402
|
+
def run_process(
|
403
|
+
command: List[str], cwd: Optional[Path] = None, env: Optional[dict] = None, print_message: Optional[str] = None
|
404
|
+
):
|
405
|
+
friendly_command = " ".join(command)
|
406
|
+
if print_message is not None:
|
407
|
+
console.print(print_message, style="cyan")
|
408
|
+
else:
|
409
|
+
console.print(f"Running: {friendly_command}", style="cyan")
|
410
|
+
return subprocess.run(command, cwd=cwd, env=env, check=True) # noqa: S603
|
411
|
+
|
412
|
+
|
413
|
+
def _clean_directory(directory: Path):
|
414
|
+
if directory.exists():
|
415
|
+
console.print(f"Cleaning {directory}", style="cyan")
|
416
|
+
shutil.rmtree(directory)
|
417
|
+
|
418
|
+
|
419
|
+
def _find_certificate(path: Path) -> Path:
|
420
|
+
"""
|
421
|
+
Verifies the existence of the file in given path or returns the default file
|
422
|
+
"""
|
423
|
+
|
424
|
+
# If the user specified the path as a directory, we try to find developer.pem in this directory
|
425
|
+
if path.is_dir():
|
426
|
+
certificate = path / "developer.pem"
|
427
|
+
else:
|
428
|
+
certificate = path
|
429
|
+
|
430
|
+
if not certificate.exists():
|
431
|
+
msg = f"Certificate {certificate} not found"
|
432
|
+
raise FileNotFoundError(msg)
|
433
|
+
return certificate
|
434
|
+
|
435
|
+
|
436
|
+
if __name__ == "__main__":
|
437
|
+
app()
|