├── CODEOWNERS ├── dtcli ├── __main__.py ├── shim.py ├── version.py ├── scripts │ ├── __init__.py │ ├── utility.py │ └── dt.py ├── __init__.py ├── constants.py ├── dev.py ├── server_api.py ├── click_helpers.py ├── utils.py ├── validate_schema.py ├── delete_extension.py ├── building.py ├── api.py └── signing.py ├── .bumpversion.cfg ├── .coveragerc ├── tests ├── __init__.py ├── test_dt.py ├── test_backtrack_error.py ├── test_utils.py └── test_signing.py ├── .flake8 ├── pyproject.toml ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── CODE_OF_CONDUCT.md ├── .gitignore ├── README.md ├── CONTRIBUTING.md └── LICENSE /CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/ @dtcli 2 | LICENSE @dtcli 3 | -------------------------------------------------------------------------------- /dtcli/__main__.py: -------------------------------------------------------------------------------- 1 | import dtcli 2 | 3 | 4 | def main(): 5 | dtcli.scripts.dt.main() 6 | 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.6.21 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+) 4 | serialize = 5 | {major}.{minor}.{patch} 6 | tag_name = v{new_version} 7 | commit = True 8 | tag = True 9 | 10 | [bumpversion:file:pyproject.toml] 11 | 12 | [bumpversion:file:dtcli/version.py] 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | source = 5 | dtcli 6 | 7 | omit = 8 | # Omit test files 9 | dtcli/tests/* 10 | dtcli/console/* 11 | 12 | [report] 13 | exclude_lines = 14 | pragma: no cover 15 | def __repr__ 16 | if self.debug: 17 | if settings.DEBUG 18 | raise AssertionError 19 | raise NotImplementedError 20 | if 0: 21 | if __name__ == .__main__.: 22 | 23 | fail_under = 0 24 | -------------------------------------------------------------------------------- /dtcli/shim.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def _Path_is_relative(p: Path, other: Path) -> bool: 5 | # TODO: simplify and inline with removal when Python 3.8 is not supported 6 | try: 7 | return p.is_relative_to(other) 8 | # source: https://github.com/python/cpython/blob/3.10/Lib/pathlib.py#L824 9 | except AttributeError: # in Python 3.8 10 | try: 11 | p.relative_to(other) 12 | except ValueError: 13 | return False 14 | else: 15 | return True 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /dtcli/version.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __version__ = "1.6.21" 16 | -------------------------------------------------------------------------------- /dtcli/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from dtcli.scripts import dt 16 | -------------------------------------------------------------------------------- /dtcli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .version import __version__ 16 | 17 | from dtcli import scripts 18 | -------------------------------------------------------------------------------- /tests/test_dt.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from dtcli import __version__ 16 | 17 | 18 | def test_version(): 19 | pass 20 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # vim:set syntax=toml: 2 | [flake8] 3 | max-line-length = 120 4 | ignore= 5 | # require module / package docstring 6 | # we're not really using that 7 | D104, 8 | D100, 9 | # one-line docstring formatting 10 | # it's annoying when you actually want to add more 11 | D200, 12 | # no machine shall compel human speech 13 | # it also doesn't work all that well 14 | D401, 15 | # practicality beats purity 16 | I101, 17 | # for now 18 | # TODO: do it! 19 | D103, 20 | D101, 21 | D102, D107, D105, 22 | # TODO: questionable 23 | C417, C416 24 | per-file-ignores = 25 | # imported but unused 26 | __init__.py: F401 27 | # D301 - click uses backslash characters in a clever way 28 | # B008 - this is fundamental to how Typer works 29 | dtcli/scripts/*: D301, B008 30 | # Flake8 parser is not additive 31 | dtcli/scripts/__init__.py: F401 32 | # otherwise cyclic import 33 | dtcli/__init__.py: I100, F401 34 | 35 | -------------------------------------------------------------------------------- /tests/test_backtrack_error.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from yaml import MappingNode, ScalarNode, SequenceNode 3 | 4 | from dtcli.validate_schema import backtrack_yaml_location 5 | 6 | 7 | def test_simple(mocker): 8 | m = mocker.Mock() 9 | assert backtrack_yaml_location([], ScalarNode(value="ble", tag=None, start_mark=m)) == m 10 | 11 | 12 | def test_complex_select_mapping(mocker): 13 | m = mocker.Mock() 14 | ast = MappingNode(tag='', value=[ 15 | (ScalarNode(tag='k', value='a'), 16 | None), 17 | (ScalarNode(tag='key', value='b'), 18 | ScalarNode(tag="v", value=None, start_mark=m)) 19 | ]) 20 | assert backtrack_yaml_location(["b"], ast) == m 21 | 22 | 23 | def test_complex_select_sequence(mocker): 24 | m = mocker.Mock() 25 | ast = SequenceNode(tag="", value=[ 26 | ScalarNode(tag="", value="scalar"), 27 | ScalarNode(tag="", value="sequence", start_mark=m), 28 | ScalarNode(tag="", value="mapping") 29 | ]) 30 | assert backtrack_yaml_location([1], ast) == m 31 | -------------------------------------------------------------------------------- /dtcli/scripts/utility.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import typer 4 | 5 | # TODO: turn completion to True when implementing completion and somehow merge it with click 6 | app = typer.Typer(hidden=True, add_completion=False) 7 | 8 | 9 | @app.callback() 10 | def utility_callback(): 11 | """ 12 | Former internal scripts outsourced for the greater good. 13 | """ 14 | pass 15 | 16 | 17 | @app.command() 18 | def acquire_secret( 19 | destination: Path = typer.Option( 20 | ..., 21 | "--output", "-o", 22 | writable=True, 23 | help="Location where the secret will be written", 24 | ), 25 | prefix: str = typer.Option(""), 26 | postfix: str = typer.Option("") 27 | ): 28 | """ 29 | The format is $Prefix$Secret$Postfix. 30 | 31 | Given prefix="ble", postfix="zog" and user inputs "fuj" 32 | the output will be "blefujzog". 33 | """ 34 | secret = typer.prompt("Enter the value for the secret above") 35 | 36 | payload = "".join([prefix, secret, postfix]) 37 | 38 | with open(destination, "w") as f: 39 | f.write(payload) 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Vagiz Duseev "] 3 | description = "Dynatrace CLI" 4 | documentation = "https://github.com/dynatrace-oss/dt-cli" 5 | homepage = "https://github.com/dynatrace-oss/dt-cli" 6 | keywords = ["dynatrace", "cli", "extensions"] 7 | license = "Apache-2.0" 8 | maintainers = ["Vagiz Duseev "] 9 | name = "dt-cli" 10 | packages = [ 11 | {include = "dtcli"}, 12 | ] 13 | readme = "README.md" 14 | repository = "https://github.com/dynatrace-oss/dt-cli" 15 | version = "1.6.21" 16 | 17 | [tool.poetry.dependencies] 18 | PyYAML = "^6.0" 19 | asn1crypto = "^1.4" 20 | click-aliases = "^1.0" 21 | cryptography = ">3" 22 | python = "^3.8" 23 | wheel = "^0" 24 | requests = "^2.26" 25 | jsonschema = "^4.7" 26 | typer = "^0" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | Sphinx = ">=3.5" 30 | black = {version = ">=20", allow-prereleases = true} 31 | bump2version = ">=1.0" 32 | flake8 = ">=3.9" 33 | flake8-blind-except = ">=0.2" 34 | flake8-bugbear = ">=21.4" 35 | flake8-comprehensions = ">=3.4" 36 | flake8-docstrings = ">=1.6" 37 | flake8-import-order = ">=0.18" 38 | flake8-polyfill = ">=1.0" 39 | ipython = ">=7.22" 40 | mypy = ">=0.812" 41 | pydocstyle = ">=6.0" 42 | pytest = ">=5.2" 43 | pytest-black = ">=0.3" 44 | pytest-cov = ">=2.11" 45 | pytest-flake8 = ">=1.0" 46 | pytest-mock = ">=3.5" 47 | pytest-mypy = ">=0.8" 48 | types-requests = ">=2.25" 49 | types-PyYAML = ">=5.4" 50 | types-jsonschema = ">=3.2" 51 | radon = ">=4.0" 52 | sphinxcontrib-programoutput = ">=0.17" 53 | # pyinstaller = ">=4.3" 54 | # staticx = ">=0.13.8" 55 | 56 | [build-system] 57 | build-backend = "poetry.core.masonry.api" 58 | requires = ["poetry-core>=1.0.8"] 59 | 60 | [tool.poetry.scripts] 61 | dt = 'dtcli.__main__:main' 62 | 63 | [tool.black] 64 | line-length = 120 65 | -------------------------------------------------------------------------------- /dtcli/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | import os.path 17 | import stat 18 | from pathlib import Path 19 | 20 | 21 | EXTENSION_YAML = "extension.yaml" 22 | EXTENSION_ZIP = "extension.zip" 23 | EXTENSION_ZIP_SIG = "extension.zip.sig" 24 | # TODO: convert to Pathlib 25 | DEFAULT_TARGET_PATH = os.path.curdir 26 | DEFAULT_EXTENSION_DIR = os.path.join(os.path.curdir, "extension") 27 | DEFAULT_EXTENSION_DIR2 = os.path.join(os.path.curdir, "src") 28 | DEFAULT_DEV_CERT = os.path.join(os.path.curdir, "developer.pem") 29 | DEFAULT_DEV_KEY = os.path.join(os.path.curdir, "developer.key") 30 | DEFAULT_CA_CERT = os.path.join(os.path.curdir, "ca.pem") 31 | DEFAULT_CA_KEY = os.path.join(os.path.curdir, "ca.key") 32 | EXTENSION_ZIP_BUNDLE = Path(DEFAULT_TARGET_PATH) / "bundle.zip" 33 | # TODO: is this a good default value? 34 | DEFAULT_CERT_VALIDITY = 365 * 3 35 | DEFAULT_SCHEMAS_DOWNLOAD_DIR = os.path.join(os.path.curdir, "schemas") 36 | DEFAULT_TOKEN_PATH = os.path.join(os.path.curdir, "secrets", "token") 37 | DEFAULT_KEYCERT_PATH = os.path.join(os.path.curdir, "secrets", "developer.pem") 38 | DEFAULT_BUILD_OUTPUT = Path(DEFAULT_TARGET_PATH) / EXTENSION_ZIP 39 | REQUIRED_PRIVATE_KEY_PERMISSIONS = stat.S_IREAD 40 | SCHEMAS_ENTRYPOINT = os.path.join(os.path.curdir, "schemas", "extension.schema.json") 41 | -------------------------------------------------------------------------------- /dtcli/dev.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import shutil 17 | import subprocess 18 | import sys 19 | import tempfile 20 | 21 | 22 | def pack_python_extension(setup_path, target_path, additional_path): 23 | with tempfile.TemporaryDirectory() as tmp: 24 | args = [sys.executable, "-m", "pip", "wheel", "-w", tmp] 25 | if additional_path is not None: 26 | args.extend(["-f", additional_path]) 27 | 28 | args.append(setup_path) 29 | result = subprocess.run(args, capture_output=True) 30 | 31 | if result.returncode != 0: 32 | print("Error building python extension: {}".format(result.stderr.decode("utf-8")), file=sys.stderr) 33 | return result.returncode 34 | 35 | lib_folder = os.path.join(target_path, "lib") 36 | if not os.path.exists(lib_folder): 37 | os.makedirs(lib_folder) 38 | if not os.path.isdir(lib_folder): 39 | print("ERROR - {} is file, needs to be a folder".format(lib_folder), file=sys.stderr) 40 | return 1 41 | for file in os.listdir(tmp): 42 | src_file = os.path.join(tmp, file) 43 | dst_file = os.path.join(lib_folder, file) 44 | shutil.copy(src=src_file, dst=dst_file) 45 | print("Python extension packed successfully to {}".format(lib_folder)) 46 | return 0 47 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pytest 16 | from dtcli import utils 17 | 18 | 19 | def test_require_extension_name_valid(): 20 | utils.require_extension_name_valid("custom:e") 21 | utils.require_extension_name_valid("custom:some.test.ext") 22 | utils.require_extension_name_valid("custom:some_simple.test.ext-1") 23 | utils.require_extension_name_valid("custom:_some_simple_test_extension") 24 | utils.require_extension_name_valid("custom:-some-simple.test.ext_1_") 25 | 26 | 27 | def test_require_extension_name_valid_negative(): 28 | with pytest.raises(utils.ExtensionBuildError): 29 | utils.require_extension_name_valid("some.test.ext") 30 | with pytest.raises(utils.ExtensionBuildError): 31 | utils.require_extension_name_valid("custom:") 32 | with pytest.raises(utils.ExtensionBuildError): 33 | utils.require_extension_name_valid("custom:.some.test.ext.") 34 | with pytest.raises(utils.ExtensionBuildError): 35 | utils.require_extension_name_valid("custom:some.test..ext") 36 | with pytest.raises(utils.ExtensionBuildError): 37 | utils.require_extension_name_valid("custom:som:e.t/est.e$xt") 38 | with pytest.raises(utils.ExtensionBuildError): 39 | utils.require_extension_name_valid("custom:SOME.test.ext") 40 | with pytest.raises(utils.ExtensionBuildError): 41 | utils.require_extension_name_valid("custom:SOME123.test.ext") 42 | with pytest.raises(utils.ExtensionBuildError): 43 | utils.require_extension_name_valid("custom:\u0194test,ext") 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - 'README.md' 6 | push: 7 | branches: 8 | - main 9 | paths-ignore: 10 | - 'README.md' 11 | workflow_dispatch: 12 | 13 | jobs: 14 | check-line-endings: 15 | name: Check CRLF line endings 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 2 18 | steps: 19 | - name: Checkout repository contents 20 | uses: actions/checkout@v4 21 | 22 | - name: Use action to check for CRLF endings 23 | uses: erclu/check-crlf@v1 24 | 25 | run-tests: 26 | name: Run tests 27 | strategy: 28 | fail-fast: true 29 | matrix: 30 | python-version: ['3.9', '3.10', '3.11', '3.12'] 31 | os: [ubuntu-latest, windows-latest, macos-latest] 32 | 33 | runs-on: ${{ matrix.os }} 34 | timeout-minutes: 5 35 | needs: check-line-endings 36 | steps: 37 | - name: Checkout repository contents 38 | uses: actions/checkout@v4 39 | 40 | - name: Set up Python 3.10 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | 45 | - name: Install poetry 46 | run: | 47 | pip install poetry 48 | poetry install 49 | 50 | - name: Run pytest tests 51 | run: | 52 | poetry run pytest 53 | 54 | # Disabled by vduseev on 2024-02-21 55 | # because it's clear we are not using mypy here 56 | #- name: Run mypy tests 57 | # run: | 58 | # bash -c '! poetry run mypy --strict dtcli | grep "Module has no attribute"' 59 | 60 | - name: Run flake8 lint checker 61 | run: | 62 | poetry run flake8 dtcli 63 | 64 | - name: Run test coverage report 65 | run: | 66 | poetry run pytest --cov . --cov-report html || true 67 | 68 | - name: Check that the package can be built 69 | run: | 70 | poetry build 71 | 72 | - name: Check that we can generate a CA certificate 73 | run: | 74 | poetry run dt ext genca --ca-cert ./ca.pem --ca-key ./ca.key --ca-subject "/CN=Default/O=Company/OU=Extension" --no-ca-passphrase --force 75 | -------------------------------------------------------------------------------- /dtcli/server_api.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import requests 16 | 17 | from . import utils as dtcliutils 18 | 19 | 20 | def validate(extension_zip_file, tenant_url, api_token): 21 | url = f"{tenant_url}/api/v2/extensions?validateOnly=true" 22 | 23 | with open(extension_zip_file, "rb") as extzf: 24 | headers = { 25 | "Accept": "application/json; charset=utf-8", 26 | 'Content-Type': 'application/octet-stream', 27 | "Authorization": f"Api-Token {api_token}", 28 | } 29 | try: 30 | extzf_data = extzf.read() 31 | response = requests.post(url, headers=headers, data=extzf_data) 32 | response.raise_for_status() 33 | print("Extension validation successful!") 34 | except requests.exceptions.HTTPError: 35 | print("Extension validation failed!") 36 | raise dtcliutils.ExtensionValidationError(response.text) 37 | 38 | 39 | def upload(extension_zip_file, tenant_url, api_token): 40 | url = f"{tenant_url}/api/v2/extensions" 41 | 42 | with open(extension_zip_file, "rb") as extzf: 43 | headers = { 44 | "Accept": "application/json; charset=utf-8", 45 | 'Content-Type': 'application/octet-stream', 46 | "Authorization": f"Api-Token {api_token}", 47 | } 48 | try: 49 | extzf_data = extzf.read() 50 | response = requests.post(url, headers=headers, data=extzf_data) 51 | response.raise_for_status() 52 | print("Extension upload successful!") 53 | except requests.exceptions.HTTPError: 54 | print("Extension upload failed!") 55 | raise dtcliutils.ExtensionValidationError(response.text) 56 | -------------------------------------------------------------------------------- /dtcli/click_helpers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import types 3 | from typing import Callable, Any, TypeVar, Optional 4 | 5 | import click 6 | 7 | 8 | T = TypeVar("T") 9 | U = TypeVar("U") 10 | 11 | 12 | def mk_click_callback(f: Callable[[T], U]) -> Callable[[Any, Any, T], U]: 13 | @functools.wraps(f) 14 | def wrapper(_, __, v): 15 | return f(v) 16 | return wrapper 17 | 18 | 19 | # TODO: type the returns 20 | def _deprecated_above(deprecation_warning: str) -> Callable[[click.core.Command], None]: 21 | def decorator(f: click.core.Command) -> None: 22 | assert isinstance(f, click.core.Command), "decorator is placed above @click.command," \ 23 | " therefore decorating a click Command," \ 24 | " instead of a bare function" 25 | command: click.core.Command = f 26 | 27 | command.short_help = "[deprecated]" 28 | command.hidden = True 29 | command.deprecated = True 30 | 31 | command.help = f"{deprecation_warning}\n{command.help}" 32 | return decorator 33 | 34 | 35 | def _deprecated_below(warning_f: Callable[[], None]): 36 | def decorator(f): 37 | assert isinstance(f, types.FunctionType), "decorator is placed below @click.command," \ 38 | " therefore decorating a a bare function," \ 39 | " not a registered Click command" 40 | 41 | @functools.wraps(f) 42 | def wrapper(*args, **kwargs): 43 | warning_f() 44 | return f(*args, **kwargs) 45 | return wrapper 46 | return decorator 47 | 48 | 49 | def deprecated(alternative: Optional[str], alternative_help: Optional[str] = None): 50 | """ 51 | This has to happen this way. 52 | 53 | Click decorator registers the function automatically, so I'd need to track where it's registered or something [so 54 | the actual function that's run could be decorated], instead I've opted for spawning 2 decorators and just doing 55 | it the hacky way. 56 | """ 57 | if alternative: 58 | alt_text = ( 59 | f"\nPlease consider using {click.style(alternative, fg='bright_cyan')} " 60 | f"instead. " 61 | f"{' ' + alternative_help.capitalize() + '.' if alternative_help else ''}\n" 62 | ) 63 | else: 64 | alt_text = "" 65 | warning = f"{click.style('This function is deprecated', fg='red')}.{alt_text}" 66 | return (_deprecated_above(warning), _deprecated_below(lambda: click.echo(warning))) 67 | 68 | 69 | # TODO: type it correctly 70 | def compose_click_decorators_2(a, b) -> "decorator": # noqa: F821 71 | def wrapper(f): 72 | return a(b(f)) 73 | return wrapper 74 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build-package: 7 | name: Build package 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 5 10 | steps: 11 | - id: check_ref 12 | run: echo "::set-output name=match::$(echo '${{ github.ref }}' | grep -Pq '^refs/tags/v\d+\.\d+\.\d+$' && echo true || echo false)" 13 | shell: bash 14 | 15 | - name: Check if tag is valid 16 | if: steps.check_ref.outputs.match != 'true' 17 | run: exit 1 18 | 19 | - name: Checkout repository contents 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.10' 26 | 27 | - name: Install poetry 28 | run: | 29 | pip install poetry 30 | poetry install 31 | 32 | - name: Build package 33 | run: | 34 | poetry build 35 | 36 | - name: Cache built package artifacts 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: package 40 | path: | 41 | dist/* 42 | 43 | github-release: 44 | name: Create GitHub release 45 | if: startsWith(github.ref, 'refs/tags/v') 46 | runs-on: ubuntu-latest 47 | needs: 48 | - build-package 49 | steps: 50 | - uses: actions/checkout@v4 51 | 52 | - name: Generate changelog 53 | run: | 54 | cat > CHANGELOG.md <> CHANGELOG.md 64 | 65 | - name: Download cached built package 66 | uses: actions/download-artifact@v4 67 | with: 68 | name: package 69 | path: dist 70 | 71 | - name: Create GitHub release 72 | uses: softprops/action-gh-release@v1 73 | with: 74 | files: | 75 | dist/* 76 | LICENSE 77 | body_path: CHANGELOG.md 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | publish-to-pypi: 82 | name: Publish to PyPI 83 | if: startsWith(github.ref, 'refs/tags/v') 84 | runs-on: ubuntu-latest 85 | needs: 86 | - build-package 87 | steps: 88 | - uses: actions/checkout@v4 89 | 90 | - name: Set up Python 3.10 91 | uses: actions/setup-python@v5 92 | with: 93 | python-version: '3.10' 94 | 95 | - name: Install poetry 96 | run: | 97 | pip install poetry 98 | poetry install 99 | 100 | - name: Download cached built package 101 | uses: actions/download-artifact@v4 102 | with: 103 | name: package 104 | path: dist 105 | 106 | - name: Publish to PyPI 107 | env: 108 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 109 | run: | 110 | poetry publish --username __token__ --password $PYPI_TOKEN 111 | -------------------------------------------------------------------------------- /dtcli/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os.path 16 | import re 17 | from pathlib import Path 18 | 19 | 20 | class ExtensionBuildError(Exception): 21 | pass 22 | 23 | 24 | class ExtensionValidationError(Exception): 25 | pass 26 | 27 | 28 | class KeyGenerationError(Exception): 29 | pass 30 | 31 | 32 | def require_extension_name_valid(extension_name): 33 | extension_name_regex = re.compile("^custom:(?!\\.)(?!.*\\.\\.)(?!.*\\.$)[a-z0-9-_\\.]+$") 34 | if not extension_name_regex.match(extension_name): 35 | print("""Name of your extension, (an extension not developed by Dynatrace) must start with "custom:" and 36 | comply with the metric ingestion protocol requirements for dimensions. 37 | Read more at: 38 | https://www.dynatrace.com/support/help/extend-dynatrace/extensions20/extension-yaml/#start-extension-yaml-file""" 39 | ) 40 | print("%s doesn't satisfy extension naming format, aborting!" % extension_name) 41 | raise ExtensionBuildError() 42 | 43 | 44 | def check_file_exists(file_path, exception_cls=ExtensionBuildError, warn_overwrite=True): 45 | """Returns True and prints a message if file under given path exists and is a real file. 46 | 47 | In case the path represents a directory, exception given in the exception_cls parameter will be thrown. 48 | In case there's no file under the given path returns False. 49 | """ 50 | if os.path.exists(file_path): 51 | require_is_not_dir(file_path, exception_cls) 52 | if warn_overwrite: 53 | print("%s file already exists, it will be overwritten!" % file_path) 54 | return True 55 | return False 56 | 57 | 58 | def require_file_exists(file_path): 59 | if not os.path.exists(file_path): 60 | print("%s doesn't exist, aborting!" % file_path) 61 | raise ExtensionBuildError() 62 | 63 | 64 | def require_dir_exists(dir_path): 65 | if not os.path.isdir(dir_path): 66 | print("%s is not a directory, aborting!" % dir_path) 67 | raise ExtensionBuildError() 68 | 69 | 70 | def require_is_not_dir(file_path, exception_cls=ExtensionBuildError): 71 | if os.path.isdir(file_path): 72 | print("%s is a directory, aborting!" % file_path) 73 | raise exception_cls() 74 | 75 | 76 | def remove_files(file_paths): 77 | for file_path in file_paths: 78 | try: 79 | os.remove(file_path) 80 | except OSError: 81 | print("Failed to remove %s" % file_path) 82 | 83 | 84 | def acquire_file_dac(path: Path) -> int: 85 | # we only care about the last 3 digits of the mode 86 | # which are in fact the file permission 87 | return (os.stat(str(path)).st_mode) % 0o1000 88 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor covenant code of conduct 2 | 3 | ## Our pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This code of conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project email 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@dynatrace.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce this code of conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This code of conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .vscode/ 3 | 4 | # Integration tests 5 | extension/ 6 | *.zip 7 | *.sig 8 | *.key 9 | *.crt 10 | 11 | # Downloaded files 12 | alerts/ 13 | schemas/ 14 | 15 | # Secrets 16 | secrets/ 17 | 18 | # Git 19 | .gitconfig 20 | 21 | # Byte-compiled / optimized / DLL files 22 | __pycache__/ 23 | *.py[cod] 24 | *$py.class 25 | 26 | # C extensions 27 | *.so 28 | 29 | # Distribution / packaging 30 | .Python 31 | build/ 32 | develop-eggs/ 33 | dist/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | sdist/ 41 | var/ 42 | wheels/ 43 | share/python-wheels/ 44 | *.egg-info/ 45 | .installed.cfg 46 | *.egg 47 | MANIFEST 48 | 49 | # PyInstaller 50 | # Usually these files are written by a python script from a template 51 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 52 | *.manifest 53 | *.spec 54 | 55 | # Installer logs 56 | pip-log.txt 57 | pip-delete-this-directory.txt 58 | 59 | # Unit test / coverage reports 60 | htmlcov/ 61 | .tox/ 62 | .nox/ 63 | .coverage 64 | .coverage.* 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | *.cover 69 | *.py,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | cover/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Django stuff: 79 | *.log 80 | local_settings.py 81 | db.sqlite3 82 | db.sqlite3-journal 83 | 84 | # Flask stuff: 85 | instance/ 86 | .webassets-cache 87 | 88 | # Scrapy stuff: 89 | .scrapy 90 | 91 | # Sphinx documentation 92 | docs/_build/ 93 | 94 | # PyBuilder 95 | .pybuilder/ 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # IPython 102 | profile_default/ 103 | ipython_config.py 104 | 105 | # pyenv 106 | # For a library or package, you might want to ignore these files since the code is 107 | # intended to run in multiple environments; otherwise, check them in: 108 | .python-version 109 | 110 | # pipenv 111 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 112 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 113 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 114 | # install all needed dependencies. 115 | #Pipfile.lock 116 | 117 | # poetry 118 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 119 | # This is especially recommended for binary packages to ensure reproducibility, and is more 120 | # commonly ignored for libraries. 121 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 122 | poetry.lock 123 | 124 | # pdm 125 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 126 | #pdm.lock 127 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 128 | # in version control. 129 | # https://pdm.fming.dev/#use-with-ide 130 | .pdm.toml 131 | 132 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 133 | __pypackages__/ 134 | 135 | # Celery stuff 136 | celerybeat-schedule 137 | celerybeat.pid 138 | 139 | # SageMath parsed files 140 | *.sage.py 141 | 142 | # Environments 143 | .env 144 | .venv 145 | env/ 146 | venv/ 147 | ENV/ 148 | env.bak/ 149 | venv.bak/ 150 | 151 | # Spyder project settings 152 | .spyderproject 153 | .spyproject 154 | 155 | # Rope project settings 156 | .ropeproject 157 | 158 | # mkdocs documentation 159 | /site 160 | 161 | # mypy 162 | .mypy_cache/ 163 | .dmypy.json 164 | dmypy.json 165 | 166 | # Pyre type checker 167 | .pyre/ 168 | 169 | # pytype static type analyzer 170 | .pytype/ 171 | 172 | # Cython debug symbols 173 | cython_debug/ 174 | -------------------------------------------------------------------------------- /dtcli/validate_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | from pathlib import Path 4 | from typing import Any, List, Union, Callable 5 | 6 | from jsonschema import Draft202012Validator, Validator, ValidationError 7 | 8 | from referencing import Registry, Resource 9 | 10 | import yaml 11 | 12 | 13 | YamlAST = Union[yaml.ScalarNode, yaml.SequenceNode, yaml.MappingNode] 14 | 15 | 16 | def backtrack_yaml_location(path: List[Union[int, str]], ast: YamlAST) -> yaml.error.Mark: 17 | """ 18 | Go through the json-path in input file and returns location line and column. 19 | 20 | Schema validator doesn't know which line and column the error was on, since it operates 21 | at AST level without retaining source maps. 22 | """ 23 | if not path: 24 | return ast.start_mark 25 | 26 | chunk, *rest = path 27 | 28 | if isinstance(chunk, str): 29 | key_value_pairs = ast.value 30 | for k, v in key_value_pairs: 31 | assert isinstance(k, yaml.ScalarNode), "keys are scalar nodes" 32 | if k.value == chunk: 33 | return backtrack_yaml_location(rest, v) 34 | elif isinstance(chunk, int): 35 | v = ast.value[chunk] 36 | return backtrack_yaml_location(rest, v) 37 | else: 38 | assert 0, "unreachable switch branch" 39 | 40 | 41 | def remove_unsupported_regex_patterns(data: Any) -> dict: 42 | if isinstance(data, dict): 43 | cleanedup_dict = { 44 | k: remove_unsupported_regex_patterns(v) for k, v in data.items() 45 | # if not (k == "pattern" and isinstance(v, str) and r"\\p" in v) 46 | if not (k == "pattern" and isinstance(v, str) and r"\p" in v) 47 | } 48 | return cleanedup_dict 49 | elif isinstance(data, list): 50 | cleanedup_list = [ 51 | remove_unsupported_regex_patterns(i) for i in data 52 | ] 53 | return cleanedup_list 54 | else: 55 | return data 56 | 57 | 58 | def validate_schema(extension_yaml_path: Path, extension_schema_path: Path, warn: Callable[[str], None]) -> list: 59 | schema_dir_path = pathlib.Path(extension_schema_path).parent 60 | 61 | with open(extension_yaml_path, "r") as yaml_in: 62 | instance = yaml.safe_load(yaml_in) 63 | 64 | with open(extension_schema_path) as f: 65 | schema_data = json.load(f) 66 | cleanedup_schema_data = remove_unsupported_regex_patterns(schema_data) 67 | 68 | subschema_paths = [ 69 | p for p in schema_dir_path.iterdir() if p.is_file() and extension_schema_path.absolute() != p.absolute() 70 | ] 71 | 72 | resources: list[tuple[str, dict]] = [] 73 | for c in subschema_paths: 74 | try: 75 | with open(c) as f: 76 | subschema_data = json.load(f) 77 | except json.decoder.JSONDecodeError: 78 | warn(f"skipping subschema {c}, malformed json") 79 | else: 80 | cleanedup_subschema_data = remove_unsupported_regex_patterns(subschema_data) 81 | subschema_uri = cleanedup_subschema_data["$id"] 82 | subschema = Resource.from_contents(cleanedup_subschema_data) 83 | resources.append((subschema_uri, subschema)) 84 | 85 | registry = Registry().with_resources(resources) 86 | validator: Validator = Draft202012Validator( 87 | cleanedup_schema_data, 88 | registry=registry, 89 | ) 90 | 91 | with open(extension_yaml_path, "r") as f: 92 | file = yaml.compose(f) 93 | 94 | def process_validation_error(error: ValidationError): 95 | err_loc = backtrack_yaml_location(error.absolute_path, file) 96 | 97 | # TODO: add typo finder for some cases - like enum mismatch 98 | return { 99 | "line": err_loc.line, 100 | "column": err_loc.column, 101 | "path": ".".join(map(str, error.absolute_path)), 102 | "cause": error.message 103 | } 104 | 105 | detected_errors = list(validator.iter_errors(instance)) 106 | errors_info: list[dict] = [] 107 | for err in detected_errors: 108 | error_info = process_validation_error(err) 109 | errors_info.append(error_info) 110 | 111 | return errors_info 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dt-cli — Dynatrace developer's toolbox 2 | 3 | Dynatrace CLI is a command line utility that assists in signing, building and uploading 4 | extensions for Dynatrace Extension Framework 2.0. 5 | 6 |

7 | PyPI 8 | PyPI - Python Version 9 | GitHub Workflow Status (main branch) 10 |

11 | 12 | ## Features 13 | 14 | * Work with Extensions 2.0 15 | * Build and sign extensions from source 16 | * Generate CA certificates for development 17 | * Generate development certificates for extension signing 18 | * Validate and upload extension to Dynatrace 19 | * *(planned) Perform various API requests from command line* 20 | 21 | ## FAQ 22 | 23 | **What's the difference between monaco and dt-cli?** 24 | 25 | * [Monaco](https://github.com/Dynatrace/dynatrace-configuration-as-code) is a **mon**itoring configuration **a**s **co**de solution that allows you to configure Dynatrace environment using GitOps approach. It follows a declarative approach: define what you need and the tool will ensure the correct configuration. 26 | * `dt` command line, on the other hand, is a tool for performing imperative step-by-step configuration. You explicitly invoke commands to modify the state. 27 | 28 | ## Installation 29 | 30 | ```shell 31 | pip install dt-cli 32 | ``` 33 | 34 | ## Usage 35 | 36 | 1. *(optional) If you don't already have a developer certificate* 37 | 38 | 1. Generate CA key and certificate 39 | 40 | ```shell 41 | $ dt ext genca 42 | CA private key passphrase []: 43 | Repeat for confirmation: 44 | Generating CA... 45 | 46 | Wrote CA private key: ./ca.key 47 | Wrote CA certificate: ./ca.pem 48 | ``` 49 | 50 | 1. Generate developer key and certificate from the CA 51 | 52 | ```shell 53 | $ dt ext generate-developer-pem --ca-crt ca.pem --ca-key ca.key -o dev.pem 54 | Name: Ext 55 | Loading CA private key ca.key 56 | Loading CA certificate ca.pem 57 | Generating developer certificate... 58 | Wrote developer certificate: dev.pem 59 | Wrote developer private key: dev.pem 60 | ``` 61 | 62 | 1. Upload your CA certificate to the Dynatrace credential vault 63 | 64 | See: [Add your root certificate to the Dynatrace credential vault](https://www.dynatrace.com/support/help/extend-dynatrace/extensions20/sign-extension/#add-your-root-certificate-to-the-dynatrace-credential-vault) 65 | 66 | 1. Upload your CA certificate to OneAgent or ActiveGate hosts that will run your extension 67 | 68 | See: [Uplaod your root certificate to OneAgent or ActiveGate](https://docs.dynatrace.com/docs/extend-dynatrace/extensions20/sign-extension#upload) 69 | 70 | 1. Build and sign the extension 71 | 72 | ```shell 73 | $ dt ext assemble 74 | Building extension.zip from extension/ 75 | Adding file: extension/dashboards/overview_dashboard.json as dashboards/overview_dashboard.json 76 | Adding file: extension/extension.yaml as extension.yaml 77 | Adding file: extension/activationSchema.json as activationSchema.json 78 | 79 | $ dt ext sign --key dev.pem 80 | Successfully signed the extension bundle at bundle.zip 81 | ``` 82 | 83 | 1. *(optional) Validate the assembled and signed bundle with your Dynatrace tenant* 84 | 85 | ```shell 86 | $ dt ext validate bundle.zip --tenant-url https://.live.dynatrace.com --api-token 87 | Extension validation successful! 88 | ``` 89 | 90 | 1. Upload the extension to your Dynatrace tenant 91 | 92 | ```shell 93 | $ dt ext upload bundle.zip --tenant-url https://.live.dynatrace.com --api-token 94 | Extension upload successful! 95 | ``` 96 | 97 | ## Using dt-cli from your Python code 98 | 99 | You may want to use some commands implemented by `dt-cli` directly in your Python code, e.g. to automatically sign your extension in a CI environment. 100 | Here's an example of building an extension programatically, it assumes `dtcli` package is already installed and available in your working environment. 101 | 102 | 103 | ```python 104 | from dtcli import building 105 | 106 | 107 | building.build_extension( 108 | extension_dir_path = './extension', 109 | extension_zip_path = './extension.zip', 110 | extension_zip_sig_path = './extension.zip.sig', 111 | target_dir_path = './dist', 112 | certificate_file_path = './developer.crt', 113 | private_key_file_path = './developer.key', 114 | dev_passphrase=None, 115 | keep_intermediate_files=False, 116 | ) 117 | ``` 118 | 119 | ## Development 120 | 121 | See our [CONTRIBUTING](CONTRIBUTING.md) guidelines and instructions. 122 | 123 | ## Contributions 124 | 125 | You are welcome to contribute using Pull Requests to the respective 126 | repository. Before contributing, please read our 127 | [Code of Conduct](https://github.com/dynatrace-oss/dt-cli/blob/main/CODE_OF_CONDUCT.md). 128 | 129 | ## License 130 | 131 | `dt-cli` is an Open Source Project. Please see 132 | [LICENSE](https://github.com/dynatrace-oss/dt-cli/blob/main/LICENSE) for more information. -------------------------------------------------------------------------------- /dtcli/delete_extension.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Dict, Set, Optional, List, Any 3 | 4 | from dtcli.api import DynatraceAPIClient 5 | 6 | 7 | class State: 8 | def __init__(self, d): 9 | self.d = d 10 | 11 | def __getitem__(self, key): 12 | return self.d[key] 13 | 14 | def __contains__(self, key): 15 | return key in self.d 16 | 17 | def __str__(self): 18 | return str(self.d) 19 | 20 | def versions(self, extension_fqdn, exclude: Optional[Set[str]] = None) -> List[str]: 21 | if exclude is None: 22 | exclude = set() 23 | 24 | all_versions: Set[str] = set(self[extension_fqdn].keys()) 25 | 26 | return sorted(all_versions - exclude) 27 | 28 | def as_dict(self): 29 | return self.d 30 | 31 | 32 | def acquire_state(client: DynatraceAPIClient) -> State: 33 | extensions_listing = list(map(lambda e: e["extensionName"], client.acquire_extensions())) 34 | 35 | extensions_data = [] 36 | for e in extensions_listing: 37 | _extensions_data = client.acquire_extension_versions(e) 38 | extensions_data += _extensions_data 39 | 40 | # TODO: is this really any? 41 | extensions: Dict[str, Dict[str, Any]] = defaultdict(dict) 42 | for e in extensions_data: 43 | name, version = e["extensionName"], e["version"] 44 | extensions[name][version] = {"monitoring_configurations": []} 45 | 46 | for extension in extensions_listing: 47 | environment_configuration = client.acquire_environment_configuration(extension) 48 | monitoring_configurations = client.acquire_monitoring_configurations(extension) 49 | 50 | if environment_configuration: 51 | extensions[extension][environment_configuration["version"]][ 52 | "environment_configuration"] = environment_configuration 53 | for mc in monitoring_configurations: 54 | extensions[extension][mc["value"]["version"]]["monitoring_configurations"].append(mc) 55 | 56 | s = State(extensions) 57 | return s 58 | 59 | 60 | def acquire_state_for_extension(client: DynatraceAPIClient, extension: str) -> State: 61 | versions = client.acquire_extension_versions(extension) 62 | # TODO: is this really any? 63 | extension_data: Dict[str, Dict[str, Any]] = defaultdict(dict) 64 | for e in versions: 65 | name, version = e["extensionName"], e["version"] 66 | extension_data[name][version] = {"monitoring_configurations": []} 67 | 68 | environment_configuration = client.acquire_environment_configuration(extension) 69 | monitoring_configurations = client.acquire_monitoring_configurations(extension) 70 | 71 | if environment_configuration: 72 | extension_data[extension][environment_configuration["version"]][ 73 | "environment_configuration"] = environment_configuration 74 | for mc in monitoring_configurations: 75 | extension_data[extension][mc["value"]["version"]]["monitoring_configurations"].append(mc) 76 | 77 | state = State(extension_data) 78 | return state 79 | 80 | 81 | def wipe_extension_version(client, state, extension_fqdn: str, version: str): 82 | assert extension_fqdn in state 83 | if version not in state[extension_fqdn]: 84 | return 85 | 86 | # TODO: when refactoring to command pattern remember that the order and groups matter 87 | for mc in state[extension_fqdn][version]["monitoring_configurations"]: 88 | client.delete_monitoring_configuration(extension_fqdn, mc["objectId"]) 89 | if "environment_configuration" in state[extension_fqdn][version]: 90 | # TODO: dehardcode it 91 | there_are_other_mcs = False 92 | 93 | if there_are_other_mcs: 94 | # this will be a pain to sensibly parallelize, 95 | # so... for now don't run this thing on the same fqdn simultaneously 96 | target_version = state.versions(extension_fqdn, exclude={version})[-1] 97 | client.point_environment_configuration_to(extension_fqdn, target_version) 98 | else: 99 | client.delete_environment_configuration(extension_fqdn) 100 | 101 | client.delete_extension(extension_fqdn, version) 102 | 103 | 104 | def wipe_extension(client, state, extension_fqdn: str): 105 | if extension_fqdn not in state: 106 | return 107 | 108 | env_conf_ver = client.acquire_environment_configuration(extension_fqdn)["version"] 109 | versions = [v for v in state[extension_fqdn]] 110 | 111 | wipe_extension_version(client, state, extension_fqdn, env_conf_ver) 112 | versions.remove(env_conf_ver) 113 | 114 | for version in versions: 115 | wipe_extension_version(client, state, extension_fqdn, version) 116 | 117 | 118 | # TODO: split arguments that will be usefull with all commands (tenant, secrets) 119 | def wipe_single_version(fqdn: str, version: str, tenant: str, token_path: str): 120 | """Wipe single extension version. 121 | 122 | Example: ... 'com.dynatrace.palo-alto.generic' '0.1.5' --tenant lwp00649 --secrets-path ./secrets 123 | """ 124 | with open(token_path) as f: 125 | token = f.readlines()[0].rstrip() 126 | 127 | client = DynatraceAPIClient(tenant, token) 128 | state = acquire_state(client) 129 | print(state) 130 | 131 | wipe_extension_version(client, state, fqdn, version) 132 | 133 | 134 | def wipe(fqdn: str, tenant: str, token: str): 135 | # TODO: move client creation further up the chain 136 | client = DynatraceAPIClient(tenant, token) 137 | state = acquire_state_for_extension(client, fqdn) 138 | 139 | wipe_extension(client, state, fqdn) 140 | -------------------------------------------------------------------------------- /dtcli/building.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import glob 17 | import os 18 | import os.path 19 | import zipfile 20 | from pathlib import Path 21 | 22 | import yaml 23 | 24 | from . import __version__ 25 | from . import signing 26 | from . import utils 27 | from .constants import EXTENSION_YAML, EXTENSION_ZIP, EXTENSION_ZIP_SIG 28 | 29 | 30 | def _generate_build_comment(): 31 | build_data = { 32 | "Generator": f"dt-cli {__version__}", 33 | "Creation-time": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", 34 | } 35 | 36 | return "\n".join(": ".join(pair) for pair in build_data.items()) 37 | 38 | 39 | def _zip_extension(extension_dir_path, extension_zip_path): 40 | extension_yaml_path = os.path.join(extension_dir_path, EXTENSION_YAML) 41 | utils.require_file_exists(extension_yaml_path) 42 | 43 | utils.check_file_exists(extension_zip_path) 44 | print("Building %s from %s" % (extension_zip_path, extension_dir_path)) 45 | 46 | with zipfile.ZipFile(extension_zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: 47 | for file_path in glob.glob(os.path.join(extension_dir_path, "**"), recursive=True): 48 | # This is covered by glob 49 | if os.path.isdir(file_path): 50 | continue 51 | 52 | rel_path = os.path.relpath(file_path, extension_dir_path) 53 | 54 | if rel_path == str(extension_zip_path): 55 | continue 56 | 57 | zf.write(file_path, arcname=rel_path) 58 | print("Adding file: %s as %s" % (file_path, rel_path)) 59 | 60 | 61 | def _package( 62 | extension_dir_path, 63 | target_dir_path, 64 | extension_zip_path, 65 | extension_zip_sig_path, 66 | ): 67 | extension_yaml_path = os.path.join(extension_dir_path, EXTENSION_YAML) 68 | with open(extension_yaml_path, "r") as fp: 69 | try: 70 | metadata = yaml.safe_load(fp) 71 | except yaml.parser.ParserError as e: 72 | print(f"Error while parsing yaml: {e}") 73 | exit(1) 74 | extension_file_name = "%s-%s.zip" % ( 75 | metadata["name"], 76 | metadata["version"], 77 | ) 78 | 79 | utils.require_extension_name_valid(extension_file_name) 80 | extension_file_name = extension_file_name.replace(":", "_") 81 | 82 | extension_file_path = os.path.join(target_dir_path, extension_file_name) 83 | utils.check_file_exists(extension_file_path) 84 | with zipfile.ZipFile(extension_file_path, "w") as zf: 85 | zf.comment = bytes(_generate_build_comment(), "utf-8") 86 | zf.write(extension_zip_path, arcname=EXTENSION_ZIP) 87 | zf.write(extension_zip_sig_path, arcname=EXTENSION_ZIP_SIG) 88 | 89 | print("Wrote %s file" % extension_file_path) 90 | 91 | 92 | def build(extension_dir: Path, extension_zip: Path): 93 | # how about simply: source and destination? 94 | _zip_extension(extension_dir, extension_zip) 95 | 96 | 97 | def sign(payload: Path, destination: Path, certkey: Path): 98 | # since it's a constant size with regards to the payload it can be safely done in memory 99 | pem_bytes = signing.sign_file(payload, "doesn't matter", certificate_file_path=certkey, 100 | private_key_file_path=certkey, dev_passphrase=None, _no_side_effect=True) 101 | 102 | with zipfile.ZipFile(destination, "w") as zf: 103 | zf.comment = bytes(_generate_build_comment(), "utf-8") 104 | zf.write(payload, arcname=EXTENSION_ZIP) 105 | zf.writestr(EXTENSION_ZIP_SIG, pem_bytes) 106 | 107 | 108 | def build_and_sign( 109 | extension_dir_path, 110 | extension_zip_path, 111 | extension_zip_sig_path, 112 | target_dir_path, 113 | certificate_file_path, 114 | private_key_file_path, 115 | dev_passphrase=None, 116 | keep_intermediate_files=False, 117 | ): 118 | try: 119 | # shouldn't we 120 | # a) guard against it a the intput level 121 | # b) handle faults anyway? 122 | utils.require_dir_exists(extension_dir_path) 123 | utils.require_dir_exists(target_dir_path) 124 | 125 | build(extension_dir_path, extension_zip_path) 126 | 127 | signing.sign_file( 128 | extension_zip_path, extension_zip_sig_path, certificate_file_path, private_key_file_path, dev_passphrase 129 | ) 130 | 131 | # TODO: same as above - if this is an assert it should say "assert" 132 | utils.require_file_exists(extension_zip_path) 133 | utils.require_file_exists(extension_zip_sig_path) 134 | 135 | _package( 136 | extension_dir_path, 137 | target_dir_path, 138 | extension_zip_path, 139 | extension_zip_sig_path, 140 | ) 141 | if not keep_intermediate_files: 142 | utils.remove_files( 143 | [ 144 | extension_zip_path, 145 | extension_zip_sig_path, 146 | ] 147 | ) 148 | except utils.ExtensionBuildError: 149 | # TODO: handle this a presentation layer 150 | print("Failed to build extension! :-(") 151 | exit(1) 152 | -------------------------------------------------------------------------------- /dtcli/api.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | import zipfile 5 | 6 | import requests as _requests_impl 7 | import requests.exceptions 8 | 9 | 10 | # TODO: support pagination 11 | 12 | 13 | class DynatraceAPIClient: 14 | def __init__(self, url, token, requests=None): 15 | self.url_base = url 16 | self.headers = {"Authorization": f"Api-Token {token}"} 17 | self.requests = requests if requests is not None else _requests_impl 18 | 19 | def acquire_alert(self, alert_id: str) -> dict: 20 | r = self.requests.get(self.url_base + f"/api/config/v1/anomalyDetection/metricEvents/{alert_id}", 21 | headers=self.headers) 22 | r.raise_for_status() 23 | alert = r.json() 24 | return alert 25 | 26 | def acquire_monitoring_configurations(self, fqdn: str): 27 | r = self.requests.get(self.url_base + f"/api/v2/extensions/{fqdn}/monitoringConfigurations", 28 | headers=self.headers) 29 | r.raise_for_status() 30 | return r.json()["items"] 31 | 32 | def acquire_environment_configuration(self, fqdn: str): 33 | r = self.requests.get(self.url_base + f"/api/v2/extensions/{fqdn}/environmentConfiguration", 34 | headers=self.headers) 35 | 36 | if r.status_code == 404: 37 | return 38 | 39 | r.raise_for_status() 40 | return r.json() 41 | 42 | def acquire_extensions(self): 43 | r = self.requests.get(f"{self.url_base}/api/v2/extensions", headers=self.headers) 44 | # Temporary solution. It can cause too much data usage. 45 | # Also we can hit a hard backend limit wrg to page size. 46 | # Which can cause false negatives. 47 | ext_num_tot = r.json()["totalCount"] 48 | r = self.requests.get(f"{self.url_base}/api/v2/extensions?pageSize={ext_num_tot}", headers=self.headers) 49 | r.raise_for_status() 50 | return r.json()["extensions"] 51 | 52 | def acquire_extension_versions(self, fqdn: str): 53 | r = self.requests.get(self.url_base + f"/api/v2/extensions/{fqdn}", headers=self.headers) 54 | 55 | r.raise_for_status() 56 | return r.json()["extensions"] 57 | 58 | def delete_monitoring_configuration(self, fqdn: str, configuration_id: str): 59 | r = self.requests.delete( 60 | f"{self.url_base}/api/v2/extensions/{fqdn}/monitoringConfigurations/{configuration_id}", 61 | headers=self.headers 62 | ) 63 | try: 64 | r.raise_for_status() 65 | except requests.HTTPError: 66 | err = "" 67 | try: 68 | err = r.json() 69 | except json.decoder.JSONDecodeError: 70 | pass 71 | 72 | print(err) 73 | raise 74 | 75 | def delete_environment_configuration(self, fqdn: str): 76 | r = self.requests.delete(self.url_base + f"/api/v2/extensions/{fqdn}/environmentConfiguration", 77 | headers=self.headers) 78 | err = r.json() 79 | try: 80 | r.raise_for_status() 81 | except requests.HTTPError: 82 | print(err) 83 | if r.code != 404: 84 | raise 85 | 86 | def delete_extension(self, fqdn: str, version: str): 87 | r = self.requests.delete(self.url_base + f"/api/v2/extensions/{fqdn}/{version}", headers=self.headers) 88 | err = r.json() 89 | try: 90 | r.raise_for_status() 91 | except requests.HTTPError: 92 | print(err) 93 | if r.code != 404: 94 | raise 95 | 96 | def get_schema_target_version(self, target_version: str): 97 | """Get version number from tenant. If version doesn't exist return list of available versions.""" 98 | r = self.requests.get(self.url_base + "/api/v2/extensions/schemas", headers=self.headers) 99 | 100 | r.raise_for_status() 101 | versions = r.json().get("versions", []) 102 | 103 | if target_version == "latest": 104 | return versions[-1] 105 | 106 | matches = [v for v in versions if v.startswith(target_version)] 107 | if matches: 108 | return matches[0] 109 | 110 | raise SystemExit(f"Target version {target_version} does not exist. \nAvailable versions: {versions}") 111 | 112 | def download_schemas(self, target_version: str, download_dir: str): 113 | """Downloads schemas from chosen version.""" 114 | version = self.get_schema_target_version(target_version) 115 | 116 | if not os.path.exists(download_dir): 117 | os.makedirs(download_dir) 118 | 119 | header = self.headers 120 | header["accept"] = "application/octet-stream" 121 | schema_file = self.requests.get(self.url_base + f"/api/v2/extensions/schemas/{version}", 122 | headers=header, stream=True) 123 | schema_file.raise_for_status() 124 | zfile = zipfile.ZipFile(io.BytesIO(schema_file.content)) 125 | 126 | THRESHOLD_ENTRIES = 10000 127 | THRESHOLD_SIZE = 1000000000 128 | THRESHOLD_RATIO = 10 129 | 130 | totalSizeArchive = 0 131 | totalEntryArchive = 0 132 | 133 | for zinfo in zfile.infolist(): 134 | data = zfile.read(zinfo) 135 | totalEntryArchive += 1 136 | totalSizeArchive = totalSizeArchive + len(data) 137 | ratio = len(data) / zinfo.compress_size 138 | if ratio > THRESHOLD_RATIO: 139 | raise Exception("ratio between compressed and uncompressed data is highly suspicious," 140 | " looks like a Zip Bomb Attack") 141 | 142 | if totalSizeArchive > THRESHOLD_SIZE: 143 | raise Exception("the uncompressed data size is too much for the application resource capacity") 144 | 145 | if totalEntryArchive > THRESHOLD_ENTRIES: 146 | raise Exception("too much entries in this archive, can lead to inodes exhaustion of the system") 147 | 148 | zfile.extractall(download_dir) 149 | zfile.close() 150 | 151 | return version 152 | 153 | def point_environment_configuration_to(self, fqdn: str, version: str): 154 | r = self.requests.put(self.url_base + f"/api/v2/extensions/{fqdn}/environmentConfiguration", 155 | headers=self.headers, json={"version": version}) 156 | err = r.json() 157 | try: 158 | r.raise_for_status() 159 | except requests.HTTPError: 160 | print(err) 161 | raise 162 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to dt-cli 2 | 3 | 👍🎉 Thank you for choosing to contribute to dt-cli! 🎉👍 4 | 5 | **Table of Contents** 6 | 7 | * [Development](#development) 8 | * [Required tools](#required-tools) 9 | * [Environment setup](#environment-setup) 10 | * [With Poetry](#with-poetry) 11 | * [With Docker](#with-docker) 12 | * [Development commands](#development-commands) 13 | * [Development cycle](#development-cycle) 14 | * [Development configuration](#development-configuration) 15 | 16 | 17 | 18 | ## Development 19 | 20 | This tool requires Python 3.8+ and is built with [poetry](https://python-poetry.org/). 21 | Before starting, make sure you have a dedicated [virtual environment](https://docs.python.org/3/library/venv.html) 22 | for working with this project. 23 | 24 | 25 | 26 | ### Required tools 27 | 28 | You will need Python 3.8+ and `poetry` tool installed on your system. 29 | Alternatively, you can use the Docker image to replicate the environment without having to install anything on your system. 30 | 31 | 32 | 33 | ### Environment setup 34 | 35 | 36 | 37 | #### With Poetry 38 | 39 | * Set up a virtual environment 40 | 41 | After poetry is installed and proper version of Python is present in the system, 42 | tell poetry to use the proper version for creation of the virtual environment. 43 | 44 | ```shell 45 | poetry env use 3.9.5 46 | ``` 47 | 48 | It might be beneficial to have the virtual environment placed right inside the project directory for 49 | an easier configuration of the syntax highlighting in the IDE. For these purposes, create the virtual 50 | environment named `.venv` in the root directory of the project. Poetry will automatically pick it up 51 | and use it as a destination directory for its operations with the venv as described in 52 | [the docs](https://python-poetry.org/docs/configuration/#virtualenvsin-project). 53 | 54 | ```shell 55 | python -m venv .venv 56 | ``` 57 | 58 | Now you can install the dependencies specified in `pyproject.toml` and `poetry.lock` 59 | (frozen versions and hashes of dependencies). 60 | 61 | ```shell 62 | poetry install 63 | ``` 64 | 65 | 66 | 67 | #### With Docker 68 | 69 | * Copy toml file to ensure cacheability. Bash\Powershell: 70 | 71 | ```shell 72 | cp pyproject.toml pyproject.toml.mod 73 | ``` 74 | 75 | * Build the image locally: 76 | 77 | ```shell 78 | docker build -t dtcli-dev . 79 | ``` 80 | 81 | * Run it with root of the repo mounted into `/app` directory: 82 | 83 | ```shell 84 | docker run --rm -it -v "$(pwd):/app" dtcli-dev bash 85 | ``` 86 | 87 | This will launch an interactive shell into Docker container where you can run all the commands below. 88 | 89 | 90 | 91 | ### Development commands 92 | 93 | * Run interactive python shell with syntax highlighting within the virtual environment 94 | 95 | ```shell 96 | poetry run ipython 97 | ``` 98 | 99 | * Run full test suite using MyPy, flake8, Coverage, and pytest: 100 | 101 | ```shell 102 | poetry run pytest --mypy dtcli --strict --flake8 --cov . --cov-report html 103 | ``` 104 | 105 | * Run `pytest` until the first failed test 106 | 107 | ```shell 108 | poetry run pytest -x 109 | ``` 110 | 111 | * Run `dt` command line itself in it's current state within the virtual environment 112 | 113 | ```shell 114 | poetry run dt --help 115 | ``` 116 | 117 | * Bump to the new version using `bump2version` CLI. 118 | 119 | *Note: all changes must be committed*. 120 | 121 | ```shell 122 | # Here is major (x.0.0), minor (0.x.0), or patch (0.0.x) 123 | poetry run bump2version patch 124 | 125 | # or 126 | poetry run bump2version --new-version 1.2.3 127 | ``` 128 | 129 | 130 | 131 | ### Development cycle 132 | 133 | Every commit and branch name must contain a short word describing the changes that were applied. 134 | Currently, the following set of words is being used: 135 | 136 | * `doc`: Changes to the documentation 137 | * `cli`: Command line interface 138 | * `lib`: Improvements to library (reusable and importable) portion of the dt-cli 139 | * `build`: Changes to the build or CI/CD process 140 | * `bug`: Bugfix 141 | * `feat`: New feature 142 | * `lint`: Changes caused by formatting or refactoring performed mainly for styling purposes 143 | * `typing`: Fixes improvements around strong typing 144 | * `tests`: Changes to the set of tests 145 | * `chore`: Anything not falling under the described category but that **needs** to be done 146 | 147 | Steps to follow when adding new changes: 148 | 149 | * Create a new branch for your changes. 150 | 151 | Branch must be related to an existing GitHub issue. The naming convention for the branch is 152 | 153 | ```shell 154 | # Username is optional but preferrable to be able to quickly filter the branches 155 | # Issue ID is a number that represents the GitHub issue resolved by this branch. 156 | # Change subtype is something like: doc 157 | # Short description is something like: fix typo 158 | 159 | ?--- 160 | 161 | # Example 162 | 163 | vduseev-12-feat-implement-init-command 164 | ``` 165 | 166 | * Run the test suite locally. 167 | 168 | ```shell 169 | poetry run pytest 170 | ``` 171 | 172 | * Put the mention of the GitHub issue ID into the commit message (e.g. `Implements #19`). 173 | 174 | ```text 175 | feat: Init command 176 | 177 | New command is added to initialize a new extension. 178 | 179 | Implements #12 180 | ``` 181 | 182 | * Push new branch to the repo (if you are maintainer) or create a PR from your fork or branch. 183 | * Wait for the pipeline to build and test the changes in the PR. 184 | * PR gets approved and merged into the `main` branch by the reviewers. 185 | * Maintainers wait until enough changes are accumulated in the `main` branch for a new release. 186 | * Maitaner makes a new release 187 | * Pulls the `main` branch locally 188 | * Runs all tests to double check 189 | * Runs `poetry bump2version ` to bump the version which creates a new commit and a tag. 190 | * Pushes the newly versioned `main` branch back to the GitHub using `git push --follow-tags` 191 | * New tagged push triggers the release of new version to the PyPI. 192 | 193 | 194 | 195 | ### Development configuration 196 | 197 | * `.python-version` tells `pyenv` which Python version to use in the project directory 198 | * `.coveragerc` contains settings that control how test coverage is measured 199 | * `.bumpversion.cfg` controls how version is bumped and how semantics of it work 200 | * `.readthedocs.yml` controls how Sphinx documentation is built on Readthedocs platform 201 | * `pyproject.toml` controls most of the tool settings (instead of the old approach with `setup.cfg`). 202 | * `.github/workflows/*` contains Pipeline settings for GitHub actions. 203 | -------------------------------------------------------------------------------- /tests/test_signing.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import os 17 | import pytest 18 | 19 | from cryptography import x509 as crypto_x509 20 | from cryptography.hazmat.primitives import serialization 21 | from cryptography.x509.oid import NameOID 22 | from dtcli import signing 23 | from dtcli import utils 24 | 25 | 26 | def test_generate_ca(): 27 | cert_path = "test_ca_certificate.crt" 28 | key_path = "test_ca_key.key" 29 | not_valid_after = datetime.datetime.today().replace(microsecond=0) + datetime.timedelta(days=123) 30 | passphrase = "secretpassphrase" 31 | signing.generate_ca( 32 | cert_path, 33 | key_path, 34 | { 35 | "CN": "Some Common Name", 36 | "O": "Some Org Name", 37 | "OU": "Some OU", 38 | "L": "Some Locality", 39 | "S": "Some State", 40 | "C": "PL", 41 | }, 42 | not_valid_after, 43 | passphrase, 44 | ) 45 | 46 | os.chmod(cert_path, 0o644) 47 | os.chmod(key_path, 0o644) 48 | 49 | assert os.path.exists(cert_path) 50 | assert os.path.exists(key_path) 51 | 52 | with open(cert_path, "rb") as fp: 53 | ca_cert = crypto_x509.load_pem_x509_certificate(fp.read()) 54 | 55 | assert ca_cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == "Some Common Name" 56 | assert ca_cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value == "Some Org Name" 57 | assert ca_cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value == "Some OU" 58 | assert ca_cert.issuer.get_attributes_for_oid(NameOID.LOCALITY_NAME)[0].value == "Some Locality" 59 | assert ca_cert.issuer.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME)[0].value == "Some State" 60 | assert ca_cert.issuer.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value == "PL" 61 | 62 | assert ca_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == "Some Common Name" 63 | assert ca_cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value == "Some Org Name" 64 | assert ca_cert.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value == "Some OU" 65 | assert ca_cert.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME)[0].value == "Some Locality" 66 | assert ca_cert.subject.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME)[0].value == "Some State" 67 | assert ca_cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value == "PL" 68 | 69 | assert ca_cert.not_valid_after == not_valid_after 70 | 71 | with open(key_path, "rb") as fp: 72 | ca_private_key = serialization.load_pem_private_key(fp.read(), password=passphrase.encode()) 73 | assert ca_cert.public_key().public_bytes( 74 | serialization.Encoding.PEM, serialization.PublicFormat.PKCS1 75 | ) == ca_private_key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.PKCS1) 76 | 77 | os.remove(cert_path) 78 | os.remove(key_path) 79 | 80 | 81 | def test_generate_ca_empty_attributes(): 82 | cert_path = "test_ca_certificate.crt" 83 | key_path = "test_ca_key.key" 84 | 85 | signing.generate_ca(cert_path, key_path, {}, datetime.datetime.today() + datetime.timedelta(days=1)) 86 | 87 | os.chmod(cert_path, 0o644) 88 | os.chmod(key_path, 0o644) 89 | 90 | assert os.path.exists(cert_path) 91 | assert os.path.exists(key_path) 92 | 93 | with open(cert_path, "rb") as fp: 94 | ca_cert = crypto_x509.load_pem_x509_certificate(fp.read()) 95 | 96 | assert not ca_cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME) 97 | assert not ca_cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) 98 | assert not ca_cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME) 99 | assert not ca_cert.issuer.get_attributes_for_oid(NameOID.LOCALITY_NAME) 100 | assert not ca_cert.issuer.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME) 101 | assert not ca_cert.issuer.get_attributes_for_oid(NameOID.COUNTRY_NAME) 102 | 103 | assert not ca_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) 104 | assert not ca_cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) 105 | assert not ca_cert.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME) 106 | assert not ca_cert.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME) 107 | assert not ca_cert.subject.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME) 108 | assert not ca_cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) 109 | 110 | with open(key_path, "rb") as fp: 111 | ca_private_key = serialization.load_pem_private_key(fp.read(), password=None) 112 | assert ca_cert.public_key().public_bytes( 113 | serialization.Encoding.PEM, serialization.PublicFormat.PKCS1 114 | ) == ca_private_key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.PKCS1) 115 | 116 | os.remove(cert_path) 117 | os.remove(key_path) 118 | 119 | 120 | def test_generate_cert(): 121 | ca_cert_path = "test_ca_certificate.crt" 122 | ca_key_path = "test_ca_key.key" 123 | ca_passphrase = "secretcapassphrase" 124 | 125 | signing.generate_ca( 126 | ca_cert_path, 127 | ca_key_path, 128 | { 129 | "CN": "Some Common Name", 130 | "O": "Some Org Name", 131 | "OU": "Some OU", 132 | "L": "Some Locality", 133 | "S": "Some State", 134 | "C": "PL", 135 | }, 136 | datetime.datetime.today() + datetime.timedelta(days=1), 137 | ca_passphrase, 138 | ) 139 | 140 | os.chmod(ca_cert_path, 0o644) 141 | os.chmod(ca_key_path, 0o644) 142 | 143 | assert os.path.exists(ca_cert_path) 144 | assert os.path.exists(ca_key_path) 145 | 146 | dev_cert_path = "test_dev_certificate.crt" 147 | dev_key_path = "test_dev_key.key" 148 | not_valid_after = datetime.datetime.today().replace(microsecond=0) + datetime.timedelta(days=123) 149 | dev_passphrase = "secretdevpassphrase" 150 | 151 | signing.generate_cert( 152 | ca_cert_path, 153 | ca_key_path, 154 | dev_cert_path, 155 | dev_key_path, 156 | { 157 | "CN": "Some Other Common Name", 158 | "O": "Some Other Org Name", 159 | "OU": "Some Other OU", 160 | "L": "Some Locality", 161 | "S": "Some State", 162 | "C": "PL", 163 | }, 164 | not_valid_after, 165 | ca_passphrase, 166 | dev_passphrase, 167 | ) 168 | 169 | os.chmod(dev_cert_path, 0o644) 170 | os.chmod(dev_key_path, 0o644) 171 | 172 | assert os.path.exists(dev_cert_path) 173 | assert os.path.exists(dev_key_path) 174 | 175 | with open(dev_cert_path, "rb") as fp: 176 | dev_cert = crypto_x509.load_pem_x509_certificate(fp.read()) 177 | 178 | assert dev_cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == "Some Common Name" 179 | assert dev_cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value == "Some Org Name" 180 | assert dev_cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value == "Some OU" 181 | assert dev_cert.issuer.get_attributes_for_oid(NameOID.LOCALITY_NAME)[0].value == "Some Locality" 182 | assert dev_cert.issuer.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME)[0].value == "Some State" 183 | assert dev_cert.issuer.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value == "PL" 184 | 185 | assert dev_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == "Some Other Common Name" 186 | assert dev_cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value == "Some Other Org Name" 187 | assert dev_cert.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value == "Some Other OU" 188 | assert dev_cert.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME)[0].value == "Some Locality" 189 | assert dev_cert.subject.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME)[0].value == "Some State" 190 | assert dev_cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value == "PL" 191 | 192 | assert dev_cert.not_valid_after == not_valid_after 193 | 194 | with open(dev_key_path, "rb") as fp: 195 | dev_private_key = serialization.load_pem_private_key(fp.read(), password=dev_passphrase.encode()) 196 | assert dev_cert.public_key().public_bytes( 197 | serialization.Encoding.PEM, serialization.PublicFormat.PKCS1 198 | ) == dev_private_key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.PKCS1) 199 | 200 | os.remove(ca_cert_path) 201 | os.remove(ca_key_path) 202 | os.remove(dev_cert_path) 203 | os.remove(dev_key_path) 204 | 205 | 206 | def test_generate_cert_issuer_eq_subject(): 207 | ca_cert_path = "test_ca_certificate.crt" 208 | ca_key_path = "test_ca_key.key" 209 | 210 | signing.generate_ca( 211 | ca_cert_path, 212 | ca_key_path, 213 | { 214 | "CN": "Some Common Name", 215 | "O": "Some Org Name", 216 | "OU": "Some OU", 217 | "L": "Some Locality", 218 | "S": "Some State", 219 | "C": "PL", 220 | }, 221 | datetime.datetime.today() + datetime.timedelta(days=1), 222 | ) 223 | 224 | os.chmod(ca_cert_path, 0o644) 225 | os.chmod(ca_key_path, 0o644) 226 | 227 | assert os.path.exists(ca_cert_path) 228 | assert os.path.exists(ca_key_path) 229 | 230 | dev_cert_path = "test_dev_certificate.crt" 231 | dev_key_path = "test_dev_key.key" 232 | with pytest.raises(utils.KeyGenerationError): 233 | signing.generate_cert( 234 | ca_cert_path, 235 | ca_key_path, 236 | dev_cert_path, 237 | dev_key_path, 238 | { 239 | "CN": "Some Common Name", 240 | "O": "Some Org Name", 241 | "OU": "Some OU", 242 | "L": "Some Locality", 243 | "S": "Some State", 244 | "C": "PL", 245 | }, 246 | datetime.datetime.today() + datetime.timedelta(days=1), 247 | ) 248 | assert not os.path.exists(dev_cert_path) 249 | assert not os.path.exists(dev_key_path) 250 | 251 | os.remove(ca_cert_path) 252 | os.remove(ca_key_path) 253 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Dynatrace LLC 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /dtcli/signing.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import os 17 | 18 | from asn1crypto import cms, util, x509, core, pem 19 | 20 | from cryptography import x509 as crypto_x509 21 | from cryptography.hazmat.backends import default_backend 22 | from cryptography.hazmat.primitives import serialization, hashes 23 | from cryptography.hazmat.primitives.asymmetric import padding, rsa, utils 24 | from cryptography.x509.oid import NameOID 25 | 26 | from . import constants 27 | from . import utils as dtcliutils 28 | 29 | 30 | CHUNK_SIZE = 1024 * 1024 31 | 32 | X509NameAttributes = { 33 | "CN": NameOID.COMMON_NAME, 34 | "O": NameOID.ORGANIZATION_NAME, 35 | "OU": NameOID.ORGANIZATIONAL_UNIT_NAME, 36 | "L": NameOID.LOCALITY_NAME, 37 | "S": NameOID.STATE_OR_PROVINCE_NAME, 38 | "C": NameOID.COUNTRY_NAME, 39 | } 40 | 41 | 42 | def _generate_x509_name(attributes): 43 | names_attributes = [] 44 | for name, oid in X509NameAttributes.items(): 45 | if name in attributes and attributes[name]: 46 | names_attributes.append(crypto_x509.NameAttribute(oid, attributes[name])) 47 | 48 | return crypto_x509.Name(names_attributes) 49 | 50 | 51 | def generate_ca(ca_cert_file_path, ca_key_file_path, subject, not_valid_after, passphrase=None): 52 | print("Generating CA...") 53 | private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) 54 | private_key_encryption = ( 55 | serialization.BestAvailableEncryption(passphrase.encode()) if passphrase else serialization.NoEncryption() 56 | ) 57 | with open(ca_key_file_path, "wb") as fp: 58 | fp.write( 59 | private_key.private_bytes( 60 | encoding=serialization.Encoding.PEM, 61 | format=serialization.PrivateFormat.TraditionalOpenSSL, 62 | encryption_algorithm=private_key_encryption, 63 | ) 64 | ) 65 | print("Wrote CA private key: %s" % ca_key_file_path) 66 | public_key = private_key.public_key() 67 | builder = crypto_x509.CertificateBuilder() 68 | builder = builder.subject_name(_generate_x509_name(subject)) 69 | builder = builder.issuer_name(_generate_x509_name(subject)) 70 | builder = builder.not_valid_before(datetime.datetime.today() - datetime.timedelta(days=1)) 71 | builder = builder.not_valid_after(not_valid_after) 72 | builder = builder.serial_number(crypto_x509.random_serial_number()) 73 | builder = builder.public_key(public_key) 74 | builder = builder.add_extension( 75 | crypto_x509.BasicConstraints(ca=True, path_length=0), 76 | critical=False, 77 | ) 78 | subject_identifier = crypto_x509.SubjectKeyIdentifier.from_public_key(public_key) 79 | builder = builder.add_extension( 80 | subject_identifier, 81 | critical=False, 82 | ) 83 | builder = builder.add_extension( 84 | crypto_x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(subject_identifier), 85 | critical=False, 86 | ) 87 | builder = builder.add_extension( 88 | crypto_x509.KeyUsage( 89 | digital_signature=False, 90 | content_commitment=False, 91 | key_encipherment=False, 92 | data_encipherment=False, 93 | key_agreement=False, 94 | key_cert_sign=True, 95 | crl_sign=False, 96 | encipher_only=False, 97 | decipher_only=False, 98 | ), 99 | critical=False, 100 | ) 101 | certificate = builder.sign( 102 | private_key=private_key, 103 | algorithm=hashes.SHA256(), 104 | ) 105 | with open(ca_cert_file_path, "wb") as fp: 106 | fp.write(certificate.public_bytes(serialization.Encoding.PEM)) 107 | print("Wrote CA certificate: %s" % ca_cert_file_path) 108 | 109 | 110 | def generate_cert( 111 | ca_cert_file_path, 112 | ca_key_file_path, 113 | dev_cert_file_path, 114 | dev_key_file_path, 115 | subject, 116 | not_valid_after, 117 | ca_passphrase=None, 118 | dev_passphrase=None, 119 | destination=None, 120 | ): 121 | if not (destination or (dev_cert_file_path and dev_key_file_path)): 122 | raise TypeError("either fused destination or cert *AND* key destination must be specified") 123 | 124 | if destination: 125 | dev_cert_file_path = destination 126 | dev_key_file_path = destination 127 | flags = "a" 128 | else: 129 | flags = "w" 130 | 131 | print("Loading CA private key %s" % ca_key_file_path) 132 | with open(ca_key_file_path, "rb") as fp: 133 | ca_private_key = serialization.load_pem_private_key( 134 | fp.read(), password=ca_passphrase.encode() if ca_passphrase else None, backend=default_backend() 135 | ) 136 | 137 | print("Loading CA certificate %s" % ca_cert_file_path) 138 | with open(ca_cert_file_path, "rb") as fp: 139 | ca_cert = crypto_x509.load_pem_x509_certificate(fp.read()) 140 | subject_name = _generate_x509_name(subject) 141 | if ca_cert.issuer == subject_name: 142 | raise dtcliutils.KeyGenerationError("Certificate subject must be different from its issuer") 143 | 144 | print("Generating developer certificate...") 145 | private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) 146 | private_key_encryption = ( 147 | serialization.BestAvailableEncryption(dev_passphrase.encode()) 148 | if dev_passphrase 149 | else serialization.NoEncryption() 150 | ) 151 | 152 | public_key = private_key.public_key() 153 | 154 | builder = crypto_x509.CertificateBuilder() 155 | builder = builder.subject_name(subject_name) 156 | builder = builder.issuer_name(ca_cert.issuer) 157 | builder = builder.not_valid_before(datetime.datetime.today() - datetime.timedelta(days=1)) 158 | builder = builder.not_valid_after(not_valid_after) 159 | builder = builder.serial_number(crypto_x509.random_serial_number()) 160 | builder = builder.public_key(public_key) 161 | builder = builder.add_extension( 162 | crypto_x509.SubjectKeyIdentifier.from_public_key(public_key), 163 | critical=False, 164 | ) 165 | try: 166 | subject_identifier = ca_cert.extensions.get_extension_for_class(crypto_x509.SubjectKeyIdentifier) 167 | builder = builder.add_extension( 168 | crypto_x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(subject_identifier.value), 169 | critical=False, 170 | ) 171 | except crypto_x509.ExtensionNotFound: 172 | pass 173 | builder = builder.add_extension( 174 | crypto_x509.KeyUsage( 175 | digital_signature=True, 176 | content_commitment=False, 177 | key_encipherment=False, 178 | data_encipherment=False, 179 | key_agreement=False, 180 | key_cert_sign=False, 181 | crl_sign=False, 182 | encipher_only=False, 183 | decipher_only=False, 184 | ), 185 | critical=False, 186 | ) 187 | certificate = builder.sign( 188 | private_key=ca_private_key, 189 | algorithm=hashes.SHA256(), 190 | ) 191 | 192 | with open(dev_cert_file_path, "wb") as fp: 193 | fp.write(certificate.public_bytes(serialization.Encoding.PEM)) 194 | print("Wrote developer certificate: %s" % dev_cert_file_path) 195 | 196 | with open(dev_key_file_path, "b" + flags) as fp: 197 | fp.write( 198 | private_key.private_bytes( 199 | encoding=serialization.Encoding.PEM, 200 | format=serialization.PrivateFormat.TraditionalOpenSSL, 201 | encryption_algorithm=private_key_encryption, 202 | ) 203 | ) 204 | print("Wrote developer private key: %s" % dev_key_file_path) 205 | 206 | os.chmod(dev_key_file_path, constants.REQUIRED_PRIVATE_KEY_PERMISSIONS) 207 | 208 | 209 | def sign_file(file_path, signature_file_path, certificate_file_path, 210 | private_key_file_path, dev_passphrase=None, _no_side_effect=False): 211 | if not _no_side_effect: 212 | print( 213 | "Signing %s using %s certificate and %s private key" % (file_path, certificate_file_path, 214 | private_key_file_path) 215 | ) 216 | 217 | with open(private_key_file_path, "rb") as fp: 218 | private_key = serialization.load_pem_private_key( 219 | fp.read(), password=dev_passphrase.encode() if dev_passphrase else None, backend=default_backend() 220 | ) 221 | sha256 = hashes.SHA256() 222 | hasher = hashes.Hash(sha256) 223 | with open(file_path, "rb") as fp: 224 | buf = fp.read(CHUNK_SIZE) 225 | while len(buf) > 0: 226 | hasher.update(buf) 227 | buf = fp.read(CHUNK_SIZE) 228 | signature = private_key.sign(hasher.finalize(), padding.PKCS1v15(), utils.Prehashed(sha256)) 229 | signed_data = cms.SignedData() 230 | signed_data["version"] = "v1" 231 | signed_data["encap_content_info"] = util.OrderedDict([("content_type", "data"), ("content", None)]) 232 | signed_data["digest_algorithms"] = [util.OrderedDict([("algorithm", "sha256"), ("parameters", None)])] 233 | 234 | signer_info = cms.SignerInfo() 235 | signer_info["version"] = 1 236 | signer_info["digest_algorithm"] = util.OrderedDict([("algorithm", "sha256"), ("parameters", None)]) 237 | signer_info["signature_algorithm"] = util.OrderedDict([("algorithm", "rsassa_pkcs1v15"), ("parameters", core.Null)]) 238 | signer_info["signature"] = signature 239 | 240 | with open(certificate_file_path, "rb") as fp: 241 | der_bytes = fp.read() 242 | if pem.detect(der_bytes): 243 | type_name, headers, der_bytes = pem.unarmor(der_bytes) 244 | else: 245 | print("Wrong certificate format, expected PEM, aborting!") 246 | raise dtcliutils.ExtensionBuildError() 247 | 248 | cert = x509.Certificate.load(der_bytes) 249 | 250 | signed_data["certificates"] = [ 251 | cert, 252 | ] 253 | 254 | try: 255 | signer_info["sid"] = cms.SignerIdentifier( 256 | { 257 | "issuer_and_serial_number": util.OrderedDict( 258 | [ 259 | ("issuer", cert.issuer), 260 | ("serial_number", cert.serial_number), 261 | ] 262 | ) 263 | } 264 | ) 265 | except ValueError as e: 266 | # I don't love it, but not sure if there is another way... 267 | if "Error parsing asn1crypto.x509.TbsCertificate - method should have been constructed," \ 268 | " but primitive was found" in e.args[0]: 269 | # please delete TODOs if https://github.com/dynatrace-oss/dt-cli/issues/99 is closed 270 | # TODO: add failing test 271 | # TODO: handle it - essentially reorder key and cert so that cert is at the top 272 | # TODO: warn about the situation with DI warning 273 | 274 | # TODO: don't print nor exit in the core, instead propagate the error to the UI and handle 275 | print("Error: Malformed fused certkey, certificate should be first;" 276 | " please regenerate the certificate or reorder manually") 277 | exit(1) 278 | else: 279 | raise 280 | 281 | signed_data["signer_infos"] = [ 282 | signer_info, 283 | ] 284 | 285 | # TODO timestamping? 286 | # dump ASN.1 object 287 | asn1obj = cms.ContentInfo() 288 | asn1obj["content_type"] = "signed_data" 289 | asn1obj["content"] = signed_data 290 | 291 | der_bytes = asn1obj.dump() 292 | pem_bytes = pem.armor("CMS", der_bytes) 293 | 294 | if not _no_side_effect: 295 | with open(signature_file_path, "wb+") as fp: 296 | fp.write(pem_bytes) 297 | print("Wrote signature file %s" % signature_file_path) 298 | else: 299 | return pem_bytes 300 | -------------------------------------------------------------------------------- /dtcli/scripts/dt.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Dynatrace LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import functools 17 | import json 18 | import os 19 | import platform 20 | import re 21 | import sys 22 | from pathlib import Path 23 | from pprint import pprint 24 | 25 | import click 26 | import requests # noqa:I201 27 | import typer # noqa:I201 28 | from click_aliases import ClickAliasedGroup # noqa: I201,I100 29 | 30 | import dtcli.constants as const 31 | from dtcli import __version__ 32 | from dtcli import building, delete_extension, api, utils, validate_schema as _validate_schema 33 | from dtcli import dev 34 | from dtcli import server_api 35 | from dtcli import signing 36 | from dtcli.click_helpers import deprecated, compose_click_decorators_2, mk_click_callback 37 | from dtcli.scripts.utility import app as utility_app 38 | from dtcli.shim import _Path_is_relative 39 | 40 | 41 | CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} 42 | FORCE_OPTION = typer.Option(False, 43 | "--force", "-f", 44 | help="Ignore subtleties, overwrite without prompt, when in doubt - advance!") 45 | 46 | 47 | def validate_parse_subject(ctx, param, value): 48 | if value is None: 49 | return None 50 | 51 | def split_pair_and_verify_key(pair): 52 | key, val = pair.replace("\\", "").split("=") 53 | if key not in signing.X509NameAttributes: 54 | raise click.BadParameter( 55 | f"subject attributes must be one of {list(signing.X509NameAttributes)}. Got '{key}' instead." 56 | ) 57 | return key, val 58 | 59 | try: 60 | return dict(map(split_pair_and_verify_key, filter(None, re.split(r"(? str: 130 | if value.endswith("/"): 131 | value = value[:-1] 132 | 133 | def validate_url(url): 134 | # pr is needed only to call one function 135 | pr = requests.models.PreparedRequest() 136 | pr.prepare_url(url, None) 137 | 138 | try: 139 | validate_url(value) 140 | except requests.exceptions.MissingSchema: 141 | click.echo(f"Warning: Invalid URL {value}: No scheme supplied. Defaulting to https, retrying...", err=True) 142 | value = "https://" + value 143 | 144 | return value 145 | 146 | 147 | # Walk around for token read from env if no file is provided, by default value is "-" and gets token from default file 148 | # location if file doesn't exist takes token from virtual variable, else takes token from file passed as argument 149 | api_token = click.argument("api-token-path", nargs=1, 150 | type=click.Path(exists=True, dir_okay=False, readable=True, 151 | resolve_path=True, allow_dash=True), 152 | default="-", callback=token_load 153 | ) 154 | 155 | 156 | def tenant_error_handler(func): 157 | @functools.wraps(func) 158 | def wrapper(*args, **kwargs): 159 | try: 160 | return func(*args, **kwargs) 161 | except requests.exceptions.ConnectionError as e: 162 | err = re.sub(r"\<[\w\s\d\.]+\>:\s|\[[\w\d\s\-]+\]\s", "", str(e.args[0].reason)) 163 | # TODO Extract to generic handler 164 | raise SystemExit(f"Tried url: {e.request.url}\n{err}") 165 | return wrapper 166 | 167 | tenant_url_click = click.option( # noqa:E305 168 | "--tenant-url", 169 | callback=mk_click_callback(parse_tenant_url), 170 | prompt=True, 171 | help="Dynatrace environment URL, e.g., https://.live.dynatrace.com" 172 | ) 173 | 174 | tenant_url = compose_click_decorators_2(tenant_url_click, tenant_error_handler) 175 | requires_tenant = compose_click_decorators_2(api_token, tenant_url) 176 | 177 | 178 | @click.group(context_settings=CONTEXT_SETTINGS, cls=ClickAliasedGroup) 179 | @click.version_option(version=__version__) 180 | def main(): 181 | """ 182 | Dynatrace CLI is a command line utility for Dynatrace Extensions 2.0 framework. 183 | """ 184 | pass 185 | 186 | 187 | @main.group(aliases=["extensions", "ext"]) 188 | def extension(): 189 | """ 190 | Set of utilities for signing, building and uploading extensions. 191 | 192 | Example flow: 193 | 1. (optional) When you don't have a developer certificate yet 194 | a) Generate CA key and certificate 195 | b) Generate developer key and certificate from the CA 196 | 197 | $ dt ext genca 198 | $ dt ext generate-developer-pem --ca-crt ca.pem --ca-key ca.key -o dev.pem 199 | 200 | 2. Build and sign the extension 201 | 202 | $ dt ext assemble 203 | $ dt ext sign --key dev.pem 204 | 205 | 3. (optional) Validate the assembled and signed bundle with your Dynatrace tenant 206 | 207 | $ dt ext validate bundle.zip --tenant-url https://.live.dynatrace.com --api-token 208 | 209 | 4. Upload the extension to your Dynatrace tenant 210 | 211 | $ dt ext upload bundle.zip --tenant-url https://.live.dynatrace.com --api-token 212 | """ 213 | pass 214 | # TODO: turn completion to True when implementing completion and somehow merge it with click 215 | # see: https://github.com/tiangolo/typer/issues/141 216 | typer_extension = typer.Typer(add_completion=False) # noqa: E305 217 | 218 | 219 | @main.group(aliases=["extensions_dev", "ext_dev"], hidden=True) 220 | def extension_dev(): 221 | pass 222 | 223 | 224 | @extension.command( 225 | help="Creates CA key and certificate, needed to create developer certificate used for extension signing" 226 | ) 227 | @click.option("--ca-cert", default=const.DEFAULT_CA_CERT, show_default=True, help="CA certificate output path") 228 | @click.option("--ca-key", default=const.DEFAULT_CA_KEY, show_default=True, help="CA key output path") 229 | @click.option( 230 | "--ca-subject", 231 | callback=validate_parse_subject, 232 | default="/CN=Default Extension CA/O=Some Company/OU=Extension CA", 233 | show_default=True, 234 | help="Certificate subject. Accepted format is /key0=value0/key1=value1/...", 235 | ) 236 | @click.option( 237 | "--ca-passphrase", 238 | type=str, 239 | prompt="CA private key passphrase", 240 | confirmation_prompt=True, 241 | hide_input=True, 242 | default="", 243 | help="Sets passphrase for CA private key encryption - private key is not encrypted if empty", 244 | ) 245 | @click.option( 246 | "--no-ca-passphrase", 247 | default=False, 248 | is_flag=True, 249 | is_eager=True, 250 | help="Skips prompt for CA private key encryption passphrase - private key is not encrypted", 251 | # TODO: this is borderline unreadable - refactor 252 | callback=lambda c, p, v: edit_other_option_if_true( 253 | c, p, v, "ca_passphrase", lambda param: setattr(param, "prompt", None) # noqa: B010 254 | ), 255 | ) 256 | @click.option("--force", is_flag=True, help="Overwrites already existing CA key and certificate") 257 | @click.option( 258 | "--days-valid", 259 | default=const.DEFAULT_CERT_VALIDITY, 260 | show_default=True, 261 | type=int, 262 | help="Number of days certificate will be valid", 263 | ) 264 | def genca(**kwargs): 265 | _genca( 266 | kwargs["ca_cert"], 267 | kwargs["ca_key"], 268 | kwargs["force"], 269 | kwargs["ca_subject"], 270 | kwargs["days_valid"], 271 | kwargs["ca_passphrase"], 272 | ) 273 | 274 | 275 | _deprecate_above, _deprecate_below = deprecated("dt ext generate-developer-pem") 276 | 277 | 278 | @_deprecate_above 279 | @extension.command(help="Creates developer key and certificate used for extension signing") 280 | @_deprecate_below 281 | @click.option("--ca-cert", default=const.DEFAULT_CA_CERT, show_default=True, help="CA certificate input path") 282 | @click.option("--ca-key", default=const.DEFAULT_CA_KEY, show_default=True, help="CA key input path") 283 | @click.option( 284 | "--ca-passphrase", 285 | type=str, 286 | prompt="CA private key passphrase", 287 | hide_input=True, 288 | default="", 289 | help="Passphrase used for CA private key encryption", 290 | ) 291 | @click.option( 292 | "--no-ca-passphrase", 293 | default=False, 294 | is_flag=True, 295 | is_eager=True, 296 | help="Skips prompt for CA private key encryption passphrase", 297 | # TODO: this is borderline unreadable - refactor 298 | callback=lambda c, p, v: edit_other_option_if_true( 299 | c, p, v, "ca_passphrase", lambda param: setattr(param, "prompt", None) # noqa: B010 300 | ), 301 | ) 302 | @click.option("--dev-cert", default=const.DEFAULT_DEV_CERT, show_default=True, help="Developer certificate output path") 303 | @click.option("--dev-key", default=const.DEFAULT_DEV_KEY, show_default=True, help="Developer key output path") 304 | @click.option( 305 | "--dev-passphrase", 306 | type=str, 307 | prompt="Developer private key passphrase", 308 | confirmation_prompt=True, 309 | hide_input=True, 310 | default="", 311 | help="Sets passphrase for developer private key encryption - private key is not encrypted if empty", 312 | ) 313 | @click.option( 314 | "--no-dev-passphrase", 315 | default=False, 316 | is_flag=True, 317 | is_eager=True, 318 | help="Skips prompt for developer private key encryption passphrase - private key is not encrypted", 319 | # TODO: this is borderline unreadable - refactor 320 | callback=lambda c, p, v: edit_other_option_if_true( 321 | c, p, v, "dev_passphrase", lambda param: setattr(param, "prompt", None) # noqa: B010 322 | ), 323 | ) 324 | @click.option( 325 | "--dev-subject", 326 | callback=validate_parse_subject, 327 | default="/CN=Some Developer/O=Some Company/OU=Extension Development", 328 | show_default=True, 329 | help="certificate subject. Accepted format is /key0=value0/key1=value1/...", 330 | ) 331 | @click.option( 332 | "--days-valid", 333 | default=const.DEFAULT_CERT_VALIDITY, 334 | show_default=True, 335 | type=int, 336 | help="Number of days certificate will be valid", 337 | ) 338 | def gendevcert(**kwargs): 339 | signing.generate_cert( 340 | kwargs["ca_cert"], 341 | kwargs["ca_key"], 342 | kwargs["dev_cert"], 343 | kwargs["dev_key"], 344 | kwargs["dev_subject"], 345 | datetime.datetime.today() + datetime.timedelta(days=kwargs["days_valid"]), 346 | kwargs["ca_passphrase"], 347 | kwargs["dev_passphrase"], 348 | ) 349 | 350 | 351 | @extension.command() 352 | @click.option( 353 | "-o", 354 | "--output", 355 | "destination", 356 | type=click.Path(writable=True), 357 | callback=mk_click_callback(Path), 358 | required=True, 359 | help="Location where the certkey will be written", 360 | ) 361 | @click.option( 362 | "--ca-crt", 363 | type=click.Path(exists=True, readable=True, dir_okay=False), 364 | callback=mk_click_callback(Path), 365 | required=True, 366 | help="Location of CA public certificate" 367 | ) 368 | @click.option( 369 | "--ca-key", 370 | type=click.Path(exists=True, readable=True, dir_okay=False), 371 | callback=mk_click_callback(Path), 372 | required=True, 373 | help="Location of CA private key" 374 | ) 375 | @click.option( 376 | "--name", 377 | prompt=True, 378 | # TODO: more restrictive validation 379 | help="Name of the certificate holder, likely developer name", 380 | ) 381 | @click.option( 382 | "--company", 383 | # TODO: more restrictive validation 384 | help="Name of the company that the holder belongs to", 385 | ) 386 | @click.option( 387 | "--days-valid", 388 | default=const.DEFAULT_CERT_VALIDITY, 389 | show_default=True, 390 | # TODO: more restrictive validation 391 | type=int, 392 | help="Number of days certificate will be valid", 393 | ) 394 | def generate_developer_pem(destination, ca_crt, ca_key, name, company, days_valid): 395 | """ 396 | Generate a certkey for developer. 397 | 398 | This should be signed by CA and belong to one entity only (like an employee). The resulting file is a fused 399 | key-certificate that allows to sign extensions on behalf of the Certificate Authority. 400 | 401 | Certificates with passphrase are currently not supported as if you required that kind of level of security it 402 | wouldn't be wise to use this command in it's current form. If you'd like this feature to be implemented sooner 403 | please visit https://github.com/dynatrace-oss/dt-cli/issues/81 and upvote. 404 | """ 405 | subject_kv = [ 406 | ("CN", name), 407 | ] 408 | 409 | if company: 410 | subject_kv.append(("O", company)) 411 | # TODO: get additional keys - via an additional n-argument 412 | 413 | # TODO: is this the correct format? 414 | subject = "".join(f"/{t[0]}={t[1]}" for t in subject_kv) 415 | # TODO: maybe I can just unparse? What about order? 416 | subject = validate_parse_subject(None, None, subject) 417 | # TODO: test_ext logic after clayring that 418 | 419 | # TODO: see sign 420 | # TODO: implement sensible passphrase handling - it should be a prompt only when it's required 421 | # and handled securely (like... cleared from memory), also: get rid of the comment in help 422 | # TODO: both setting the dev passphrase and reading the CA key passphrase 423 | 424 | signing.generate_cert( 425 | ca_cert_file_path=ca_crt, 426 | ca_key_file_path=ca_key, 427 | destination=destination, 428 | subject=subject, 429 | not_valid_after=datetime.datetime.today() + datetime.timedelta(days=days_valid), 430 | # TODO: remove this after deprecating other certgen + refactoring 431 | dev_cert_file_path=None, 432 | dev_key_file_path=None, 433 | ) 434 | 435 | 436 | _deprecate_above, _deprecate_below = deprecated( 437 | "dt ext genca; dt ext generate-developer-pem", 438 | "See: https://www.dynatrace.com/support/help/extend-dynatrace/extensions20/sign-extension#cert" 439 | " for additional details") 440 | 441 | 442 | @_deprecate_above 443 | @extension.command( 444 | help="Creates CA key, CA certificate, developer key and developer certificate used for extension signing" 445 | ) 446 | @_deprecate_below 447 | @click.option("--ca-cert", default=const.DEFAULT_CA_CERT, show_default=True, help="CA certificate output path") 448 | @click.option("--ca-key", default=const.DEFAULT_CA_KEY, show_default=True, help="CA key output path") 449 | @click.option( 450 | "--ca-passphrase", 451 | type=str, 452 | prompt="CA private key passphrase", 453 | confirmation_prompt=True, 454 | hide_input=True, 455 | default="", 456 | help="Sets passphrase for CA private key encryption - private key is not encrypted if empty", 457 | ) 458 | @click.option( 459 | "--no-ca-passphrase", 460 | default=False, 461 | is_flag=True, 462 | is_eager=True, 463 | help="Skips prompt for CA private key encryption passphrase - private key is not encrypted", 464 | # TODO: this is borderline unreadable - refactor 465 | callback=lambda c, p, v: edit_other_option_if_true( 466 | c, p, v, "ca_passphrase", lambda param: setattr(param, "prompt", None) # noqa: B010 467 | ), 468 | ) 469 | @click.option( 470 | "--ca-subject", 471 | callback=validate_parse_subject, 472 | default="/CN=Default Extension CA/O=Some Company/OU=Extension CA", 473 | show_default=True, 474 | help="certificate subject. Accepted format is /key0=value0/key1=value1/...", 475 | ) 476 | @click.option("--force", is_flag=True, help="overwrites already existing CA key and certificate") 477 | @click.option("--dev-cert", default=const.DEFAULT_DEV_CERT, show_default=True, help="Developer certificate output path") 478 | @click.option("--dev-key", default=const.DEFAULT_DEV_KEY, show_default=True, help="Developer key output path") 479 | @click.option( 480 | "--dev-passphrase", 481 | type=str, 482 | prompt="Developer private key passphrase", 483 | confirmation_prompt=True, 484 | hide_input=True, 485 | default="", 486 | help="Sets passphrase for developer private key encryption - private key is not encrypted if empty", 487 | ) 488 | @click.option( 489 | "--no-dev-passphrase", 490 | default=False, 491 | is_flag=True, 492 | is_eager=True, 493 | help="Skips prompt for developer private key encryption passphrase - private key is not encrypted", 494 | # TODO: this is borderline unreadable - refactor 495 | callback=lambda c, p, v: edit_other_option_if_true( 496 | c, p, v, "dev_passphrase", lambda param: setattr(param, "prompt", None) # noqa: B010 497 | ), 498 | ) 499 | @click.option( 500 | "--dev-subject", 501 | callback=validate_parse_subject, 502 | default="/CN=Some Developer/O=Some Company/OU=Extension Development", 503 | show_default=True, 504 | help="certificate subject. Accepted format is /key0=value0/key1=value1/...", 505 | ) 506 | @click.option( 507 | "--days-valid", 508 | default=const.DEFAULT_CERT_VALIDITY, 509 | show_default=True, 510 | type=int, 511 | help="Number of days certificate will be valid", 512 | ) 513 | def gencerts(**kwargs): 514 | _genca( 515 | kwargs["ca_cert"], 516 | kwargs["ca_key"], 517 | kwargs["force"], 518 | kwargs["ca_subject"], 519 | kwargs["days_valid"], 520 | kwargs["ca_passphrase"], 521 | ) 522 | signing.generate_cert( 523 | kwargs["ca_cert"], 524 | kwargs["ca_key"], 525 | kwargs["dev_cert"], 526 | kwargs["dev_key"], 527 | kwargs["dev_subject"], 528 | datetime.datetime.today() + datetime.timedelta(days=kwargs["days_valid"]), 529 | kwargs["ca_passphrase"], 530 | kwargs["dev_passphrase"], 531 | ) 532 | 533 | 534 | _deprecate_above, _deprecate_below = deprecated("dt ext assemble or dt ext sign") 535 | 536 | 537 | @_deprecate_above 538 | @extension.command( 539 | help=( 540 | f"Build and sign extension package from the given extension directory " 541 | f"(default: {const.DEFAULT_EXTENSION_DIR}) " 542 | f"that contains extension.yaml and additional asset directories" 543 | ) 544 | ) 545 | @_deprecate_below 546 | @click.option( 547 | "--extension-directory", 548 | default=const.DEFAULT_EXTENSION_DIR, 549 | show_default=True, 550 | help="Directory where the `extension.yaml' and other extension files are located", 551 | ) 552 | @click.option( 553 | "--target-directory", 554 | default=const.DEFAULT_TARGET_PATH, 555 | show_default=True, 556 | help="Directory where extension package should be written", 557 | ) 558 | @click.option( 559 | "--certificate", 560 | default=const.DEFAULT_DEV_CERT, 561 | show_default=True, 562 | help="Developer certificate used for signing", 563 | ) 564 | @click.option( 565 | "--private-key", 566 | default=const.DEFAULT_DEV_KEY, 567 | show_default=True, 568 | help="Developer private key used for signing", 569 | ) 570 | @click.option( 571 | "--dev-passphrase", 572 | type=str, 573 | prompt="Developer private key passphrase", 574 | hide_input=True, 575 | default="", 576 | help="Passphrase used for developer private key encryption", 577 | ) 578 | @click.option( 579 | "--no-dev-passphrase", 580 | default=False, 581 | is_flag=True, 582 | is_eager=True, 583 | help="Skips prompt for developer private key encryption passphrase", 584 | # TODO: this is borderline unreadable - refactor 585 | callback=lambda c, p, v: edit_other_option_if_true( 586 | c, p, v, "dev_passphrase", lambda param: setattr(param, "prompt", None) # noqa: B010 587 | ), 588 | ) 589 | @click.option( 590 | "--keep-intermediate-files", 591 | is_flag=True, 592 | default=False, 593 | help="Do not delete the signature and `extension.zip' files after building extension archive", 594 | ) 595 | def build(**kwargs): 596 | extension_dir_path = kwargs["extension_directory"] 597 | utils.require_dir_exists(extension_dir_path) 598 | target_dir_path = kwargs["target_directory"] 599 | 600 | if extension_dir_path == target_dir_path: 601 | click.echo("Warning: extension_directory is the same as target_directory\n" 602 | f"This {click.style('might', bold=True)} cause to include secrets or excessive files", err=True) 603 | # TODO: remove the inner Path call by parsing the argument earlier 604 | elif _Path_is_relative(Path(target_dir_path), extension_dir_path): 605 | click.echo("Warning: target directory contains extension directory \n" 606 | f"This {click.style('might', bold=True)} cause to include secrets or excessive files", err=True) 607 | 608 | if os.path.exists(target_dir_path): 609 | utils.require_dir_exists(target_dir_path) 610 | if not os.path.isdir(target_dir_path): 611 | print("%s is not a directory, aborting!" % target_dir_path) 612 | return 613 | else: 614 | print("Creating target directory: %s" % target_dir_path) 615 | os.makedirs(target_dir_path, exist_ok=True) 616 | 617 | extension_zip_path = os.path.join(target_dir_path, const.EXTENSION_ZIP) 618 | extension_zip_sig_path = os.path.join(target_dir_path, const.EXTENSION_ZIP_SIG) 619 | 620 | certificate_file_path = kwargs["certificate"] 621 | utils.require_file_exists(certificate_file_path) 622 | private_key_file_path = kwargs["private_key"] 623 | utils.require_file_exists(private_key_file_path) 624 | 625 | building.build_and_sign( 626 | extension_dir_path, 627 | extension_zip_path, 628 | extension_zip_sig_path, 629 | target_dir_path, 630 | certificate_file_path, 631 | private_key_file_path, 632 | kwargs["dev_passphrase"], 633 | kwargs["keep_intermediate_files"], 634 | ) 635 | 636 | 637 | @typer_extension.command() 638 | def assemble( 639 | source: Path = typer.Option( 640 | const.DEFAULT_EXTENSION_DIR2, 641 | "--src", "--source", 642 | exists=True, dir_okay=True, 643 | readable=True, 644 | help="Directory where the `extension.yaml' and other extension files are located", 645 | ), 646 | destination: Path = typer.Option( 647 | str(const.DEFAULT_BUILD_OUTPUT), 648 | "-o", "--output", 649 | writable=True, dir_okay=False, 650 | help="Location where the extension package will be written", 651 | ), 652 | force: bool = FORCE_OPTION 653 | ): 654 | """ 655 | Build extension package. 656 | """ 657 | if destination.exists() and not force: 658 | raise click.BadParameter(f"destination {destination} already exists, please try again with --force to proceed " 659 | f"irregardless", param_hint="--source") 660 | 661 | if _Path_is_relative(destination, source): 662 | click.echo("Warning: source directory contains destination directory\n" 663 | f"This {click.style('might', bold=True)} cause to include secrets or excessive files", err=True) 664 | 665 | building.build(extension_dir=source, extension_zip=destination) 666 | 667 | 668 | @typer_extension.command() 669 | def sign( 670 | payload: Path = typer.Option( 671 | const.DEFAULT_BUILD_OUTPUT, 672 | "--src", "--source", 673 | exists=True, dir_okay=False, 674 | help="Path to zipped extension file; payload for signing", 675 | ), 676 | destination: Path = typer.Option( 677 | const.EXTENSION_ZIP_BUNDLE, 678 | "--output", "-o", 679 | writable=True, 680 | help="Location where signed extension package will be written", 681 | ), 682 | certkey: Path = typer.Option( 683 | const.DEFAULT_KEYCERT_PATH, 684 | "--key", 685 | exists=True, dir_okay=False, 686 | help="Location of the fused key-certificate for signing with", 687 | ), 688 | force: bool = FORCE_OPTION 689 | ): 690 | """ 691 | Produce signed extension package. 692 | 693 | Certificates with passphrase are currently not supported as if you required that kind of level of security it 694 | wouldn't be wise to use this command in it's current form. If you'd like this feature to be implemented sooner 695 | please visit https://github.com/dynatrace-oss/dt-cli/issues/81 and upvote. 696 | """ 697 | # TODO: get rid of the experimental warrning once all the utiliteis support fused certkey 698 | 699 | def is_key_permissions_ok(): 700 | permissions = utils.acquire_file_dac(certkey) 701 | 702 | # Windows doesn't distinguish between user, group and other in that way 703 | if platform.system() == "Windows": 704 | click.echo("Warning: skipping file permission check", err=True) 705 | return True 706 | else: 707 | return permissions == const.REQUIRED_PRIVATE_KEY_PERMISSIONS 708 | 709 | if not is_key_permissions_ok() and not force: 710 | raise click.BadParameter( 711 | ( 712 | f"key {certkey} has permissions that are too relaxes - we recommend " 713 | f"{oct(const.REQUIRED_PRIVATE_KEY_PERMISSIONS)}, please fix the " 714 | f"permissions via " 715 | f"chmod {oct(const.REQUIRED_PRIVATE_KEY_PERMISSIONS)[-3:]} {certkey} " 716 | f"and try again or try again with --force to proceed irregardless" 717 | ), 718 | param_hint="--key", 719 | ) 720 | 721 | if destination.exists(): 722 | if force: 723 | click.echo(f"Warning: overwritting {destination}", err=True) 724 | else: 725 | raise click.BadParameter(f"destination {destination} already exists, please try again with --force to " 726 | f"proceed irregardless", param_hint="--source") 727 | 728 | # TODO: see generate_developer_pem 729 | # TODO: implement sensible passphrase handling - it should be a prompt only when it's required 730 | # and handled securely (like... cleared from memory), also: get rid of the comment in help 731 | 732 | building.sign(payload, destination, certkey) 733 | 734 | 735 | @extension.command( 736 | help="Validates extension package using Dynatrace Cluster API" 737 | ) 738 | @click.argument("extension-zip", type=click.Path(exists=True, readable=True)) 739 | @tenant_url 740 | @click.option( 741 | "--api-token", 742 | prompt=True, 743 | help="Dynatrace API token. Please note that token needs to have the 'Write extension' scope enabled.", 744 | ) 745 | def validate(**kwargs): 746 | extension_zip = kwargs["extension_zip"] 747 | utils.require_file_exists(extension_zip) 748 | server_api.validate(extension_zip, kwargs["tenant_url"], kwargs["api_token"]) 749 | 750 | 751 | @extension.command(help="Uploads extension package to the Dynatrace Cluster") 752 | @click.argument("extension-zip", type=click.Path(exists=True, readable=True)) 753 | @tenant_url 754 | @click.option( 755 | "--api-token", 756 | prompt=True, 757 | help="Dynatrace API token. Please note that token needs to have the 'Write extension' scope enabled.", 758 | ) 759 | def upload(**kwargs): 760 | extension_zip = kwargs["extension_zip"] 761 | utils.require_file_exists(extension_zip) 762 | server_api.upload(extension_zip, kwargs["tenant_url"], kwargs["api_token"]) 763 | 764 | 765 | @extension.command( 766 | help="Download alert from choosen id (E|). Token - API v1 scopes Read and Write Configuration." 767 | ) 768 | @click.argument( 769 | "alert-id", nargs=1 770 | ) 771 | @requires_tenant 772 | def alert(**kwargs): 773 | token = kwargs["api_token_path"] 774 | dt = api.DynatraceAPIClient(kwargs["tenant_url"], token=token) 775 | alert = dt.acquire_alert(kwargs["alert_id"]) 776 | print(json.dumps(alert, indent=4)) 777 | 778 | 779 | @extension.command( 780 | help="Downloads all schemas from choosen version e.g. 1.235" 781 | ) 782 | @click.argument( 783 | "version", nargs=1 784 | ) 785 | @tenant_url 786 | @api_token 787 | @click.option( 788 | "--download-dir", 789 | # TODO: this should be path 790 | default=const.DEFAULT_SCHEMAS_DOWNLOAD_DIR, show_default=True, 791 | help="Directory where downloaded schema files will be saved.", 792 | ) 793 | def schemas(**kwargs): 794 | token = kwargs["api_token_path"] 795 | dt = api.DynatraceAPIClient(kwargs["tenant_url"], token=token) 796 | version = dt.download_schemas(kwargs["version"], kwargs["download_dir"]) 797 | print(f"Downloaded schemas for version {version}") 798 | 799 | 800 | @extension.command() 801 | @click.argument( 802 | "extension", 803 | nargs=1, 804 | ) 805 | @tenant_url 806 | @api_token 807 | def delete(**kwargs): 808 | """ 809 | Delete extension from Dynatrace Cluster. 810 | 811 | Example: custom:com.dynatrace.extension.extension-name 812 | """ 813 | token = kwargs["api_token_path"] 814 | try: 815 | delete_extension.wipe(fqdn=kwargs["extension"], tenant=kwargs["tenant_url"], token=token) 816 | except requests.exceptions.HTTPError as err: 817 | if err.response.status_code == 404: 818 | raise click.BadParameter(err, param_hint="EXTENSION") 819 | else: 820 | raise 821 | 822 | 823 | @extension.command( 824 | help="Validate extension with schemas" 825 | ) 826 | @click.option( 827 | "--instance", 828 | type=click.Path(exists=True, dir_okay=False), 829 | callback=mk_click_callback(Path), 830 | default=const.EXTENSION_YAML, show_default=True, 831 | help="Extension file", 832 | ) 833 | @click.option( 834 | "--schema-entrypoint", 835 | type=click.Path(exists=True, dir_okay=False), 836 | callback=mk_click_callback(Path), 837 | default=const.SCHEMAS_ENTRYPOINT, show_default=True, 838 | help="Schema entrypoint. Assumption: All schema files are in the same directory.", 839 | ) 840 | def validate_schema(instance, **kwargs): 841 | errors = _validate_schema.validate_schema( 842 | extension_yaml_path=instance, extension_schema_path=kwargs["schema_entrypoint"], 843 | warn=functools.partial(click.echo, err=True), 844 | ) 845 | invalid = False 846 | if errors: 847 | invalid = True 848 | for i, e in enumerate(errors): 849 | print(f'{10 * "-"} error {i} {10 * "-"}', file=sys.stderr) 850 | print(f'line: {e["line"]}, column: {e["column"]}', file=sys.stderr) 851 | print(f'path: {e["path"]}', file=sys.stderr) 852 | cause = e["cause"] 853 | if isinstance(cause, dict): 854 | pprint(f'cause: {cause}', stream=sys.stderr) 855 | else: 856 | print(f'cause: {e["cause"]}', file=sys.stderr) 857 | if invalid: 858 | print(f"{30 * '-'}", file=sys.stderr) 859 | raise click.ClickException(f"{i + 1} validation errors total, aborting!") 860 | 861 | 862 | @extension_dev.command() 863 | @click.argument( 864 | "path-to-setup-py", 865 | ) 866 | @click.option("--additional-libraries-dir", default=None, help="Path to folder containing additional directories") 867 | @click.option( 868 | "--extension-directory", 869 | default=const.DEFAULT_EXTENSION_DIR, 870 | help="Directory where extension files are. Default: " + const.DEFAULT_EXTENSION_DIR, 871 | ) 872 | def prepare_python(path_to_setup_py, **kwargs): 873 | """ 874 | Pack python package as a datasource. 875 | 876 | It uses pip to download all dependencies and create whl files 877 | """ 878 | additional_libraries_dir = kwargs.get("additional_libraries_dir", None) 879 | extension_directory = kwargs["extension_directory"] 880 | 881 | return dev.pack_python_extension( 882 | setup_path=path_to_setup_py, target_path=extension_directory, additional_path=additional_libraries_dir 883 | ) 884 | 885 | 886 | for name, cmd in typer.main.get_command(typer_extension).commands.items(): 887 | extension.add_command(cmd, name) 888 | 889 | 890 | main.add_command(typer.main.get_command(utility_app), "utility") 891 | --------------------------------------------------------------------------------