├── VERSION ├── tests ├── __init__.py ├── test_cli_system.py ├── unikube_file │ ├── unikube_apps_invalid.yaml │ ├── unikube_version_latest.yaml │ ├── unikube_version_1.yaml │ ├── unikube.yaml │ ├── unikube_apps_default.yaml │ └── test_unikube_file.py ├── test_cli_ps.py ├── test_context.py ├── test_cli_completion.py ├── login_testcase.py ├── test_cli_app.py ├── test_cli_orga.py ├── test_cli_project.py ├── test_cli_deck.py ├── test_cli_helper.py ├── test_cli_auth.py ├── test_cli_init.py └── console │ └── test_input.py ├── unikube ├── __init__.py ├── cli │ ├── __init__.py │ ├── console │ │ ├── exit.py │ │ ├── output.py │ │ ├── __init__.py │ │ ├── container.py │ │ ├── orga.py │ │ ├── project.py │ │ ├── deck.py │ │ ├── helpers.py │ │ ├── logger.py │ │ └── input.py │ ├── helper.py │ ├── unikube.py │ ├── orga.py │ ├── utils.py │ ├── auth.py │ ├── system.py │ ├── context.py │ └── init.py ├── keycloak │ ├── __init__.py │ └── permissions.py ├── local │ ├── __init__.py │ ├── providers │ │ ├── __init__.py │ │ ├── k3d │ │ │ ├── __init__.py │ │ │ ├── types.py │ │ │ ├── storage.py │ │ │ └── k3d.py │ │ ├── types.py │ │ ├── helper.py │ │ ├── factory.py │ │ ├── manager.py │ │ └── abstract_provider.py │ └── exceptions.py ├── storage │ ├── __init__.py │ ├── types.py │ ├── general.py │ ├── user.py │ ├── tinydb.py │ └── local_storage.py ├── completion │ ├── __init__.py │ ├── templates.py │ └── completion.py ├── unikubefile │ ├── __init__.py │ ├── unikube_file.py │ ├── selector.py │ └── unikube_file_1_0.py ├── authentication │ ├── __init__.py │ ├── types.py │ ├── web.py │ └── authentication.py ├── __main__.py ├── types.py ├── context │ ├── types.py │ ├── __init__.py │ ├── helper.py │ └── context.py ├── _backup │ ├── decorators.py │ └── utils_project.py ├── settings.py ├── graphql_utils.py ├── helpers.py └── commands.py ├── logo_cli.png ├── .coveragerc ├── docs ├── _static │ ├── favicon │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ └── favicon-32x32.png │ ├── img │ │ ├── project-unikube-1.png │ │ ├── project-unikube-2.png │ │ ├── project-unikube-ship.png │ │ ├── project-unikube-install.png │ │ └── navigation_background.svg │ └── css │ │ └── custom.css ├── reference │ ├── app.rst │ ├── orga.rst │ ├── auth.rst │ ├── deck.rst │ ├── context.rst │ ├── system.rst │ ├── project.rst │ └── shortcut.rst ├── requirements.txt ├── Makefile ├── make.bat ├── index.rst └── conf.py ├── MANIFEST.in ├── requirements.dev.txt ├── setup.cfg ├── .isort.cfg ├── .github ├── dependabot.yml ├── workflows │ ├── python-publish.yaml │ └── python-app.yaml └── unikube-release.yml ├── cloudbuild.yaml ├── requirements.txt ├── .bumpversion.cfg ├── .pre-commit-config.yaml ├── setup.py ├── README.md ├── .gitignore └── CHANGELOG.md /VERSION: -------------------------------------------------------------------------------- 1 | 1.3.2-dev2 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_cli_system.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/keycloak/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/local/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/completion/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/unikubefile/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/local/providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unikube/local/providers/k3d/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logo_cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/logo_cli.png -------------------------------------------------------------------------------- /tests/unikube_file/unikube_apps_invalid.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | apps: 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = unikube/ 4 | concurrency=multiprocessing -------------------------------------------------------------------------------- /unikube/local/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnikubeClusterUnavailableError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /unikube/__main__.py: -------------------------------------------------------------------------------- 1 | from unikube.commands import cli 2 | 3 | if __name__ == "__main__": 4 | cli() 5 | -------------------------------------------------------------------------------- /docs/_static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/favicon/favicon.ico -------------------------------------------------------------------------------- /docs/_static/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /docs/_static/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /docs/_static/img/project-unikube-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/img/project-unikube-1.png -------------------------------------------------------------------------------- /docs/_static/img/project-unikube-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/img/project-unikube-2.png -------------------------------------------------------------------------------- /docs/reference/app.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_app: 2 | 3 | .. click:: unikube.commands:app 4 | :prog: unikube app 5 | :nested: full -------------------------------------------------------------------------------- /docs/reference/orga.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_orga: 2 | 3 | .. click:: unikube.commands:orga 4 | :prog: unikube orga 5 | :nested: full -------------------------------------------------------------------------------- /docs/_static/img/project-unikube-ship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/img/project-unikube-ship.png -------------------------------------------------------------------------------- /docs/reference/auth.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_auth: 2 | 3 | .. click:: unikube.commands:auth 4 | :prog: unikube auth 5 | :nested: full 6 | -------------------------------------------------------------------------------- /docs/reference/deck.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_deck: 2 | 3 | .. click:: unikube.commands:deck 4 | :prog: unikube deck 5 | :nested: full -------------------------------------------------------------------------------- /unikube/types.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ConfigurationData(BaseModel): 5 | auth_host: str = "" 6 | -------------------------------------------------------------------------------- /docs/_static/img/project-unikube-install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unikubehq/cli/HEAD/docs/_static/img/project-unikube-install.png -------------------------------------------------------------------------------- /docs/reference/context.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_context: 2 | 3 | .. click:: unikube.commands:context 4 | :prog: unikube context 5 | :nested: full -------------------------------------------------------------------------------- /docs/reference/system.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_system: 2 | 3 | .. click:: unikube.commands:system 4 | :prog: unikube system 5 | :nested: full 6 | -------------------------------------------------------------------------------- /docs/reference/project.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_project: 2 | 3 | .. click:: unikube.commands:project 4 | :prog: unikube project 5 | :nested: full 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # documentation 2 | Sphinx==4.5.0 3 | sphinx-click==4.0.3 4 | sphinx-rtd-theme==1.0.0 5 | 6 | # cli 7 | -r ../requirements.txt 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | include README.md 4 | include requirements.txt 5 | include VERSION 6 | 7 | recursive-include unikube/ * 8 | -------------------------------------------------------------------------------- /docs/reference/shortcut.rst: -------------------------------------------------------------------------------- 1 | .. _unikube_shortcut: 2 | 3 | .. click:: unikube.commands:cli 4 | :prog: unikube 5 | :commands: login, logout, install, up, shell 6 | :nested: full -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # version 2 | bump2version==1.0.1 3 | 4 | # documentation 5 | Sphinx==4.5.0 6 | sphinx-click==4.0.3 7 | sphinx-rtd-theme==1.0.0 8 | coveralls==3.3.1 9 | coverage>=5.3 10 | -------------------------------------------------------------------------------- /tests/unikube_file/unikube_version_latest.yaml: -------------------------------------------------------------------------------- 1 | apps: 2 | projects: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | target: development 7 | deployment: test 8 | command: bash 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=0 3 | 4 | [isort] 5 | multi_line_output = 3 6 | include_trailing_comma = True 7 | force_grid_wrap = 0 8 | use_parentheses = True 9 | ensure_newline_before_comments = True 10 | line_length = 120 -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_third_party = coverage,click,setuptools 3 | multi_line_output = 3 4 | include_trailing_comma = True 5 | force_grid_wrap = 0 6 | use_parentheses = True 7 | ensure_newline_before_comments = True 8 | line_length = 120 -------------------------------------------------------------------------------- /tests/unikube_file/unikube_version_1.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | apps: 4 | projects: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: development 9 | deployment: test 10 | command: bash 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "08:00" 8 | open-pull-requests-limit: 8 9 | labels: 10 | - dependabot 11 | -------------------------------------------------------------------------------- /unikube/context/types.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ContextData(BaseModel): 7 | organization_id: Optional[str] = None 8 | project_id: Optional[str] = None 9 | deck_id: Optional[str] = None 10 | -------------------------------------------------------------------------------- /unikube/authentication/types.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class AuthenticationData(BaseModel): 5 | email: str = "" 6 | access_token: str = "" 7 | refresh_token: str = "" 8 | requesting_party_token: bool = False 9 | public_key: str = "" 10 | -------------------------------------------------------------------------------- /unikube/local/providers/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from pydantic.main import BaseModel 5 | 6 | 7 | class K8sProviderType(Enum): 8 | k3d = "k3d" 9 | 10 | 11 | class K8sProviderData(BaseModel): 12 | id: str 13 | name: Optional[str] = None 14 | -------------------------------------------------------------------------------- /unikube/local/providers/k3d/types.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from unikube.local.providers.types import K8sProviderData 4 | 5 | 6 | class K3dData(K8sProviderData): 7 | api_port: Optional[str] = None 8 | publisher_port: Optional[str] = None 9 | kubeconfig_path: Optional[str] = None 10 | -------------------------------------------------------------------------------- /tests/unikube_file/unikube.yaml: -------------------------------------------------------------------------------- 1 | apps: 2 | your-app-01: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | target: target 7 | deployment: deployment 8 | port: 8000 9 | command: bash 10 | volumes: 11 | - ./src:/app 12 | env: 13 | - VARIABLE-01: "variable-01" 14 | -------------------------------------------------------------------------------- /unikube/cli/console/exit.py: -------------------------------------------------------------------------------- 1 | from unikube.cli.console.logger import error, info 2 | 3 | 4 | def exit_login_required(): 5 | info("You need to login (again). Please run 'unikube login' and try again.") 6 | exit(1) 7 | 8 | 9 | def exit_generic_error(): 10 | error("Something went wrong!") 11 | exit(1) 12 | -------------------------------------------------------------------------------- /unikube/cli/console/output.py: -------------------------------------------------------------------------------- 1 | import click 2 | from tabulate import tabulate 3 | 4 | from unikube import settings 5 | 6 | 7 | def table(data, headers={}): 8 | click.echo( 9 | tabulate( 10 | data, 11 | headers=headers, 12 | tablefmt=settings.CLI_TABLEFMT, 13 | ) 14 | ) 15 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | # static docs 3 | - name: eu.gcr.io/unikube-io/sphinx 4 | entrypoint: bash 5 | args: 6 | - '-c' 7 | - | 8 | pip install -r requirements.txt \ 9 | && make html 10 | dir: 'docs' 11 | - name: gcr.io/cloud-builders/gsutil 12 | args: ["-m", "rsync", "-r", "-c", "-d", "./docs/build/html", "gs://unikube-cli-docs"] 13 | -------------------------------------------------------------------------------- /unikube/cli/console/__init__.py: -------------------------------------------------------------------------------- 1 | from .container import container_list 2 | from .deck import deck_list 3 | from .exit import exit_generic_error, exit_login_required 4 | from .input import confirm, input, list 5 | from .logger import debug, echo, error, info, link, success, warning 6 | from .orga import organization_list 7 | from .output import table 8 | from .project import project_list 9 | -------------------------------------------------------------------------------- /tests/unikube_file/unikube_apps_default.yaml: -------------------------------------------------------------------------------- 1 | apps: 2 | your-app-01: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | target: development 7 | deployment: test 8 | command: bash 9 | 10 | default: 11 | build: 12 | context: . 13 | dockerfile: Dockerfile 14 | target: development 15 | deployment: test 16 | command: bash 17 | -------------------------------------------------------------------------------- /tests/test_cli_ps.py: -------------------------------------------------------------------------------- 1 | from tests.login_testcase import LoginTestCase 2 | from unikube.cli.unikube import ps 3 | from unikube.commands import ClickContext 4 | 5 | 6 | class PsTest(LoginTestCase): 7 | def test_no_cluster(self): 8 | result = self.runner.invoke( 9 | ps, 10 | obj=ClickContext(), 11 | ) 12 | self.assertEqual(result.exit_code, 0) 13 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from unikube.context.helper import is_valid_uuid4 4 | 5 | 6 | class IsValidUuid4Test(unittest.TestCase): 7 | def test_is_valid_uuid4_valid(self): 8 | result = is_valid_uuid4("51b1d6b3-8375-4859-94f6-73afc05d7275") 9 | self.assertTrue(result) 10 | 11 | def test_is_valid_uuid4_invalid(self): 12 | result = is_valid_uuid4("invalid") 13 | self.assertFalse(result) 14 | -------------------------------------------------------------------------------- /unikube/cli/console/container.py: -------------------------------------------------------------------------------- 1 | import unikube.cli.console as console 2 | 3 | 4 | def container_list(data): 5 | if len(data.spec.containers) <= 1: 6 | return None 7 | 8 | container = console.list( 9 | message="Please select a container", 10 | message_no_choices="No container is running.", 11 | choices=[c.name for c in data.spec.containers], 12 | ) 13 | if container is None: 14 | return None 15 | 16 | return container 17 | -------------------------------------------------------------------------------- /unikube/local/providers/helper.py: -------------------------------------------------------------------------------- 1 | from unikube.cli import console 2 | from unikube.local.providers.abstract_provider import AbstractK8sProvider 3 | 4 | 5 | def get_cluster_or_exit(ctx, project_id) -> AbstractK8sProvider: 6 | cluster_data = ctx.cluster_manager.get(id=project_id) 7 | cluster = ctx.cluster_manager.select(cluster_data=cluster_data) 8 | if not cluster: 9 | console.error("The project cluster does not exist.", _exit=True) 10 | 11 | return cluster 12 | -------------------------------------------------------------------------------- /unikube/unikubefile/unikube_file.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from unikube.context.types import ContextData 4 | 5 | 6 | class UnikubeFileNotFoundError(Exception): 7 | pass 8 | 9 | 10 | class UnikubeFileVersionError(Exception): 11 | pass 12 | 13 | 14 | class UnikubeFileError(Exception): 15 | pass 16 | 17 | 18 | class UnikubeFile(ABC): 19 | @abstractmethod 20 | def get_context(self) -> ContextData: 21 | raise NotImplementedError 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # click + console 2 | click==8.1.2 3 | click-didyoumean~=0.3.0 4 | click-spinner==0.1.10 5 | colorama~=0.4.4 # color logging windows 6 | inquirerpy~=0.3.3 7 | tabulate~=0.8.9 8 | 9 | # libraries 10 | pydantic~=1.9.0 11 | pyyaml>=5.4.0 12 | tinydb~=4.7.0 13 | python-slugify~=6.1.1 14 | 15 | requests~=2.27.1 16 | requests-toolbelt~=0.9.1 17 | pyjwt[crypto]~=2.3.0 18 | 19 | gql~=3.2.0 20 | semantic-version~=2.9.0 21 | kubernetes>=11.0.0,<22.0.0 22 | retrying~=1.3.3 23 | oic==1.3.0 24 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.3.2-dev2 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))? 7 | serialize = 8 | {major}.{minor}.{patch}-{release}{build} 9 | {major}.{minor}.{patch} 10 | 11 | [bumpversion:file:VERSION] 12 | 13 | [bumpversion:part:release] 14 | optional_value = gamma 15 | values = 16 | dev 17 | gamma 18 | 19 | [bumpversion:part:build] 20 | first_value = 1 21 | -------------------------------------------------------------------------------- /tests/test_cli_completion.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from click.testing import CliRunner 4 | 5 | from unikube.commands import ClickContext, completion 6 | 7 | 8 | class CompletionTest(unittest.TestCase): 9 | def setUp(self) -> None: 10 | self.runner = CliRunner() 11 | 12 | def test_no_cluster(self): 13 | result = self.runner.invoke( 14 | completion, 15 | ["bash"], 16 | obj=ClickContext(), 17 | ) 18 | self.assertEqual(result.exit_code, 0) 19 | -------------------------------------------------------------------------------- /unikube/local/providers/k3d/storage.py: -------------------------------------------------------------------------------- 1 | from unikube.local.providers.abstract_provider import AbstractK8SProviderStorage 2 | from unikube.local.providers.k3d.types import K3dData 3 | from unikube.storage.local_storage import LocalStorage 4 | 5 | 6 | class K3dLocalStorage(LocalStorage): 7 | table_name = "k3d" 8 | pydantic_class = K3dData 9 | 10 | 11 | class K3dStorage(AbstractK8SProviderStorage): 12 | def __init__(self, id: str) -> None: 13 | super().__init__( 14 | id=id, 15 | storage=K3dLocalStorage(), 16 | ) 17 | -------------------------------------------------------------------------------- /unikube/context/__init__.py: -------------------------------------------------------------------------------- 1 | class ClickContext(object): 2 | def __init__(self): 3 | from unikube.authentication.authentication import get_authentication 4 | from unikube.context.context import Context 5 | from unikube.local.providers.manager import K8sClusterManager 6 | from unikube.storage.general import LocalStorageGeneral 7 | 8 | self.auth = get_authentication() 9 | self.storage_general = LocalStorageGeneral() 10 | self.context: Context = Context(auth=self.auth) 11 | self.cluster_manager = K8sClusterManager() 12 | -------------------------------------------------------------------------------- /unikube/storage/types.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from unikube.authentication.types import AuthenticationData 4 | from unikube.context.types import ContextData 5 | from unikube.types import ConfigurationData 6 | 7 | 8 | class TinyDatabaseData(BaseModel): 9 | id: str 10 | 11 | 12 | class GeneralData(TinyDatabaseData): 13 | id: str = "general" 14 | authentication: AuthenticationData = AuthenticationData() 15 | 16 | 17 | class UserData(TinyDatabaseData): 18 | context: ContextData = ContextData() 19 | config: ConfigurationData = ConfigurationData() 20 | -------------------------------------------------------------------------------- /unikube/storage/general.py: -------------------------------------------------------------------------------- 1 | from unikube.storage.local_storage import LocalStorage 2 | from unikube.storage.types import GeneralData 3 | 4 | 5 | class LocalStorageGeneral(LocalStorage): 6 | table_name = GeneralData().id 7 | pydantic_class = GeneralData 8 | 9 | document_id = GeneralData().id 10 | 11 | def get(self) -> GeneralData: 12 | data = super().get(id=self.document_id) 13 | return self.pydantic_class(**data.dict()) 14 | 15 | def set(self, data: GeneralData) -> None: 16 | super().set(id=self.document_id, data=data) 17 | 18 | def delete(self) -> None: 19 | super().delete(id=self.document_id) 20 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?=-E 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /unikube/local/providers/factory.py: -------------------------------------------------------------------------------- 1 | from unikube.local.providers.k3d.k3d import K3dBuilder 2 | from unikube.local.providers.types import K8sProviderType 3 | 4 | 5 | class K8sClusterFactory: 6 | def __init__(self): 7 | self._builders = {} 8 | 9 | def register_builder(self, provider_type: K8sProviderType, builder): 10 | self._builders[provider_type.value] = builder 11 | 12 | def __create(self, provider_type: K8sProviderType, **kwargs): 13 | builder = self._builders.get(provider_type.value) 14 | if not builder: 15 | raise ValueError(provider_type) 16 | return builder(**kwargs) 17 | 18 | def get(self, provider_type: K8sProviderType, **kwargs): 19 | return self.__create(provider_type, **kwargs) 20 | 21 | 22 | kubernetes_cluster_factory = K8sClusterFactory() 23 | kubernetes_cluster_factory.register_builder(K8sProviderType.k3d, K3dBuilder()) 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/login_testcase.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from click.testing import CliRunner 5 | 6 | from unikube.cli import auth 7 | from unikube.commands import ClickContext 8 | 9 | 10 | class LoginTestCase(unittest.TestCase): 11 | def setUp(self) -> None: 12 | self.runner = CliRunner() 13 | 14 | email = os.getenv("TESTRUNNER_EMAIL") 15 | secret = os.getenv("TESTRUNNER_SECRET") 16 | self.assertIsNotNone(email) 17 | self.assertIsNotNone(secret) 18 | 19 | self.runner.invoke( 20 | auth.login, 21 | ["--email", email, "--password", secret], 22 | obj=ClickContext(), 23 | ) 24 | 25 | def tearDown(self) -> None: 26 | result = self.runner.invoke( 27 | auth.logout, 28 | obj=ClickContext(), 29 | ) 30 | self.assertEqual(result.output, "[INFO] Logout completed.\n") 31 | self.assertEqual(result.exit_code, 0) 32 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 5 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* -------------------------------------------------------------------------------- /tests/test_cli_app.py: -------------------------------------------------------------------------------- 1 | from tests.login_testcase import LoginTestCase 2 | from unikube.cli import app 3 | from unikube.commands import ClickContext 4 | 5 | 6 | def check(): 7 | """Function used to mock check function""" 8 | pass 9 | 10 | 11 | class AppTestCase(LoginTestCase): 12 | def test_list(self): 13 | obj = ClickContext() 14 | obj.auth.check = check 15 | result = self.runner.invoke( 16 | app.list, 17 | obj=obj, 18 | ) 19 | assert result.exit_code == 1 20 | 21 | def test_shell_invalid_arguments(self): 22 | obj = ClickContext() 23 | obj.auth.check = check 24 | result = self.runner.invoke( 25 | app.shell, 26 | [ 27 | "test", 28 | "--organization", 29 | "13fc0b1b-3bc1-4a69-8e80-835fb1515bc4", 30 | "--project", 31 | "13fc0b1b-3bc1-4a69-8e80-835fb1515bc4", 32 | "--deck", 33 | "13fc0b1b-3bc1-4a69-8e80-835fb1515bc4", 34 | ], 35 | obj=obj, 36 | ) 37 | assert "[ERROR] Something went wrong!\n" in result.output 38 | -------------------------------------------------------------------------------- /unikube/storage/user.py: -------------------------------------------------------------------------------- 1 | from unikube.storage.general import LocalStorageGeneral 2 | from unikube.storage.local_storage import LocalStorage 3 | from unikube.storage.types import UserData 4 | 5 | 6 | class LocalStorageUser(LocalStorage): 7 | table_name = "user" 8 | pydantic_class = UserData 9 | 10 | def __init__(self, user_email) -> None: 11 | super().__init__() 12 | 13 | self.user_email = user_email 14 | 15 | def get(self) -> UserData: 16 | try: 17 | data = super().get(id=self.user_email) 18 | return self.pydantic_class(**data.dict()) 19 | except Exception: 20 | return UserData() 21 | 22 | def set(self, data: UserData) -> None: 23 | super().set(id=self.user_email, data=data) 24 | 25 | def delete(self) -> None: 26 | super().delete(id=self.user_email) 27 | 28 | 29 | def get_local_storage_user(): 30 | try: 31 | local_storage_general = LocalStorageGeneral() 32 | general_data = local_storage_general.get() 33 | local_storage_user = LocalStorageUser(user_email=general_data.authentication.email) 34 | return local_storage_user 35 | except Exception: 36 | return None 37 | -------------------------------------------------------------------------------- /unikube/storage/tinydb.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | from tinydb import Query, TinyDB 5 | 6 | from unikube import settings 7 | from unikube.storage.types import TinyDatabaseData 8 | 9 | 10 | class TinyDatabase: 11 | def __init__( 12 | self, 13 | table_name="database", 14 | ): 15 | self.table_name = table_name 16 | 17 | self.db = TinyDB(settings.CLI_CONFIG_FILE) 18 | self.table = self.db.table(self.table_name) 19 | 20 | def select(self, id: str) -> TinyDatabaseData: 21 | document = self.table.get(Query().id == id) 22 | return document 23 | 24 | def insert(self, data: BaseModel) -> int: 25 | doc_id = self.table.insert(data.dict()) 26 | return doc_id 27 | 28 | def update(self, id: str, data: BaseModel) -> List[int]: 29 | doc_id_list = self.table.upsert(data.dict(), Query().id == id) 30 | return doc_id_list 31 | 32 | def delete(self, id: str) -> bool: 33 | doc_id = self.table.remove(Query().id == id) 34 | if not doc_id: 35 | return False 36 | return True 37 | 38 | def drop(self): 39 | self.db.purge_table(self.table_name) 40 | -------------------------------------------------------------------------------- /.github/unikube-release.yml: -------------------------------------------------------------------------------- 1 | asset: true 2 | tap: unikubehq/homebrew-tooling/Formula/unikube.rb 3 | branches: 4 | - brew-release-automation 5 | template: > 6 | class Unikube < Formula 7 | include Language::Python::Virtualenv 8 | desc "CLI to run local Kubernetes development with ease." 9 | homepage "https://unikube.io/" 10 | license "Apache-2.0" 11 | 12 | depends_on "rust" => :build 13 | depends_on "python@3.8" 14 | depends_on "openssl@1.1" 15 | 16 | stable do 17 | url "$STABLE_URL" 18 | version "$STABLE_VERSION_NUMBER" 19 | sha256 "$STABLE_SHA256" 20 | $STABLE_PYTHON_DEPS 21 | end 22 | 23 | head do 24 | url "$DEVEL_URL" 25 | version "$DEVEL_VERSION_NUMBER" 26 | sha256 "$DEVEL_SHA256" 27 | $DEVEL_PYTHON_DEPS 28 | end 29 | 30 | def install 31 | venv = virtualenv_create(libexec, "python3") 32 | resources.each do |r| 33 | if r.name == "unikube" 34 | venv.pip_install_and_link r 35 | else 36 | venv.pip_install r 37 | end 38 | end 39 | venv.pip_install_and_link buildpath 40 | end 41 | 42 | test do 43 | assert_match "unikube, $STABLE_VERSION_NUMBER", shell_output("#{bin}/unikube version") 44 | end 45 | end -------------------------------------------------------------------------------- /tests/test_cli_orga.py: -------------------------------------------------------------------------------- 1 | from tests.login_testcase import LoginTestCase 2 | from unikube.cli import orga 3 | from unikube.commands import ClickContext 4 | 5 | 6 | class OrgaTestCase(LoginTestCase): 7 | def test_orga_info(self): 8 | result = self.runner.invoke( 9 | orga.info, 10 | ["ACME"], 11 | obj=ClickContext(), 12 | ) 13 | 14 | self.assertIn("Key", result.output) 15 | self.assertIn("Value", result.output) 16 | self.assertIn("title", result.output) 17 | self.assertIn("ACME", result.output) 18 | self.assertEqual(result.exit_code, 0) 19 | 20 | def test_info_not_existing_orga(self): 21 | result = self.runner.invoke( 22 | orga.info, 23 | "not_existing_orga", 24 | obj=ClickContext(), 25 | ) 26 | self.assertIn("[ERROR] Organization name/slug does not exist.\n", result.output) 27 | 28 | def test_orga_list(self): 29 | 30 | result = self.runner.invoke( 31 | orga.list, 32 | obj=ClickContext(), 33 | ) 34 | 35 | self.assertIn("id", result.output) 36 | self.assertIn("name", result.output) 37 | self.assertIn("acme", result.output) 38 | self.assertEqual(result.exit_code, 0) 39 | -------------------------------------------------------------------------------- /unikube/_backup/decorators.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | import click 4 | from utils.console import error 5 | from utils.exceptions import UnikubeClusterUnavailableError 6 | from utils.localsystem import K3D, KubeAPI 7 | from utils.project import AppManager, ProjectManager 8 | 9 | 10 | def retry_command(function): 11 | def wrapper(*args, **kwargs): 12 | try: 13 | res = function(*args, **kwargs) 14 | except UnikubeClusterUnavailableError: 15 | error("Cannot reach local cluster.") 16 | project = ProjectManager().get_active() 17 | app = AppManager().get_active() 18 | if click.confirm(f"Should we try to \"project up {project.get('name')}\"?"): 19 | K3D(project).up(ingress_port=None, workers=None) 20 | retry_count = 0 21 | k8s = KubeAPI(project, app) 22 | while not k8s.is_available and retry_count <= 30: 23 | sleep(0.5) 24 | retry_count += 1 25 | if retry_count == 30: 26 | error("Could not up project.") 27 | exit(1) 28 | res = function(*args, **kwargs) 29 | else: 30 | exit(1) 31 | return res 32 | 33 | return wrapper 34 | -------------------------------------------------------------------------------- /unikube/storage/local_storage.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from unikube.storage.tinydb import TinyDatabase 4 | from unikube.storage.types import TinyDatabaseData 5 | 6 | 7 | class ILocalStorage(ABC): 8 | @abstractmethod 9 | def get(self, id: str): 10 | raise NotImplementedError 11 | 12 | @abstractmethod 13 | def set(self, id: str, data: TinyDatabaseData): 14 | raise NotImplementedError 15 | 16 | @abstractmethod 17 | def delete(self, id: str): 18 | raise NotImplementedError 19 | 20 | 21 | class LocalStorage(ILocalStorage): 22 | table_name = "local" 23 | pydantic_class = TinyDatabaseData 24 | 25 | def __init__(self) -> None: 26 | # database / storage 27 | self.database = TinyDatabase(table_name=self.table_name) 28 | 29 | def get(self, id: str, **kwargs) -> TinyDatabaseData: 30 | try: 31 | document = self.database.select(id=id) 32 | return self.pydantic_class(**dict(document)) 33 | except Exception: 34 | return self.pydantic_class(id=id, **kwargs) 35 | 36 | def set(self, id: str, data: TinyDatabaseData) -> None: 37 | self.database.update(id=id, data=data) 38 | 39 | def delete(self, id: str) -> None: 40 | self.database.delete(id=id) 41 | -------------------------------------------------------------------------------- /tests/test_cli_project.py: -------------------------------------------------------------------------------- 1 | from tests.login_testcase import LoginTestCase 2 | from unikube.cli import orga, project 3 | from unikube.commands import ClickContext 4 | 5 | 6 | class ProjectTestCase(LoginTestCase): 7 | def test_project_info(self): 8 | result = self.runner.invoke( 9 | project.info, 10 | ["buzzword-counter"], 11 | obj=ClickContext(), 12 | ) 13 | 14 | self.assertIn("Key", result.output) 15 | self.assertIn("Value", result.output) 16 | self.assertIn("buzzword-counter", result.output) 17 | self.assertEqual(result.exit_code, 0) 18 | 19 | def test_info_not_existing_project(self): 20 | result = self.runner.invoke( 21 | project.info, 22 | ["not-existing-project"], 23 | obj=ClickContext(), 24 | ) 25 | 26 | self.assertIn("[ERROR] Project name/slug does not exist.\n", result.output) 27 | 28 | def test_project_list(self): 29 | result = self.runner.invoke( 30 | project.list, 31 | obj=ClickContext(), 32 | ) 33 | 34 | self.assertIn("id", result.output) 35 | self.assertIn("name", result.output) 36 | self.assertIn("buzzword-counter", result.output) 37 | self.assertEqual(result.exit_code, 0) 38 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. unikube-cli documentation master file 2 | 3 | Welcome to the Unikube CLI Documentation! 4 | ========================================= 5 | 6 | This is the Unikube CLI reference guide. 7 | 8 | This command line interface is implemented using Python and the 9 | `Click framework `_. The CLI is realized with the following goals in mind: 10 | 11 | * easy and intuitive for developers 12 | * scriptable (for continuous integration/delivery scenarios) 13 | * portable 14 | 15 | 16 | Command groups 17 | ============== 18 | All Unikube CLI commands are divided into several command groups, which represent a specific concept or unit, such as 19 | :ref:`unikube auth`, :ref:`unikube system` or :ref:`unikube project`. 20 | However, there are additional :ref:`shortcuts` for frequently used commands, which are directly 21 | accessible under :program:`unikube`, such as the :ref:`reference/shortcut:login`. 22 | 23 | Generally, commands in unikube CLI looks like this: 24 | 25 | .. code-block:: shell 26 | 27 | unikube [--OPTION] 28 | 29 | .. toctree:: 30 | :glob: 31 | 32 | reference/shortcut 33 | reference/auth 34 | reference/orga 35 | reference/project 36 | reference/deck 37 | reference/app 38 | reference/system 39 | reference/context 40 | 41 | Indices and Tables 42 | ================== 43 | 44 | * :ref:`genindex` 45 | * :ref:`modindex` 46 | * :ref:`search` 47 | -------------------------------------------------------------------------------- /unikube/cli/console/orga.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import unikube.cli.console as console 4 | from unikube.cli.console.input import get_identifier_or_pass 5 | from unikube.context.helper import convert_organization_argument_to_uuid 6 | from unikube.graphql_utils import GraphQL 7 | 8 | 9 | def organization_list(ctx) -> Union[None, str]: 10 | # GraphQL 11 | try: 12 | graph_ql = GraphQL(authentication=ctx.auth) 13 | data = graph_ql.query( 14 | """ 15 | query { 16 | allOrganizations { 17 | results { 18 | id 19 | title 20 | } 21 | } 22 | } 23 | """ 24 | ) 25 | organization_list = data["allOrganizations"]["results"] 26 | except Exception as e: 27 | console.debug(e) 28 | console.exit_generic_error() 29 | 30 | selection = console.list( 31 | message="Please select an organization", 32 | choices=[organization["title"] for organization in organization_list], 33 | identifiers=[organization["id"] for organization in organization_list], 34 | message_no_choices="No organizations available!", 35 | ) 36 | if selection is None: 37 | return None 38 | 39 | # get identifier if available 40 | organization_argument = get_identifier_or_pass(selection) 41 | 42 | organization_id = convert_organization_argument_to_uuid(ctx.auth, argument_value=organization_argument) 43 | return organization_id 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | # force all unspecified python hooks to run python3.8 3 | python: python3.8 4 | 5 | repos: 6 | # black - format python code 7 | - repo: https://github.com/psf/black 8 | rev: 22.1.0 9 | hooks: 10 | - id: black 11 | args: # arguments to configure black 12 | - --line-length=120 13 | 14 | # these folders wont be formatted by black 15 | - --exclude="""\.git | 16 | \.__pycache__| 17 | \.hg| 18 | \.mypy_cache| 19 | \.tox| 20 | \.venv| 21 | migrations| 22 | _build| 23 | buck-out| 24 | build| 25 | dist""" 26 | 27 | language_version: python3.8 28 | 29 | # flake8 - style guide enforcement 30 | - repo: https://gitlab.com/pycqa/flake8 31 | rev: 3.8.4 32 | hooks: 33 | - id: flake8 34 | args: # arguments to configure flake8 35 | # making line length compatible with black 36 | - "--max-line-length=120" 37 | - "--max-complexity=18" 38 | - "--select=B,C,E,F,W,T4,B9" 39 | 40 | # these are errors that will be ignored by flake8 41 | # check out their meaning here 42 | # https://flake8.pycqa.org/en/latest/user/error-codes.html 43 | - "--ignore=E203,E266,E501,W503,F403,F401,E402,C901" 44 | 45 | # isort - organize import correctly 46 | - repo: https://github.com/PyCQA/isort 47 | rev: 5.6.4 48 | hooks: 49 | - id: isort 50 | 51 | # gitlint - correct git commit format 52 | - repo: https://github.com/jorisroovers/gitlint 53 | rev: v0.15.0 54 | hooks: 55 | - id: gitlint 56 | 57 | -------------------------------------------------------------------------------- /unikube/cli/helper.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import errno 3 | import os 4 | import socket 5 | from typing import List 6 | 7 | 8 | def port_in_use(port: int): 9 | """ 10 | Checks whether a port is available on the local system. 11 | """ 12 | a_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 13 | 14 | location = ("127.0.0.1", port) 15 | result_of_check = a_socket.connect_ex(location) 16 | 17 | return result_of_check == 0 18 | 19 | 20 | def check_ports(port_list: List[int]): 21 | """ 22 | Takes a list of integers and check whether those ports are available. 23 | 24 | Returns a list of ports which are busy. 25 | """ 26 | return list(filter(port_in_use, port_list)) 27 | 28 | 29 | def exist_or_create(folder): 30 | if not os.path.exists(os.path.dirname(folder)): 31 | try: 32 | os.makedirs(os.path.dirname(folder)) 33 | except OSError as exc: 34 | if exc.errno != errno.EEXIST: 35 | raise 36 | 37 | 38 | def age_from_timestamp(timestamp): 39 | delta = datetime.timedelta(seconds=datetime.datetime.now(tz=datetime.timezone.utc).timestamp() - timestamp) 40 | if delta >= datetime.timedelta(days=1): 41 | result = f"{delta.days}d" 42 | elif delta >= datetime.timedelta(hours=1): 43 | result = f"{delta.seconds // 3600}h" 44 | elif delta >= datetime.timedelta(minutes=10): 45 | result = f"{delta.seconds // 60}m" 46 | 47 | elif delta >= datetime.timedelta(minutes=1): 48 | result = f"{delta.seconds // 60}m{delta.seconds % 60}s" 49 | else: 50 | result = f"{delta.seconds}s" 51 | return result 52 | -------------------------------------------------------------------------------- /tests/test_cli_deck.py: -------------------------------------------------------------------------------- 1 | from tests.login_testcase import LoginTestCase 2 | from unikube.cli import deck 3 | from unikube.commands import ClickContext 4 | 5 | 6 | class DeckTestCase(LoginTestCase): 7 | def test_deck_info(self): 8 | result = self.runner.invoke( 9 | deck.info, 10 | ["buzzword-counter"], 11 | obj=ClickContext(), 12 | ) 13 | 14 | self.assertIn("Key", result.output) 15 | self.assertIn("Value", result.output) 16 | self.assertIn("buzzword-counter", result.output) 17 | self.assertEqual(result.exit_code, 0) 18 | 19 | def test_info_not_existing_deck(self): 20 | 21 | result = self.runner.invoke( 22 | deck.info, 23 | ["not_existing_deck"], 24 | obj=ClickContext(), 25 | ) 26 | self.assertIn("[ERROR] Deck name/slug does not exist.\n", result.output) 27 | 28 | def test_deck_list(self): 29 | 30 | result = self.runner.invoke( 31 | deck.list, 32 | obj=ClickContext(), 33 | ) 34 | 35 | self.assertIn("project", result.output) 36 | self.assertIn("id", result.output) 37 | self.assertIn("title", result.output) 38 | self.assertIn("buzzword-counter", result.output) 39 | self.assertEqual(result.exit_code, 0) 40 | 41 | def test_deck_ingress(self): 42 | result = self.runner.invoke( 43 | deck.ingress, 44 | ["4634368f-1751-40ae-9cd7-738fcb656a0d"], 45 | obj=ClickContext(), 46 | ) 47 | 48 | self.assertIn( 49 | "[ERROR] The project cluster does not exist. Please be sure to run 'unikube project up' first.\n", 50 | result.output, 51 | ) 52 | self.assertEqual(result.exit_code, 1) 53 | -------------------------------------------------------------------------------- /tests/test_cli_helper.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from requests import HTTPError, Session 3 | 4 | from unikube.helpers import ( 5 | check_environment_type_local_or_exit, 6 | download_manifest, 7 | download_specs, 8 | environment_type_from_string, 9 | get_requests_session, 10 | ) 11 | 12 | 13 | def test_get_requests_session(): 14 | access_token = "" 15 | assert type(access_token) is str 16 | 17 | session = get_requests_session(access_token=access_token) 18 | assert type(session) is Session 19 | 20 | 21 | def test_download_specs(): 22 | environment_id = "WRONG" 23 | access_token = "" 24 | 25 | with pytest.raises(HTTPError) as pytest_wrapped_e: 26 | _ = download_specs(access_token=access_token, environment_id=environment_id) 27 | 28 | assert pytest_wrapped_e.type == HTTPError 29 | 30 | 31 | def test_download_manifest(): 32 | deck = { 33 | "environment": [ 34 | { 35 | "id": None, 36 | } 37 | ], 38 | "project": { 39 | "id": None, 40 | }, 41 | } 42 | 43 | class Authentication: 44 | def refresh(self): 45 | return {"success": True, "response": {"access_token": ""}} 46 | 47 | access_token = "" 48 | authentication = Authentication() 49 | 50 | with pytest.raises(SystemExit) as pytest_wrapped_e: 51 | _ = download_manifest(deck=deck, authentication=authentication, access_token=access_token) 52 | 53 | assert pytest_wrapped_e.type == SystemExit 54 | assert pytest_wrapped_e.value.code == 1 55 | 56 | 57 | def test_environment_type_from_string(): 58 | result = environment_type_from_string("") 59 | assert result is None 60 | 61 | 62 | def test_check_environment_type_local_or_exit(): 63 | with pytest.raises(SystemExit) as pytest_wrapped_e: 64 | deck = {"environment": {0: {"type": "REMOTE"}}} 65 | check_environment_type_local_or_exit(deck) 66 | assert pytest_wrapped_e.type == SystemExit 67 | assert pytest_wrapped_e.value.code == 1 68 | -------------------------------------------------------------------------------- /unikube/cli/console/project.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import unikube.cli.console as console 4 | from unikube.cli.console.input import get_identifier_or_pass 5 | from unikube.context.helper import convert_project_argument_to_uuid 6 | from unikube.graphql_utils import GraphQL 7 | 8 | 9 | def project_list( 10 | ctx, organization_id: str = None, filter: List[str] = None, excludes: List[str] = None 11 | ) -> Union[None, str]: 12 | # GraphQL 13 | try: 14 | graph_ql = GraphQL(authentication=ctx.auth) 15 | data = graph_ql.query( 16 | """ 17 | query($organization_id: UUID) { 18 | allProjects(organizationId: $organization_id) { 19 | results { 20 | title 21 | id 22 | organization { 23 | id 24 | title 25 | } 26 | } 27 | } 28 | } 29 | """, 30 | query_variables={ 31 | "organization_id": organization_id, 32 | }, 33 | ) 34 | project_list = data["allProjects"]["results"] 35 | except Exception as e: 36 | console.debug(e) 37 | console.exit_generic_error() 38 | 39 | selection = console.list( 40 | message="Please select a project", 41 | choices=[project["title"] for project in project_list], 42 | identifiers=[project["id"] for project in project_list], 43 | filter=filter, 44 | excludes=excludes, 45 | help_texts=[project["organization"]["title"] for project in project_list], 46 | message_no_choices="No projects available!", 47 | ) 48 | if selection is None: 49 | return None 50 | 51 | # get identifier if available 52 | project_argument = get_identifier_or_pass(selection) 53 | 54 | project_id = convert_project_argument_to_uuid( 55 | ctx.auth, argument_value=project_argument, organization_id=organization_id 56 | ) 57 | return project_id 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | base_dir = os.path.abspath(os.path.dirname(__file__)) 6 | with open(os.path.join(base_dir, "VERSION")) as f: 7 | VERSION = f.read() 8 | 9 | 10 | with open(os.path.join(base_dir, "README.md"), encoding="utf-8") as f: 11 | long_description = f.read() 12 | 13 | DESCRIPTION = "This is the unikube.io command line interface" 14 | 15 | 16 | setup( 17 | name="unikube", 18 | description=DESCRIPTION, 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | version=VERSION, 22 | py_modules=["unikube"], 23 | install_requires=[ 24 | "click==8.1.2", 25 | "click-spinner==0.1.10", 26 | "colorama~=0.4.4", 27 | "inquirerpy>=0.3.3,<0.4.0", 28 | "tabulate~=0.8.9", 29 | "pydantic>=1.7.3,<1.10.0", 30 | "pyyaml>=5.4", 31 | "tinydb>=3.15.2,<4.8.0", 32 | "requests>=2.25.1,<2.28.0", 33 | "pyjwt[crypto]>=2.0.1,<2.4.0", 34 | "gql>=3.2,<3.3", 35 | "semantic-version>=2.8.4,<2.10.0", 36 | "kubernetes>=11.0.0,<22.0.0", 37 | "retrying~=1.3.3", 38 | "oic==1.3.0", 39 | "python-slugify>=5.0.2,<6.2.0", 40 | "click-didyoumean~=0.3.0", 41 | "requests-toolbelt~=0.9.1", 42 | ], 43 | python_requires="~=3.7", 44 | packages=find_packages(), 45 | url="https://github.com/unikubehq/cli", 46 | project_urls={ 47 | "Source": "https://github.com/unikubehq/cli", 48 | "Documentation": "https://cli.unikube.io", 49 | "Bug Tracker": "https://github.com/unikubehq/cli/issues", 50 | }, 51 | author="Michael Schilonka", 52 | author_email="michael@blueshoe.de", 53 | include_package_data=True, 54 | classifiers=[ 55 | "Development Status :: 3 - Alpha", 56 | "Intended Audience :: Developers", 57 | "Topic :: Software Development :: Build Tools", 58 | "Programming Language :: Python :: 3.8", 59 | ], 60 | entry_points={"console_scripts": ["unikube=unikube.commands:cli"]}, 61 | ) 62 | -------------------------------------------------------------------------------- /unikube/cli/console/deck.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import unikube.cli.console as console 4 | from unikube.cli.console.input import get_identifier_or_pass 5 | from unikube.context.helper import convert_deck_argument_to_uuid 6 | from unikube.graphql_utils import GraphQL 7 | 8 | 9 | def deck_list(ctx, organization_id: str = None, project_id: str = None) -> Union[None, str]: 10 | # GraphQL 11 | try: 12 | graph_ql = GraphQL(authentication=ctx.auth) 13 | data = graph_ql.query( 14 | """ 15 | query($organization_id: UUID, $project_id: UUID) { 16 | allDecks(organizationId: $organization_id, projectId: $project_id) { 17 | results { 18 | title 19 | id 20 | project { 21 | id 22 | title 23 | organization { 24 | id 25 | } 26 | } 27 | } 28 | } 29 | } 30 | """, 31 | query_variables={ 32 | "organization_id": organization_id, 33 | "project_id": project_id, 34 | }, 35 | ) 36 | deck_list = data["allDecks"]["results"] 37 | except Exception as e: 38 | console.debug(e) 39 | console.exit_generic_error() 40 | 41 | selection = console.list( 42 | message="Please select a deck", 43 | choices=[deck["title"] for deck in deck_list], 44 | identifiers=[deck["id"] for deck in deck_list], 45 | help_texts=[deck["project"]["title"] for deck in deck_list], 46 | message_no_choices="No decks available!", 47 | ) 48 | if selection is None: 49 | return None 50 | 51 | # get identifier if available 52 | deck_argument = get_identifier_or_pass(selection) 53 | 54 | deck_id = convert_deck_argument_to_uuid( 55 | ctx.auth, argument_value=deck_argument, organization_id=organization_id, project_id=project_id 56 | ) 57 | return deck_id 58 | -------------------------------------------------------------------------------- /docs/_static/img/navigation_background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Path 4 + Path 4 Copy Mask 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /unikube/cli/console/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from unikube.cli import console 4 | from unikube.graphql_utils import GraphQL 5 | 6 | 7 | def organization_id_2_display_name(ctx, id: str = None) -> str: 8 | if not id: 9 | return "-" 10 | 11 | try: 12 | graph_ql = GraphQL(authentication=ctx.auth) 13 | data = graph_ql.query( 14 | """ 15 | query($id: UUID!) { 16 | organization(id: $id) { 17 | title 18 | } 19 | } 20 | """, 21 | query_variables={ 22 | "id": id, 23 | }, 24 | ) 25 | title = data["organization"]["title"] 26 | except Exception as e: 27 | console.debug(e) 28 | title = "-" 29 | 30 | return f"{title} ({id})" 31 | 32 | 33 | def project_id_2_display_name(ctx, id: str = None) -> Optional[str]: 34 | if not id: 35 | return "-" 36 | 37 | try: 38 | graph_ql = GraphQL(authentication=ctx.auth) 39 | data = graph_ql.query( 40 | """ 41 | query($id: UUID!) { 42 | project(id: $id) { 43 | title 44 | } 45 | } 46 | """, 47 | query_variables={ 48 | "id": id, 49 | }, 50 | ) 51 | title = data["project"]["title"] 52 | except Exception as e: 53 | console.debug(e) 54 | title = "-" 55 | 56 | return f"{title} ({id})" 57 | 58 | 59 | def deck_id_2_display_name(ctx, id: str = None) -> Optional[str]: 60 | if not id: 61 | return "-" 62 | 63 | try: 64 | graph_ql = GraphQL(authentication=ctx.auth) 65 | data = graph_ql.query( 66 | """ 67 | query($id: UUID!) { 68 | deck(id: $id) { 69 | title 70 | } 71 | } 72 | """, 73 | query_variables={ 74 | "id": id, 75 | }, 76 | ) 77 | title = data["deck"]["title"] 78 | except Exception as e: 79 | console.debug(e) 80 | title = "-" 81 | 82 | return f"{title} ({id})" 83 | -------------------------------------------------------------------------------- /unikube/unikubefile/selector.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Union 3 | 4 | import click 5 | import yaml 6 | 7 | from unikube.settings import UNIKUBE_FILE 8 | from unikube.unikubefile.unikube_file import ( 9 | UnikubeFile, 10 | UnikubeFileError, 11 | UnikubeFileNotFoundError, 12 | UnikubeFileVersionError, 13 | ) 14 | from unikube.unikubefile.unikube_file_1_0 import UnikubeFile_1_0 15 | 16 | 17 | class UnikubeFileSelector: 18 | def __init__(self, options: dict): 19 | self.options = options 20 | 21 | @staticmethod 22 | def __convert_apps_to_list(path_unikube_file: str, data: dict): 23 | apps_list = [] 24 | for name, item in data["apps"].items(): 25 | item["unikube_file"] = path_unikube_file 26 | item["name"] = name 27 | apps_list.append(item) 28 | data["apps"] = apps_list 29 | return data 30 | 31 | def get(self, path_unikube_file: str = None) -> Union[UnikubeFile, UnikubeFile_1_0, None]: 32 | # default file path 33 | if not path_unikube_file: 34 | path_unikube_file = os.path.join(os.getcwd(), UNIKUBE_FILE) 35 | 36 | # load unikube file + get version 37 | try: 38 | with click.open_file(path_unikube_file) as unikube_file: 39 | data = yaml.load(unikube_file, Loader=yaml.FullLoader) 40 | except FileNotFoundError: 41 | raise UnikubeFileNotFoundError 42 | 43 | # add & format data 44 | try: 45 | data = self.__convert_apps_to_list(path_unikube_file=path_unikube_file, data=data) 46 | except Exception: 47 | raise UnikubeFileError("Invalid unikube file.") 48 | 49 | # version 50 | version = str(data.get("version", "latest")) 51 | data["version"] = version 52 | 53 | # get class 54 | unikube_file_class = self.options.get(version) 55 | if not unikube_file_class: 56 | raise UnikubeFileVersionError 57 | 58 | return unikube_file_class(**data) 59 | 60 | 61 | unikube_file_selector = UnikubeFileSelector( 62 | options={ 63 | "latest": UnikubeFile_1_0, 64 | "1": UnikubeFile_1_0, 65 | "1.0": UnikubeFile_1_0, 66 | } 67 | ) 68 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yaml: -------------------------------------------------------------------------------- 1 | name: Test and build 2 | 3 | concurrency: python-tests 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | 16 | strategy: 17 | matrix: 18 | python-version: [ 3.8, 3.9 ] 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install flake8 pytest 29 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 unikube/ --count --exit-zero --max-complexity=18 --max-line-length=120 --statistics --ignore=E203,E266,E501,W503,F403,F401,E402 --select=B,C,E,F,W,T4,B9 34 | - name: Lint with black 35 | run: | 36 | pip install black 37 | black --check --line-length=120 unikube/ 38 | 39 | - name: Test with coverage 40 | run: | 41 | pip install coverage 42 | coverage run -m pytest 43 | env: 44 | TESTRUNNER_EMAIL: ${{ secrets.TESTRUNNER_EMAIL }} 45 | TESTRUNNER_SECRET: ${{ secrets.TESTRUNNER_SECRET }} 46 | 47 | - name: Combine coverage 48 | run: | 49 | coverage combine 50 | 51 | - name: Upload coverage data to coveralls.io 52 | # https://coveralls-python.readthedocs.io/en/latest/usage/configuration.html#configuration 53 | # need COVERALLS_REPO_TOKEN 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | COVERALLS_FLAG_NAME: ${{ matrix.python-version }} 57 | COVERALLS_PARALLEL: true 58 | run: | 59 | pip install coveralls 60 | coveralls --service=github 61 | 62 | coveralls: 63 | name: Indicate completion to coveralls.io 64 | # need secrets.GITHUB_TOKEN 65 | needs: test 66 | runs-on: ubuntu-latest 67 | container: python:3-slim 68 | steps: 69 | - name: Finished 70 | run: | 71 | pip3 install --upgrade coveralls 72 | coveralls --finish 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # If extensions (or modules to document with autodoc) are in another directory, 2 | # add these directories to sys.path here. If the directory is relative to the 3 | # documentation root, use os.path.abspath to make it absolute, like shown here. 4 | # 5 | import os 6 | import sys 7 | 8 | import sphinx_rtd_theme 9 | 10 | # Configuration file for the Sphinx documentation builder. 11 | # 12 | # This file only contains a selection of the most common options. For a full 13 | # list see the documentation: 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 15 | 16 | # -- Path setup -------------------------------------------------------------- 17 | 18 | 19 | sys.path.insert(0, os.path.abspath(".")) 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | project = "Unikube CLI" 26 | copyright = "2021, Blueshoe GmbH" 27 | author = "The Unikube authors" 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = ["sphinx.ext.autodoc", "sphinx_rtd_theme", "sphinx_click", "sphinx.ext.autosectionlabel"] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = [] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = "sphinx_rtd_theme" 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ["_static"] 57 | html_css_files = ["css/custom.css"] 58 | html_theme_options = {"logo_only": True, "collapse_navigation": False} 59 | html_logo = "unikube_logo.svg" 60 | html_favicon = "_static/favicon/favicon.ico" 61 | 62 | pygments_style = "sphinx" 63 | autosectionlabel_prefix_document = True 64 | -------------------------------------------------------------------------------- /unikube/cli/unikube.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | import unikube.cli.console as console 4 | from unikube.cli.context import show_context 5 | from unikube.graphql_utils import GraphQL 6 | from unikube.local.providers.helper import get_cluster_or_exit 7 | from unikube.local.system import Telepresence 8 | from unikube.storage.user import get_local_storage_user 9 | 10 | 11 | @click.command() 12 | @click.pass_obj 13 | def ps(ctx, **kwargs): 14 | """ 15 | Displays the current process state. 16 | """ 17 | 18 | # cluster 19 | cluster_list = ctx.cluster_manager.get_cluster_list(ready=True) 20 | cluster_id_list = [cluster.id for cluster in cluster_list] 21 | 22 | # GraphQL 23 | try: 24 | graph_ql = GraphQL(authentication=ctx.auth) 25 | data = graph_ql.query( 26 | """ 27 | query { 28 | allProjects { 29 | results { 30 | title 31 | id 32 | description 33 | } 34 | } 35 | } 36 | """, 37 | ) 38 | project_list = data["allProjects"]["results"] 39 | except Exception as e: 40 | console.debug(e) 41 | console.exit_generic_error() 42 | 43 | cluster_data = [] 44 | for project in project_list: 45 | if project["id"] in cluster_id_list: 46 | cluster_data.append(project) 47 | 48 | console.info("Project:") 49 | console.table( 50 | data={ 51 | "id": [cluster["id"] for cluster in cluster_data], 52 | "title": [cluster["title"] for cluster in cluster_data], 53 | "description": [cluster["description"] for cluster in cluster_data], 54 | }, 55 | headers=["cluster: id", "name", "description"], 56 | ) 57 | console.echo("") 58 | 59 | # switch 60 | intercept_count = 0 61 | if cluster_data: 62 | cluster = get_cluster_or_exit(ctx, cluster_data[0]["id"]) 63 | provider_data = cluster.storage.get() 64 | 65 | telepresence = Telepresence(provider_data) 66 | intercept_count = telepresence.intercept_count() 67 | 68 | if intercept_count == 0 or not intercept_count: 69 | console.info("No app switched!") 70 | else: 71 | console.info(f"Apps switched: #{intercept_count}") 72 | console.echo("") 73 | 74 | # context 75 | local_storage_user = get_local_storage_user() 76 | user_data = local_storage_user.get() 77 | show_context(ctx=ctx, context=user_data.context) 78 | -------------------------------------------------------------------------------- /unikube/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import urllib3 4 | from InquirerPy import get_style 5 | 6 | from unikube.cli.helper import exist_or_create 7 | from unikube.local.providers.types import K8sProviderType 8 | 9 | # disable warnings 10 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 11 | 12 | # cli 13 | CLI_CONFIG_FILE = os.path.expanduser("~/.unikube/config_dev") 14 | exist_or_create(CLI_CONFIG_FILE) 15 | 16 | CLI_KUBECONFIG_DIRECTORY = os.path.expanduser("~/.unikube/") 17 | CLI_TABLEFMT = "psql" 18 | 19 | CLI_LOG_LEVEL = "INFO" # DEBUG, INFO, WARNING, ERROR/SUCCESS 20 | CLI_ALWAYS_SHOW_CONTEXT = False 21 | 22 | # authentication 23 | AUTH_DEFAULT_HOST = "https://login.unikube.io" # "http://keycloak.127.0.0.1.nip.io:8085" 24 | 25 | # unikube 26 | UNIKUBE_FILE = "unikube.yaml" 27 | UNIKUBE_DEFAULT_PROVIDER_TYPE = K8sProviderType.k3d 28 | 29 | # token 30 | TOKEN_REALM = "unikube" 31 | TOKEN_ALGORITHM = "RS256" 32 | TOKEN_PUBLIC_KEY = f"/auth/realms/{TOKEN_REALM}" 33 | TOKEN_LOGIN_PATH = f"/auth/realms/{TOKEN_REALM}/protocol/openid-connect/token" 34 | TOKEN_VERIFY_PATH = f"/auth/realms/{TOKEN_REALM}/protocol/openid-connect/userinfo" 35 | TOKEN_REFRESH_PATH = f"/auth/realms/{TOKEN_REALM}/protocol/openid-connect/token" 36 | TOKEN_TIMEOUT = 30 37 | TOKEN_AUDIENCE = "gateway" 38 | TOKEN_RPT_AUDIENCE = "gateway" 39 | KC_CLIENT_ID = "cli" 40 | 41 | # GraphQL 42 | GRAPHQL_URL = "https://api.unikube.io/graphql/" # "http://gateway.unikube.127.0.0.1.nip.io:8085/graphql/" 43 | GRAPHQL_TIMEOUT = 30 44 | 45 | # manifest 46 | MANIFEST_DEFAULT_HOST = "https://api.unikube.io/manifests/" 47 | 48 | # local system: dependencies + versions + settings 49 | DOCKER_TEST_IMAGE = "busybox" 50 | DOCKER_CLI_MIN_VERSION = "15.0.1" 51 | DOCKER_WEBSITE = "https://docs.docker.com/install/" 52 | 53 | K3S_CLI_MIN_VERSION = "1.17.1" 54 | 55 | K3D_CLI_MIN_VERSION = "3.0.0" 56 | K3D_WEBSITE = "https://github.com/rancher/k3d" 57 | K3D_CLUSTER_PREFIX = "unikube-" 58 | K3D_DEFAULT_INGRESS_PORT = 80 59 | K3D_DEFAULT_WORKERS = 1 60 | 61 | TELEPRESENCE_CLI_MIN_VERSION = "2.3.2" 62 | TELEPRESENCE_TAG_PREFIX = "telepresence:dev" 63 | TELEPRESENCE_DOCKER_IMAGE_FORMAT = "{project}-{deck}-{name}-" + TELEPRESENCE_TAG_PREFIX 64 | 65 | HOMEBREW_CLI_MIN_VERSION = "3.2.0" 66 | HOMEBREW_WEBSITE = "https://brew.sh/" 67 | 68 | KUBECTL_MIN_CLI_VERSION = "1.18.0" 69 | 70 | # Kubernetes 71 | SERVICE_TOKEN_FILENAME = "/var/run/secrets/kubernetes.io/serviceaccount/token" 72 | SERVICE_CERT_FILENAME = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 73 | 74 | INQUIRER_STYLE = get_style( 75 | { 76 | "answermark": "#45d093 bold", 77 | "questionmark": "#fff", 78 | "question": "bold", 79 | }, 80 | style_override=False, 81 | ) 82 | -------------------------------------------------------------------------------- /unikube/graphql_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum 3 | from typing import Union 4 | 5 | import click_spinner 6 | from gql import Client, gql 7 | from gql.transport.exceptions import TransportServerError 8 | from gql.transport.requests import RequestsHTTPTransport 9 | from retrying import retry 10 | 11 | import unikube.cli.console as console 12 | from unikube import settings 13 | 14 | 15 | # EnvironmentType 16 | class EnvironmentType(Enum): 17 | LOCAL = "LOCAL" 18 | REMOTE = "REMOTE" 19 | 20 | 21 | class RetryException(Exception): 22 | pass 23 | 24 | 25 | # retry exception logic 26 | def retry_exception(exception): 27 | return isinstance(exception, RetryException) 28 | 29 | 30 | class GraphQL: 31 | def __init__( 32 | self, 33 | authentication, 34 | url=settings.GRAPHQL_URL, 35 | timeout=settings.GRAPHQL_TIMEOUT, 36 | ): 37 | self.url = url 38 | self.timeout = timeout 39 | 40 | # automatic token refresh 41 | self.authentication = authentication 42 | self.access_token = str(authentication.general_data.authentication.access_token) 43 | 44 | # client 45 | self.client = self._client() 46 | 47 | def _client(self): 48 | # header 49 | headers = { 50 | "Content-type": "application/json", 51 | "Authorization": "Bearer " + str(self.access_token), 52 | } 53 | 54 | # transport 55 | transport = RequestsHTTPTransport( 56 | url=self.url, 57 | use_json=True, 58 | headers=headers, 59 | verify=False, 60 | retries=3, 61 | timeout=self.timeout, 62 | ) 63 | 64 | # client 65 | client = Client(transport=transport) 66 | 67 | return client 68 | 69 | @retry(retry_on_exception=retry_exception, stop_max_attempt_number=2) 70 | def query( 71 | self, 72 | query: str, 73 | query_variables: dict = None, 74 | ) -> Union[dict, None]: 75 | try: 76 | query = gql(query) 77 | with click_spinner.spinner(beep=False, disable=False, force=False, stream=sys.stdout): 78 | data = self.client.execute( 79 | document=query, 80 | variable_values=query_variables, 81 | ) 82 | 83 | except TransportServerError: 84 | # refresh token 85 | response = self.authentication.refresh() 86 | if not response["success"]: 87 | console.exit_login_required() 88 | 89 | self.access_token = response["response"]["access_token"] 90 | self.client = self._client() 91 | 92 | raise RetryException("retry") 93 | 94 | return data 95 | -------------------------------------------------------------------------------- /tests/test_cli_auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from click.testing import CliRunner 6 | 7 | from unikube.cli import auth 8 | from unikube.commands import ClickContext 9 | 10 | 11 | def test_login_failed(): 12 | runner = CliRunner() 13 | result = runner.invoke( 14 | auth.login, 15 | ["--email", "test@test.de", "--password", "unsecure"], 16 | obj=ClickContext(), 17 | ) 18 | assert "[ERROR] Login failed. Please check email and password.\n" in result.output 19 | assert result.exit_code == 0 20 | 21 | 22 | def test_login_wrong_token(): 23 | def login(email, password): 24 | return {"success": True, "response": {"access_token": "WRONG_TOKEN"}} 25 | 26 | runner = CliRunner() 27 | obj = ClickContext() 28 | obj.auth.login = login 29 | result = runner.invoke( 30 | auth.login, 31 | ["--email", "test@test.de", "--password", "secure"], 32 | obj=obj, 33 | ) 34 | assert "[ERROR] Login failed. Your token does not match." in result.output 35 | assert result.exit_code == 0 36 | 37 | 38 | def test_logout(): 39 | runner = CliRunner() 40 | result = runner.invoke( 41 | auth.logout, 42 | obj=ClickContext(), 43 | ) 44 | assert result.output == "[INFO] Logout completed.\n" 45 | assert result.exit_code == 0 46 | 47 | 48 | def test_status_not_logged(): 49 | runner = CliRunner() 50 | result = runner.invoke( 51 | auth.status, 52 | obj=ClickContext(), 53 | ) 54 | assert result.output == "[INFO] Authentication could not be verified.\n" 55 | assert result.exit_code == 0 56 | 57 | 58 | def test_status_success(): 59 | def verify(): 60 | return {"success": True} 61 | 62 | runner = CliRunner() 63 | obj = ClickContext() 64 | obj.auth.verify = verify 65 | result = runner.invoke( 66 | auth.status, 67 | obj=obj, 68 | ) 69 | assert result.output == "[SUCCESS] Authentication verified.\n" 70 | assert result.exit_code == 0 71 | 72 | 73 | def test_login_logout_success(): 74 | runner = CliRunner() 75 | 76 | email = os.getenv("TESTRUNNER_EMAIL") 77 | secret = os.getenv("TESTRUNNER_SECRET") 78 | assert email is not None 79 | assert secret is not None 80 | 81 | result = runner.invoke( 82 | auth.login, 83 | ["--email", email, "--password", secret], 84 | obj=ClickContext(), 85 | ) 86 | assert "[SUCCESS] Login successful. Hello Testrunner!\n" in result.output 87 | assert result.exit_code == 0 88 | 89 | result = runner.invoke( 90 | auth.logout, 91 | obj=ClickContext(), 92 | ) 93 | assert result.output == "[INFO] Logout completed.\n" 94 | assert result.exit_code == 0 95 | -------------------------------------------------------------------------------- /tests/test_cli_init.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from tests.login_testcase import LoginTestCase 4 | from unikube.cli import init 5 | from unikube.context import ClickContext 6 | 7 | 8 | class InitTestCase(LoginTestCase): 9 | @patch("unikube.cli.init.get_env") 10 | @patch("unikube.cli.init.get_volume") 11 | @patch("unikube.cli.init.confirm") 12 | @patch("unikube.cli.init.get_command") 13 | @patch("unikube.cli.init.get_port") 14 | @patch("unikube.cli.init.get_deployment") 15 | @patch("unikube.cli.init.get_target") 16 | @patch("unikube.cli.init.get_context") 17 | @patch("unikube.cli.init.get_docker_file") 18 | @patch("unikube.cli.init.deck_list") 19 | @patch("unikube.cli.init.project_list") 20 | @patch("unikube.cli.init.organization_list") 21 | def test_init( 22 | self, 23 | organization_list, 24 | project_list, 25 | deck_list, 26 | get_docker_file, 27 | get_context, 28 | get_target, 29 | get_deployment, 30 | get_port, 31 | get_command, 32 | confirm, 33 | get_volume, 34 | get_env, 35 | ): 36 | organization_list.return_value = "ceba2255-3113-4a2c-af7a-7e0c9e73cd0c" 37 | project_list.return_value = "b464a6a7-7367-41d3-92a3-d3d98ed10cb5" 38 | deck_list.return_value = "4634368f-1751-40ae-9cd7-738fcb656a0d" 39 | get_docker_file.return_value = "Dockerfile" 40 | get_context.return_value = "." 41 | get_target.return_value = "" 42 | get_deployment.return_value = "project-service" 43 | get_port.return_value = "9000" 44 | get_command.return_value = "" 45 | get_volume.return_value = "" 46 | get_env.return_value = "" 47 | confirm.return_value = "y" 48 | 49 | result = self.runner.invoke( 50 | init.init, 51 | ["--stdout"], 52 | obj=ClickContext(), 53 | ) 54 | 55 | assert organization_list.called 56 | assert project_list.called 57 | assert deck_list.called 58 | assert get_docker_file.called 59 | assert get_context.called 60 | assert get_target.called 61 | assert get_deployment.called 62 | assert get_port.called 63 | assert get_command.called 64 | assert get_volume.called 65 | assert get_env.called 66 | 67 | self.assertIn(organization_list.return_value, result.output) 68 | self.assertIn(project_list.return_value, result.output) 69 | self.assertIn(deck_list.return_value, result.output) 70 | self.assertIn(get_docker_file.return_value, result.output) 71 | self.assertIn(get_deployment.return_value, result.output) 72 | self.assertIn(get_port.return_value, result.output) 73 | -------------------------------------------------------------------------------- /unikube/cli/orga.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | import unikube.cli.console as console 4 | from unikube.graphql_utils import GraphQL 5 | from unikube.keycloak.permissions import KeycloakPermissions 6 | 7 | 8 | @click.command() 9 | @click.pass_obj 10 | def list(ctx, **kwargs): 11 | """ 12 | List all your organizations. 13 | """ 14 | 15 | _ = ctx.auth.refresh() 16 | context = ctx.context.get() 17 | 18 | # keycloak 19 | try: 20 | keycloak_permissions = KeycloakPermissions(authentication=ctx.auth) 21 | permission_list = keycloak_permissions.get_permissions_by_scope("organization:*") 22 | except Exception as e: 23 | console.debug(e) 24 | console.exit_generic_error() 25 | 26 | # append "(active)" 27 | if context.organization_id: 28 | for permission in permission_list: 29 | if permission.rsid == context.organization_id: 30 | permission.rsid += " (active)" 31 | 32 | # console 33 | organization_list = [ 34 | { 35 | "id": permission.rsid, 36 | "name": permission.rsname.replace("organization ", ""), 37 | } 38 | for permission in permission_list 39 | ] 40 | console.table( 41 | data=organization_list, 42 | headers={ 43 | "id": "id", 44 | "name": "name", 45 | }, 46 | ) 47 | 48 | 49 | @click.command() 50 | @click.argument("organization", required=False) 51 | @click.pass_obj 52 | def info(ctx, organization, **kwargs): 53 | """ 54 | Display further information of the selected organization. 55 | """ 56 | 57 | _ = ctx.auth.refresh() 58 | 59 | # context 60 | organization_id, _, _ = ctx.context.get_context_ids_from_arguments(organization_argument=organization) 61 | 62 | # argument 63 | if not organization_id: 64 | organization_id = console.organization_list(ctx) 65 | if not organization_id: 66 | return None 67 | 68 | # GraphQL 69 | try: 70 | graph_ql = GraphQL(authentication=ctx.auth) 71 | data = graph_ql.query( 72 | """ 73 | query($id: UUID!) { 74 | organization(id: $id) { 75 | id 76 | title 77 | description 78 | } 79 | } 80 | """, 81 | query_variables={"id": organization_id}, 82 | ) 83 | organization_selected = data["organization"] 84 | except Exception as e: 85 | console.debug(e) 86 | console.exit_generic_error() 87 | 88 | # console 89 | if organization_selected: 90 | console.table( 91 | data={ 92 | "key": [k for k in organization_selected.keys()], 93 | "value": [v for v in organization_selected.values()], 94 | }, 95 | headers=["Key", "Value"], 96 | ) 97 | else: 98 | console.error("Organization does not exist.") 99 | -------------------------------------------------------------------------------- /unikube/local/providers/manager.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import unikube.cli.console as console 4 | from unikube import settings 5 | from unikube.local.providers.abstract_provider import AbstractK8sProvider 6 | from unikube.local.providers.factory import kubernetes_cluster_factory 7 | from unikube.local.providers.types import K8sProviderData, K8sProviderType 8 | from unikube.storage.local_storage import LocalStorage 9 | 10 | 11 | class K8sClusterManager(LocalStorage): 12 | table_name = "clusters" 13 | pydantic_class = K8sProviderData 14 | 15 | def get_all(self) -> List[K8sProviderData]: 16 | cluster_list = [] 17 | for item in self.database.table.all(): 18 | try: 19 | cluster_data = K8sProviderData(**item) 20 | cluster_list.append(cluster_data) 21 | except Exception: 22 | pass 23 | 24 | return cluster_list 25 | 26 | def get_cluster_list(self, ready: bool = None): 27 | ls = [] 28 | for cluster_data in self.get_all(): 29 | for provider_type in K8sProviderType: 30 | if self.exists(cluster_data, provider_type): 31 | # handle ready option 32 | if ready: 33 | kubernetes_cluster = self.select( 34 | cluster_data=cluster_data, 35 | cluster_provider_type=provider_type, 36 | ) 37 | if not kubernetes_cluster: 38 | continue 39 | 40 | if kubernetes_cluster.ready() != ready: 41 | continue 42 | 43 | # append cluster to list 44 | ls.append(cluster_data) 45 | return ls 46 | 47 | def exists( 48 | self, 49 | cluster_data: K8sProviderData, 50 | cluster_provider_type: K8sProviderType, 51 | ) -> bool: 52 | try: 53 | _ = self.select( 54 | cluster_data=cluster_data, 55 | cluster_provider_type=cluster_provider_type, 56 | ) 57 | return True 58 | except Exception: 59 | return False 60 | 61 | def select( 62 | self, 63 | cluster_data: K8sProviderData, 64 | cluster_provider_type: K8sProviderType = settings.UNIKUBE_DEFAULT_PROVIDER_TYPE, 65 | ) -> Union[AbstractK8sProvider, None]: 66 | # create config 67 | config = { 68 | "id": cluster_data.id, 69 | } 70 | 71 | if cluster_data.name: 72 | config["name"] = cluster_data.name 73 | 74 | # get selected kubernetes cluster from factory 75 | try: 76 | kubernetes_cluster = kubernetes_cluster_factory.get( 77 | cluster_provider_type, 78 | **config, 79 | ) 80 | return kubernetes_cluster 81 | except Exception as e: 82 | console.debug(e) 83 | return None 84 | -------------------------------------------------------------------------------- /unikube/completion/templates.py: -------------------------------------------------------------------------------- 1 | # Heavily inspired by the cleo project. Check it out. 2 | # https://github.com/sdispater/cleo 3 | # We use double curly braces to "escape" curly braces. 4 | # Templates are rendered using Python's built-in `format` method for strings. 5 | 6 | 7 | BASH_TEMPLATE = """{flag_complete_functions} 8 | {function}() 9 | {{ 10 | local cur script coms opts com 11 | COMPREPLY=() 12 | _get_comp_words_by_ref -n : cur prev words 13 | # for an alias, get the real script behind it 14 | if [[ $(type -t ${{words[0]}}) == "alias" ]]; then 15 | script=$(alias ${{words[0]}} | sed -E "s/alias ${{words[0]}}='(.*)'/\\1/") 16 | else 17 | script=${{words[0]}} 18 | fi 19 | # lookup for command 20 | for word in ${{words[@]:1}}; do 21 | if [[ $word != -* ]]; then 22 | com=$word 23 | break 24 | fi 25 | done 26 | 27 | # completing for an option 28 | if [[ ${{cur}} == --* ]] ; then 29 | opts="{opts}" 30 | case "$com" in 31 | {command_list} 32 | esac 33 | COMPREPLY=($(compgen -W "${{opts}}" -- ${{cur}})) 34 | __ltrim_colon_completions "$cur" 35 | return 0; 36 | fi 37 | 38 | if [[ $prev == $com ]]; then 39 | case "$com" in 40 | {subcommands} 41 | esac 42 | fi 43 | 44 | # completing for a command 45 | if [[ $cur == $com ]]; then 46 | coms="{coms}" 47 | COMPREPLY=($(compgen -W "${{coms}}" -- ${{cur}})) 48 | __ltrim_colon_completions "$cur" 49 | return 0 50 | fi 51 | }} 52 | {compdefs}""" 53 | 54 | ZSH_TEMPLATE = """#compdef %(script_name)s 55 | %(function)s() 56 | { 57 | local state com cur 58 | cur=${words[${#words[@]}]} 59 | # lookup for command 60 | for word in ${words[@]:1}; do 61 | if [[ $word != -* ]]; then 62 | com=$word 63 | break 64 | fi 65 | done 66 | if [[ ${cur} == --* ]]; then 67 | state="option" 68 | opts=(%(opts)s) 69 | elif [[ $cur == $com ]]; then 70 | state="command" 71 | coms=(%(coms)s) 72 | fi 73 | case $state in 74 | (command) 75 | _describe 'command' coms 76 | ;; 77 | (option) 78 | case "$com" in 79 | %(command_list)s 80 | esac 81 | _describe 'option' opts 82 | ;; 83 | *) 84 | # fallback to file completion 85 | _arguments '*:file:_files' 86 | esac 87 | } 88 | %(function)s "$@" 89 | %(compdefs)s""" 90 | 91 | FISH_TEMPLATE = """function __fish%(function)s_no_subcommand 92 | for i in (commandline -opc) 93 | if contains -- $i %(cmds_names)s 94 | return 1 95 | end 96 | end 97 | return 0 98 | end 99 | # global options 100 | %(opts)s 101 | # commands 102 | %(cmds)s 103 | # command options 104 | %(cmds_opts)s""" 105 | 106 | 107 | TEMPLATES = {"bash": BASH_TEMPLATE, "zsh": ZSH_TEMPLATE, "fish": FISH_TEMPLATE} 108 | -------------------------------------------------------------------------------- /unikube/local/providers/abstract_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | from semantic_version import Version 5 | 6 | from unikube.local.providers.types import K8sProviderData 7 | from unikube.local.system import Docker 8 | 9 | 10 | class IK8sProviderStorage(ABC): 11 | @abstractmethod 12 | def get(self) -> K8sProviderData: 13 | raise NotImplementedError 14 | 15 | @abstractmethod 16 | def set(self) -> None: 17 | raise NotImplementedError 18 | 19 | @abstractmethod 20 | def delete(self) -> None: 21 | raise NotImplementedError 22 | 23 | 24 | class AbstractK8SProviderStorage(IK8sProviderStorage): 25 | def __init__( 26 | self, 27 | id: str, 28 | storage=None, 29 | ) -> None: 30 | super().__init__() 31 | 32 | self.id = id 33 | self.storage = storage 34 | 35 | def get(self) -> K8sProviderData: 36 | return self.storage.get(self.id) 37 | 38 | def set(self, data) -> None: 39 | self.storage.set(self.id, data) 40 | 41 | def delete(self) -> None: 42 | self.storage.delete(self.id) 43 | 44 | 45 | class IK8sProvider(ABC): 46 | @abstractmethod 47 | def create(self, ingress_port: int = None) -> bool: 48 | raise NotImplementedError 49 | 50 | @abstractmethod 51 | def start(self) -> bool: 52 | raise NotImplementedError 53 | 54 | @abstractmethod 55 | def stop(self) -> bool: 56 | raise NotImplementedError 57 | 58 | @abstractmethod 59 | def delete(self) -> bool: 60 | raise NotImplementedError 61 | 62 | @abstractmethod 63 | def exists(self) -> bool: 64 | raise NotImplementedError 65 | 66 | @abstractmethod 67 | def ready(self) -> bool: 68 | raise NotImplementedError 69 | 70 | @abstractmethod 71 | def version(self) -> Version: 72 | """ 73 | Best return a type that allows working comparisons between versions of the same provider. 74 | E.g. (1, 10) > (1, 2), but "1.10" < "1.2" 75 | """ 76 | raise NotImplementedError 77 | 78 | 79 | class AbstractK8sProvider(IK8sProvider): 80 | provider_type = None 81 | 82 | def __init__( 83 | self, 84 | id: str, 85 | name: str = None, 86 | storage: AbstractK8SProviderStorage = None, 87 | ) -> None: 88 | self.id = id 89 | self.name = name 90 | self.storage = storage 91 | 92 | @property 93 | def display_name(self): 94 | name = self.name 95 | if name: 96 | return name 97 | 98 | id = self.id 99 | return id 100 | 101 | @property 102 | def k8s_provider_type(self): 103 | return self.provider_type 104 | 105 | def ready(self) -> bool: 106 | # get name 107 | provider_data = self.storage.get() 108 | name = provider_data.name 109 | if not name: 110 | return False 111 | 112 | return Docker().check_running(name) 113 | -------------------------------------------------------------------------------- /unikube/cli/console/logger.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import click 4 | 5 | from unikube import settings 6 | 7 | 8 | # log level 9 | class LogLevel(str, Enum): 10 | DEBUG = "debug" 11 | INFO = "info" 12 | WARNING = "warning" 13 | ERROR = "error" 14 | SUCCESS = "success" 15 | 16 | 17 | log_level_mapping = { 18 | LogLevel.DEBUG: [LogLevel.DEBUG], 19 | LogLevel.INFO: [LogLevel.DEBUG, LogLevel.INFO], 20 | LogLevel.WARNING: [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARNING], 21 | LogLevel.ERROR: None, # None -> print always 22 | LogLevel.SUCCESS: None, 23 | } 24 | 25 | # color 26 | color_mapping = { 27 | LogLevel.DEBUG: "cyan", 28 | LogLevel.INFO: "", 29 | LogLevel.WARNING: "yellow", 30 | LogLevel.ERROR: "red", 31 | LogLevel.SUCCESS: "green", 32 | } 33 | 34 | 35 | # helper 36 | def _click_secho( 37 | msg: str, silent: bool, log_level: str = None, _exit: bool = False, _exit_code: int = 1, color=None, **kwargs 38 | ): 39 | # get log level settings and console mapping 40 | setting_log_level = LogLevel(settings.CLI_LOG_LEVEL.lower()) 41 | console_log_level = log_level_mapping.get(log_level, None) 42 | 43 | # check log level 44 | if console_log_level is None: 45 | pass # log level independent 46 | 47 | else: 48 | if setting_log_level not in console_log_level: 49 | return None 50 | 51 | # silence message? 52 | if silent: 53 | return None 54 | 55 | # color 56 | if color: 57 | fg = color 58 | else: 59 | fg = color_mapping.get(log_level, "") 60 | 61 | # console echo 62 | if log_level: 63 | click.secho(f"[{log_level.value.upper()}] {msg}", fg=fg, **kwargs) 64 | else: 65 | click.secho(msg, fg=fg, **kwargs) 66 | 67 | # exit 68 | if _exit: 69 | exit(_exit_code) 70 | 71 | 72 | # console output 73 | def debug(msg: str, silent: bool = False, **kwargs): 74 | _click_secho(msg, silent, log_level=LogLevel.DEBUG, **kwargs) 75 | 76 | 77 | def echo(msg: str, silent: bool = False, _exit: bool = False, _exit_code: int = 1, **kwargs): 78 | _click_secho(msg=msg, silent=silent, log_level=None, _exit=_exit, _exit_code=_exit_code, **kwargs) 79 | 80 | 81 | def info(msg: str, silent: bool = False, _exit: bool = False, _exit_code: int = 1, **kwargs): 82 | _click_secho(msg=msg, silent=silent, log_level=LogLevel.INFO, _exit=_exit, _exit_code=_exit_code, **kwargs) 83 | 84 | 85 | def warning(msg: str, silent: bool = False, _exit: bool = False, _exit_code: int = 1, **kwargs): 86 | _click_secho(msg=msg, silent=silent, log_level=LogLevel.WARNING, _exit=_exit, _exit_code=_exit_code, **kwargs) 87 | 88 | 89 | def error(msg: str, _exit: bool = False, _exit_code: int = 1, **kwargs): 90 | _click_secho(msg=msg, silent=False, log_level=LogLevel.ERROR, _exit=_exit, _exit_code=_exit_code, **kwargs) 91 | 92 | 93 | def success(msg: str, silent: bool = False, **kwargs): 94 | _click_secho(msg=msg, silent=silent, log_level=LogLevel.SUCCESS, **kwargs) 95 | 96 | 97 | def link(msg: str, silent: bool = False, _exit: bool = False, _exit_code: int = 1, **kwargs): 98 | _click_secho(msg=msg, silent=silent, log_level=None, _exit=_exit, _exit_code=_exit_code, color="cyan", **kwargs) 99 | -------------------------------------------------------------------------------- /unikube/keycloak/permissions.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import uuid 3 | from functools import lru_cache 4 | from typing import KeysView, List, Optional, Union 5 | 6 | import jwt 7 | from pydantic import BaseModel 8 | from retrying import retry 9 | 10 | from unikube import settings 11 | from unikube.authentication.authentication import IAuthentication 12 | from unikube.authentication.types import AuthenticationData 13 | from unikube.cli import console 14 | 15 | 16 | class KeycloakPermissionData(BaseModel): 17 | scopes: Optional[List[str]] 18 | rsid: str 19 | rsname: str 20 | 21 | 22 | class KeycloakPermissions: 23 | def __init__(self, authentication: IAuthentication): 24 | self.authentication = authentication 25 | 26 | def _permission_data(self): 27 | # verify 28 | response = self.authentication.verify_or_refresh() 29 | if not response: 30 | console.exit_login_required() 31 | 32 | # get authentication_data 33 | authentication_data = self.authentication.general_data.authentication 34 | 35 | # check for requesting_party_token 36 | if not authentication_data.requesting_party_token: 37 | raise Exception("Requesting Party Token (RPT) required.") 38 | 39 | # decode requesting_party_token 40 | requesting_party_token = self._decode_requesting_party_token( 41 | requesting_party_token=authentication_data.access_token 42 | ) 43 | 44 | # convert 45 | permission_data = KeycloakPermissions._convert(requesting_party_token["authorization"]["permissions"]) 46 | 47 | return permission_data 48 | 49 | def _decode_requesting_party_token(self, requesting_party_token: str) -> dict: 50 | # decode 51 | try: 52 | token = jwt.decode( 53 | requesting_party_token, 54 | algorithms=["RS256"], 55 | audience=settings.TOKEN_AUDIENCE, 56 | options={"verify_signature": False}, 57 | ) 58 | except Exception as e: 59 | console.debug(e) 60 | raise Exception("Requesting Party Token (RPT) could not be decoded.") 61 | 62 | return token 63 | 64 | @staticmethod 65 | def _convert(permissions: dict) -> List[KeycloakPermissionData]: 66 | keycloak_permission_list = [] 67 | for permission_dict in permissions: 68 | keycloak_permission = KeycloakPermissionData(**permission_dict) 69 | keycloak_permission_list.append(keycloak_permission) 70 | 71 | return keycloak_permission_list 72 | 73 | @lru_cache(10) 74 | def get_permissions_by_scope(self, scope: str) -> List[KeycloakPermissionData]: 75 | """ 76 | Return a list of resources with the given scope. Supports to filter with wildcards 77 | e.g. organization:*. 78 | """ 79 | permission_data = self._permission_data() 80 | 81 | results = [] 82 | for permission in permission_data: 83 | if permission.scopes: 84 | # 'scopes': ['organization:view', 'organization:edit'] 85 | matched = fnmatch.filter(permission.scopes, scope) 86 | if matched: 87 | results.append(permission) 88 | 89 | return results 90 | -------------------------------------------------------------------------------- /tests/unikube_file/test_unikube_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from unikube.unikubefile.selector import unikube_file_selector 5 | from unikube.unikubefile.unikube_file import UnikubeFileError 6 | 7 | 8 | class SelectorTest(unittest.TestCase): 9 | def test_version_latest(self): 10 | path_unikube_file = "tests/unikube_file/unikube_version_latest.yaml" 11 | unikube_file = unikube_file_selector.get(path_unikube_file) 12 | self.assertEqual("latest", unikube_file.version) 13 | 14 | def test_version_1(self): 15 | path_unikube_file = "tests/unikube_file/unikube_version_1.yaml" 16 | unikube_file = unikube_file_selector.get(path_unikube_file) 17 | self.assertEqual("1", unikube_file.version) 18 | 19 | def test_apps_invalid(self): 20 | path_unikube_file = "tests/unikube_file/unikube_apps_invalid.yaml" 21 | with self.assertRaises(UnikubeFileError): 22 | _ = unikube_file_selector.get(path_unikube_file) 23 | 24 | 25 | class UnikubeFileTest(unittest.TestCase): 26 | def test_get_app_none(self): 27 | path_unikube_file = "tests/unikube_file/unikube.yaml" 28 | unikube_file = unikube_file_selector.get(path_unikube_file) 29 | 30 | unikube_file_app = unikube_file.get_app() 31 | self.assertEqual("your-app-01", unikube_file_app.name) 32 | 33 | def test_get_app_name(self): 34 | path_unikube_file = "tests/unikube_file/unikube.yaml" 35 | unikube_file = unikube_file_selector.get(path_unikube_file) 36 | 37 | unikube_file_app = unikube_file.get_app(name="your-app-01") 38 | self.assertEqual("your-app-01", unikube_file_app.name) 39 | 40 | def test_get_app_default(self): 41 | path_unikube_file = "tests/unikube_file/unikube_apps_default.yaml" 42 | unikube_file = unikube_file_selector.get(path_unikube_file) 43 | 44 | unikube_file_app = unikube_file.get_app() 45 | self.assertEqual("default", unikube_file_app.name) 46 | 47 | 48 | class UnikubeFileAppTest(unittest.TestCase): 49 | def setUp(self) -> None: 50 | path_unikube_file = "tests/unikube_file/unikube.yaml" 51 | unikube_file = unikube_file_selector.get(path_unikube_file) 52 | self.unikube_file_app = unikube_file.get_app() 53 | 54 | def test_get_docker_build(self): 55 | context, dockerfile, target = self.unikube_file_app.get_docker_build() 56 | self.assertEqual(os.path.abspath(os.path.join(os.getcwd(), "tests/unikube_file/.")), context) 57 | self.assertEqual("tests/unikube_file/Dockerfile", dockerfile) 58 | self.assertEqual("target", target) 59 | 60 | def test_get_command(self): 61 | command = self.unikube_file_app.get_command() 62 | self.assertEqual("bash".split(" "), command) 63 | 64 | def test_get_port(self): 65 | port = self.unikube_file_app.get_port() 66 | self.assertEqual(str(8000), port) 67 | 68 | def test_get_deployment(self): 69 | deployment = self.unikube_file_app.get_deployment() 70 | self.assertEqual("deployment", deployment) 71 | 72 | def test_get_mounts(self): 73 | volumes = self.unikube_file_app.get_mounts() 74 | self.assertEqual([(os.path.abspath(os.path.join(os.getcwd(), "tests/unikube_file/src")), "/app")], volumes) 75 | 76 | def test_get_environment(self): 77 | env = self.unikube_file_app.get_environment() 78 | self.assertEqual([("VARIABLE-01", "variable-01")], env) 79 | -------------------------------------------------------------------------------- /unikube/cli/utils.py: -------------------------------------------------------------------------------- 1 | # heavily inspired by yaspin (https://github.com/pavdmyt/yaspin/blob/master/yaspin/core.py) 2 | import sys 3 | from functools import wraps 4 | from itertools import cycle 5 | from threading import Event, Lock, Thread 6 | from typing import Callable, Dict 7 | 8 | 9 | class Spinner(object): 10 | def __init__(self, text=""): 11 | self.text = text 12 | self.start() 13 | 14 | def __enter__(self): 15 | return self 16 | 17 | def __exit__(self, type, value, traceback): 18 | if self.thread.is_alive(): 19 | self.stop() 20 | return False 21 | 22 | def start(self): 23 | self.thread = Thread(target=self._spin) 24 | self._stdout_lock = Lock() 25 | self.stop_event = Event() 26 | self.thread.start() 27 | 28 | def stop(self): 29 | if self.thread: 30 | self.stop_event.set() 31 | self.thread.join() 32 | sys.stdout.write("\r") 33 | self._clear_line() 34 | 35 | @staticmethod 36 | def _clear_line(): 37 | sys.stdout.write("\033[K") 38 | 39 | def message(self, message): 40 | with self._stdout_lock: 41 | sys.stdout.write("\r") 42 | self._clear_line() 43 | sys.stdout.write(f"{message}\n") 44 | 45 | def success(self, message): 46 | self.message(f"\033[92m✔\033[0m {message}") 47 | 48 | def info(self, message): 49 | self.message(f"\033[96mℹ\033[0m {message}") 50 | 51 | def error(self, message): 52 | self.message(f"\033[91m✘\033[0m {message}") 53 | 54 | def change_spinner_text(self, text): 55 | self.text = text 56 | 57 | def _spin(self): 58 | frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 59 | interval = 80 * 0.001 60 | _cycle = cycle(frames) 61 | while not self.stop_event.is_set(): 62 | char = next(_cycle) 63 | with self._stdout_lock: 64 | sys.stdout.write("\r") 65 | sys.stdout.write(f"{char} {self.text}") 66 | self._clear_line() 67 | sys.stdout.flush() 68 | self.stop_event.wait(interval) 69 | 70 | 71 | def spinner(spin_message: str, success_message: str, error_messages: Dict[Callable[..., Exception], str] = None): 72 | """ 73 | Any Exception type provided in `error_messages` will be handled with the according message being displayed 74 | as an error message. All other exceptions will bubble. 75 | 76 | Usage: 77 | 78 | @spinner("Doing xyz ...", "xyz done!", {ValueError: "Could not do xyz because of bad input"}) 79 | def xyz(*args, **kwargs): 80 | # do unspeakable things 81 | """ 82 | error_messages = error_messages or {} 83 | 84 | def decorator(fnc): 85 | @wraps(fnc) 86 | def wrapper(*args, **kwargs): 87 | with Spinner(spin_message) as spinner: 88 | try: 89 | rval = fnc(*args, **kwargs) 90 | except Exception as e: 91 | for exception_type in type(e).mro(): 92 | if exception_type in error_messages: 93 | spinner.error(error_messages[exception_type]) 94 | break 95 | else: 96 | raise e 97 | else: 98 | spinner.success(success_message) 99 | return rval 100 | 101 | return wrapper 102 | 103 | return decorator 104 | -------------------------------------------------------------------------------- /unikube/unikubefile/unikube_file_1_0.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from typing import List, Optional, Tuple 4 | 5 | from pydantic import BaseModel 6 | 7 | from unikube.context.types import ContextData 8 | from unikube.unikubefile.unikube_file import UnikubeFile 9 | 10 | 11 | class UnikubeFileContext(BaseModel): 12 | organization: Optional[str] = None 13 | project: Optional[str] = None 14 | deck: Optional[str] = None 15 | 16 | 17 | class UnikubeFileBuild(BaseModel): 18 | context: str 19 | dockerfile: Optional[str] = "Dockerfile" 20 | target: Optional[str] = None 21 | 22 | 23 | class UnikubeFileApp(BaseModel): 24 | unikube_file: str 25 | name: str 26 | build: UnikubeFileBuild 27 | deployment: str 28 | port: Optional[int] = None 29 | command: str 30 | volumes: Optional[List[str]] = None 31 | env: Optional[List[dict]] = None 32 | 33 | def get_docker_build(self) -> Tuple[str, str, str]: 34 | if self.build: 35 | base_path = os.path.dirname(self.unikube_file) 36 | path = os.path.abspath(os.path.join(base_path, self.build.context)) 37 | dockerfile = os.path.join(base_path, self.build.dockerfile) 38 | target = self.build.target 39 | return path, dockerfile, target 40 | else: 41 | return os.path.abspath("."), "Dockerfile", "" 42 | 43 | def get_command(self, **format) -> Optional[str]: 44 | if not self.command: 45 | return None 46 | 47 | command = self.command.format(**format) 48 | return command.split(" ") 49 | 50 | def get_port(self) -> Optional[str]: 51 | if self.port: 52 | return str(self.port) 53 | else: 54 | return None 55 | 56 | def get_deployment(self) -> Optional[str]: 57 | if self.deployment: 58 | return str(self.deployment) 59 | else: 60 | return None 61 | 62 | def get_mounts(self) -> List[Tuple[str, str]]: 63 | mounts = [] 64 | if self.volumes: 65 | base_path = os.path.dirname(self.unikube_file) 66 | for mount in self.volumes: 67 | mount = mount.split(":") 68 | source = os.path.abspath(os.path.join(base_path, mount[0])) 69 | target = mount[1] 70 | mounts.append((source, target)) 71 | return mounts 72 | 73 | def get_environment(self) -> List[Tuple[str, str]]: 74 | envs = [] 75 | if self.env: 76 | for env in self.env: 77 | envs.append(list(env.items())[0]) 78 | return envs 79 | 80 | 81 | class UnikubeFile_1_0(UnikubeFile, BaseModel): 82 | version: Optional[str] 83 | context: Optional[UnikubeFileContext] = None 84 | apps: List[UnikubeFileApp] 85 | 86 | def get_context(self) -> ContextData: 87 | if not self.context: 88 | return ContextData() 89 | 90 | return ContextData( 91 | organization_id=self.context.organization, 92 | project_id=self.context.project, 93 | deck_id=self.context.deck, 94 | ) 95 | 96 | def get_app(self, name: str = None) -> UnikubeFileApp: 97 | # default name 98 | if not name: 99 | name = "default" 100 | 101 | for app in self.apps: 102 | if app.name == name: 103 | return app 104 | 105 | if name != "default": 106 | raise ValueError("Invalid name.") 107 | 108 | return self.apps[0] 109 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); 2 | 3 | body { 4 | font-family: 'DM Sans', sans-serif; 5 | } 6 | a { 7 | color: #45D093; 8 | } 9 | a:visited, a:hover { 10 | color: #246b50; 11 | } 12 | .rst-content .toctree-wrapper>p.caption, h1, h2, h3, h4, h5, h6, legend { 13 | font-family: 'DM Sans', sans-serif; 14 | } 15 | .wy-nav-side { 16 | background-image: linear-gradient(18deg, #0e132e -13%, #252e65 64%); 17 | } 18 | .wy-nav-side:after { 19 | z-index: -1; 20 | background-image: url(../img/navigation_background.svg); 21 | position: absolute; 22 | bottom: 0; 23 | left: 0; 24 | width: 100%; 25 | height: 300px; 26 | 27 | content: ''; 28 | background-position: center; 29 | display: block; 30 | } 31 | .wy-side-nav-search { 32 | background-color: #252e65; 33 | text-align: left; 34 | } 35 | .wy-side-nav-search .wy-dropdown>a:hover, .wy-side-nav-search>a:hover { 36 | background-color: transparent; 37 | } 38 | .wy-menu-vertical a { 39 | color: #fff; 40 | padding: 1em 24px 1em 24px; 41 | font-weight: 200; 42 | } 43 | .wy-menu-vertical a:hover { 44 | opacity: 1; 45 | background: transparent linear-gradient(to right, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); 46 | } 47 | .wy-menu-vertical li.current { 48 | background-color: transparent; 49 | } 50 | .wy-menu-vertical li.current>a, 51 | .wy-menu-vertical li.current a:hover { 52 | background: transparent linear-gradient(to right, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)) !important; 53 | } 54 | .wy-menu-vertical li.toctree-l2.current>a, .wy-menu-vertical li.toctree-l2.current li.toctree-l3>a { 55 | background: transparent; 56 | } 57 | .wy-menu-vertical>ul>li.current { 58 | background: transparent linear-gradient(to right, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); 59 | color: #fff; 60 | } 61 | .wy-menu-vertical li.toctree-l2 a, .wy-menu-vertical li.toctree-l3 a, .wy-menu-vertical li.toctree-l4 a, .wy-menu-vertical li.toctree-l5 a, .wy-menu-vertical li.toctree-l6 a, .wy-menu-vertical li.toctree-l7 a, .wy-menu-vertical li.toctree-l8 a, .wy-menu-vertical li.toctree-l9 a, .wy-menu-vertical li.toctree-l10 a { 62 | color: #fff; 63 | } 64 | .wy-menu-vertical li.toctree-l1.current>a { 65 | opacity: 1; 66 | border-left: 4px solid #45D093; 67 | border-top: 0; 68 | border-bottom: 0; 69 | background-color: transparent; 70 | color: #fff; 71 | padding: 1em 24px 1em 20px; 72 | } 73 | .wy-menu-vertical li.toctree-l2.current>a, .wy-menu-vertical li.toctree-l2.current li.toctree-l3>a, 74 | .wy-menu-vertical li.toctree-l3.current>a, .wy-menu-vertical li.toctree-l3.current li.toctree-l4>a { 75 | background-color: transparent; 76 | color: #fff; 77 | } 78 | .wy-menu-vertical li span.toctree-expand, 79 | .wy-menu-vertical li.current>a:hover span.toctree-expand, .wy-menu-vertical li.on a:hover span.toctree-expand { 80 | color: #fff !important; 81 | } 82 | .wy-side-nav-search .wy-dropdown>a img.logo, .wy-side-nav-search>a img.logo { 83 | width: 160px; 84 | } 85 | html.writer-html4 .rst-content dl:not(.docutils)>dt, html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt { 86 | color: #36BE82; 87 | border-top: 3px solid #65E2AB; 88 | background: #E3FFF0; 89 | } 90 | .highlight { 91 | background: #E1E9FF; 92 | } 93 | .wy-nav-content { 94 | background-color: #FCFCFD; 95 | } 96 | 97 | .wy-nav-top { 98 | background: #252e65; 99 | } 100 | 101 | .wy-nav-top a { 102 | color: white; 103 | } 104 | @media screen and (min-width: 1100px) { 105 | .wy-nav-content { 106 | margin: 0; 107 | background-color: #FCFCFD; 108 | } 109 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | Build Status 6 | Quality Gate Status 7 | Coverage Status 8 | Code style: black 9 | Unikube Slack Community 10 |

11 | 12 | # The Unikube CLI 13 | 14 | This is the command line interface for [unikube][link_unikube]. 15 | 16 | ## Documentation 17 | 18 | The unikube [cli documentation][link_unikube_cli_documentation] is automatically built. 19 | 20 | ### Installation 21 | 22 | #### General 23 | 24 | The unikube cli can be installed via `pip`. Please make sure you are using Python 3. 25 | 26 | ```shell 27 | pip install unikube 28 | ``` 29 | 30 | #### MacOS 31 | 32 | The unikube cli is also installable via brew: 33 | 34 | ```shell 35 | brew tap unikubehq/tooling 36 | brew install unikubehq/tooling/unikube 37 | ``` 38 | 39 | ### Make Local 40 | 41 | ```bash 42 | cd docs 43 | make html 44 | ``` 45 | 46 | ## Development 47 | 48 | --- 49 | 50 | ### Setup 51 | 52 | Start the local unikube development cluster: 53 | 54 | ```bash 55 | k3d cluster start unikube 56 | ``` 57 | 58 | ### Install CLI 59 | 60 | To install the latest (pre-)release of the Unikube CLI type 61 | 62 | ```bash 63 | sudo pip3 install unikube== --upgrade --pre 64 | ``` 65 | 66 | #### Enable tab completion 67 | 68 | `unikube` support tab completion scripts for bash. 69 | 70 | ```shell 71 | unikube system completion bash > /etc/bash_completion.d/unikube.bash-completion 72 | ``` 73 | 74 | You probably need to restart your shell in order for the completion script to do its work. 75 | 76 | ### Virtual Environment + Requirements 77 | 78 | Create virtual environment: 79 | 80 | ```bash 81 | python -m .venv venv 82 | ``` 83 | 84 | Install requirements (production + development): 85 | 86 | ```bash 87 | pip3 install -r requirements.txt -r requirements.dev.txt 88 | ``` 89 | 90 | ### Version Build + Release 91 | 92 | Version management is handled via bump2version. 93 | 94 | `bump2version patch|minor|major` 95 | 96 | Increase _dev_ version (e.g.: 1.0.0-dev1 -> 1.0.0-dev2): 97 | 98 | `bump2version build` 99 | 100 | Create release (e.g.: 1.0.0-dev2 -> 1.0.0): 101 | 102 | `bump2version release` 103 | 104 | ## Test 105 | 106 | --- 107 | 108 | Tests for the unikube cli are developed using the `pytest` framework in combination with the _click.testing_ module. 109 | 110 | Thus, it is possible to run the tests using `pytest` or by configuring the testing environment/options within your IDE to use `pytest`. 111 | 112 | Currently, most tests are developed directly against the unikube API, using a test-account. Therefore, it is required to provide the credentials via the following environment variables: 113 | 114 | ``` 115 | TESTRUNNER_EMAIL=... 116 | TESTRUNNER_SECRET=... 117 | ``` 118 | 119 | Otherwise, tests might fail locally, even if they are correct. 120 | 121 | It is possible to set the environment variables using an `.env` file within your virtual environment or by providing them explicitly: 122 | 123 | ``` 124 | TESTRUNNER_EMAIL=... TESTRUNNER_SECRET=... pytest 125 | ``` 126 | 127 | [link_unikube]: https://unikube.io 128 | [link_unikube_cli_documentation]: https://cli.unikube.io 129 | -------------------------------------------------------------------------------- /unikube/cli/auth.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | 3 | import click 4 | from oic import rndstr 5 | from oic.oic import Client 6 | from oic.utils.authn.client import CLIENT_AUTHN_METHOD 7 | 8 | import unikube.cli.console as console 9 | from unikube import settings 10 | from unikube.helpers import compare_current_and_latest_versions 11 | 12 | 13 | @click.command() 14 | @click.option("--email", "-e", type=str, help="Authentication email") 15 | @click.option("--password", "-p", type=str, help="Authentication password") 16 | @click.pass_obj 17 | def login(ctx, email, password, **kwargs): 18 | """ 19 | Authenticate with a Unikube host. The default login process is a Browser-based method. 20 | If you want to login without being redirected to the Browser, you can just specify the parameter 21 | ``-e`` for email and enable the direct login method. For a non-interactive login, you can provide 22 | ``-p`` along with the password. 23 | """ 24 | compare_current_and_latest_versions() 25 | if email or password: 26 | if not email: 27 | email = click.prompt("email", type=str) 28 | if not password: 29 | password = getpass("password:") 30 | return password_flow(ctx, email, password) 31 | return web_flow(ctx) 32 | 33 | 34 | def password_flow(ctx, email, password): 35 | response = ctx.auth.login( 36 | email, 37 | password, 38 | ) 39 | if response["success"]: 40 | try: 41 | token = ctx.auth.token_from_response(response) 42 | except Exception as e: 43 | console.debug(e) 44 | console.debug(response) 45 | console.error("Login failed. Your token does not match.") 46 | return False 47 | 48 | if token["given_name"]: 49 | console.success(f'Login successful. Hello {token["given_name"]}!') 50 | else: 51 | console.success("Login successful.") 52 | else: 53 | console.error("Login failed. Please check email and password.") 54 | return True 55 | 56 | 57 | def web_flow(ctx): 58 | client = Client(client_authn_method=CLIENT_AUTHN_METHOD) 59 | issuer = f"{settings.AUTH_DEFAULT_HOST}/auth/realms/unikube" 60 | client.provider_config(issuer) 61 | 62 | state = rndstr() 63 | nonce = rndstr() 64 | 65 | # 1. run callback server 66 | from unikube.authentication.web import run_callback_server 67 | 68 | port = run_callback_server(state, nonce, client, ctx) 69 | 70 | # 2. send to login with redirect url. 71 | args = { 72 | "client_id": "cli", 73 | "response_type": ["token"], 74 | "response_mode": "form_post", 75 | "scope": ["openid"], 76 | "nonce": nonce, 77 | "state": state, 78 | "redirect_uri": f"http://localhost:{port}", 79 | } 80 | 81 | auth_req = client.construct_AuthorizationRequest(request_args=args) 82 | login_url = auth_req.request(client.authorization_endpoint) 83 | console.info("If your Browser does not open automatically, go to the following URL and login:") 84 | console.link(login_url) 85 | click.launch(login_url) 86 | return True 87 | 88 | 89 | @click.command() 90 | @click.pass_obj 91 | def logout(ctx, **kwargs): 92 | """ 93 | Log out of a Unikube host. 94 | """ 95 | 96 | ctx.auth.logout() 97 | console.info("Logout completed.") 98 | 99 | return True 100 | 101 | 102 | @click.command() 103 | @click.option("--token", "-t", is_flag=True, default=False, help="Show token information.") 104 | @click.pass_obj 105 | def status(ctx, token=False, **kwargs): 106 | """ 107 | View authentication status. 108 | """ 109 | 110 | response = ctx.auth.verify() 111 | 112 | # show token information 113 | if token: 114 | console.info(f"access token: {ctx.auth.general_data.authentication.access_token}") 115 | console.echo("---") 116 | console.info(f"refresh token: {ctx.auth.general_data.authentication.refresh_token}") 117 | console.echo("---") 118 | console.info(f"requesting party token: {ctx.auth.general_data.authentication.requesting_party_token}") 119 | console.echo("") 120 | 121 | if response["success"]: 122 | console.success("Authentication verified.") 123 | else: 124 | console.info("Authentication could not be verified.") 125 | 126 | return True 127 | -------------------------------------------------------------------------------- /unikube/cli/system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import click 5 | 6 | import unikube.cli.console as console 7 | from unikube.helpers import compare_current_and_latest_versions 8 | from unikube.local.dependency import install_dependency, probe_dependencies 9 | 10 | 11 | @click.command() 12 | @click.option("--reinstall", help="Reinstall the given dependencies comma-separated.") 13 | def install(reinstall): 14 | """ 15 | Install all required dependencies on your local machine. 16 | In order to reinstall dependencies use the ``--reinstall`` argument. You need to specify the name of the dependency 17 | with the ``--reinstall`` option, for example: ``--reinstall k3d,telepresence`` 18 | """ 19 | 20 | def _do_install(): 21 | incomplete = [] 22 | successful = [] 23 | unsuccessful = [] 24 | for dependency in dependencies: 25 | rcode = install_dependency(dependency["name"]) 26 | # since this run can contain multiple installations, capture all return codes 27 | if rcode is None: 28 | incomplete.append(dependency["name"]) 29 | elif rcode == 0: 30 | successful.append(dependency["name"]) 31 | elif rcode != 0: 32 | unsuccessful.append(dependency["name"]) 33 | if unsuccessful: 34 | console.error("Some of the requested installations terminated unsuccessful") 35 | elif successful and not unsuccessful and not incomplete: 36 | # this only become 0 if installation actually run and was successful 37 | console.success("All requested dependencies installed successfully") 38 | elif incomplete: 39 | console.warning("Not all dependencies could be installed") 40 | 41 | # check account permission 42 | if os.geteuid() != 0: 43 | console.warning( 44 | "You are not running the installation with an administrative account. " 45 | "You may be prompted for your password." 46 | ) 47 | 48 | # install 49 | if reinstall: 50 | dependencies = [{"name": i} for i in reinstall.split(",")] 51 | else: 52 | report_data = probe_dependencies(silent=True) 53 | dependencies = list(filter(lambda x: not x["success"], report_data)) 54 | if len(dependencies) == 1: 55 | console.info(f"The following dependency is going to be installed: {dependencies[0]['name']}") 56 | elif len(dependencies) > 1: 57 | console.info( 58 | f"The following dependencies are going to be " f"installed: {','.join(k['name'] for k in dependencies)}" 59 | ) 60 | else: 61 | console.info("All dependencies are already satisfied. No action taken.") 62 | sys.exit(0) 63 | 64 | _do_install() 65 | 66 | 67 | @click.command() 68 | @click.option( 69 | "--verbose", 70 | "-v", 71 | is_flag=True, 72 | default=False, 73 | help="Print a verbose table with state and " "actual version of a dependency.", 74 | ) 75 | def verify(verbose): 76 | """ 77 | Verifies the installation of dependencies on your local machine. If you need a verbose tabular output, please 78 | add the ``--verbose`` flag to the command. 79 | """ 80 | 81 | compare_current_and_latest_versions() 82 | 83 | report_data = probe_dependencies(silent=verbose) 84 | unsuccessful = list(filter(lambda x: not x["success"], report_data)) 85 | 86 | # show detailed table 87 | if verbose: 88 | successful = list(filter(lambda x: x["success"], report_data)) 89 | 90 | console.table( 91 | successful + unsuccessful, 92 | headers={ 93 | "name": "Name", 94 | "success": "Ok", 95 | "required_version": "Required Version", 96 | "installed_version": "Installed Version", 97 | "msg": "Message", 98 | }, 99 | ) 100 | 101 | if unsuccessful: 102 | console.error( 103 | f"There {'is' if len(unsuccessful) == 1 else 'are'} {len(unsuccessful)} (of {len(report_data)}) " 104 | f"unsuccessfully probed {'dependency' if len(unsuccessful) == 1 else 'dependencies'} on your " 105 | f"local machine. Please run 'unikube system install' in order to fix " 106 | f"these issues." 107 | ) 108 | return False 109 | 110 | console.success("Local dependencies verified.") 111 | 112 | return True 113 | -------------------------------------------------------------------------------- /unikube/_backup/utils_project.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Optional 3 | 4 | from tinydb import Query 5 | from utils.client import GQLQueryExecutor, get_requests_session 6 | 7 | from unikube import settings 8 | 9 | 10 | class ConfigManager: 11 | 12 | DB = None 13 | MISC = settings.MISC 14 | QUERY = Query() 15 | 16 | def set_active(self, _id, slug, **kwargs) -> dict: 17 | self.DB.update({"cli_active": False}) 18 | obj = self.DB.get(self.QUERY.id == _id) 19 | if obj: 20 | obj.update(kwargs) 21 | obj["cli_active"] = True 22 | self.DB.write_back([obj]) 23 | else: 24 | # object gets created for the very first time 25 | obj = {"id": _id, "name": slug, "cli_active": True} 26 | obj.update(kwargs) 27 | self.DB.insert(obj) 28 | return obj 29 | 30 | def update_active(self, obj): 31 | db_obj = self.DB.get(self.QUERY.id == obj["id"]) 32 | db_obj.update(obj) 33 | self.DB.write_back([db_obj]) 34 | 35 | def get_active(self) -> Optional[dict]: 36 | obj = self.DB.get(self.QUERY.cli_active == True) # noqa 37 | return obj 38 | 39 | def get_all(self) -> List[dict]: 40 | return self.DB.all() 41 | 42 | def delete(self, _id): 43 | self.DB.remove(self.QUERY.id == _id) 44 | 45 | 46 | class AppManager(ConfigManager): 47 | DB = settings.config.table("apps") 48 | 49 | def unset_app(self): 50 | self.DB.update({"cli_active": False}) 51 | 52 | 53 | class ProjectManager(ConfigManager): 54 | DB = settings.config.table("projects") 55 | APP_MGR = AppManager() 56 | 57 | def unset_project(self): 58 | self.DB.update({"cli_active": False}) 59 | self.APP_MGR.unset_app() 60 | 61 | 62 | class AllProjects(GQLQueryExecutor): 63 | query = """ 64 | { 65 | projects(organizationId: "") { 66 | id 67 | slug 68 | description 69 | } 70 | } 71 | """ 72 | key = "projects" 73 | 74 | 75 | class ProjectInfo(GQLQueryExecutor): 76 | query = """ 77 | { 78 | project(id: "$id") { 79 | id 80 | slug 81 | description 82 | organization { 83 | name 84 | } 85 | specRepository 86 | created 87 | } 88 | } 89 | """ 90 | key = "project" 91 | 92 | 93 | class ProjectApps(GQLQueryExecutor): 94 | query = """ 95 | { 96 | project(id: "$id") { 97 | applications { 98 | id 99 | slug 100 | description 101 | namespace 102 | environment(level:"local"){ 103 | specsUrl 104 | } 105 | } 106 | } 107 | } 108 | """ 109 | key = "project" 110 | 111 | def get_data(self, **kwargs): 112 | data = self._query(**kwargs)["applications"] 113 | if "filter" in kwargs: 114 | _filter = kwargs["filter"] 115 | result = [] 116 | if type(_filter) == list: 117 | for d in data: 118 | [d.pop(x, None) for x in _filter] 119 | result.append(d) 120 | return result 121 | else: 122 | for d in data: 123 | d.pop(_filter) 124 | result.append(d) 125 | return result 126 | return data 127 | 128 | 129 | class AppSpecs(GQLQueryExecutor): 130 | query = """ 131 | { 132 | applications(id: "$id") { 133 | namespace 134 | environment(level:"local"){ 135 | specsUrl 136 | } 137 | } 138 | } 139 | """ 140 | 141 | key = "applications" 142 | 143 | 144 | class Deployments(GQLQueryExecutor): 145 | query = """ 146 | { 147 | application(id: "$id") { 148 | namespace 149 | deployments(level: "local") { 150 | id 151 | slug 152 | description 153 | ports 154 | isSwitchable 155 | } 156 | } 157 | } 158 | """ 159 | 160 | key = "application" 161 | 162 | 163 | def download_specs(url): 164 | session = get_requests_session() 165 | r = session.get(settings.DEFAULT_UNIKUBE_GRAPHQL_HOST + url) 166 | if r.status_code == 200: 167 | return r.json() 168 | raise Exception(f"access to K8s specs failed (status {r.status_code})") 169 | -------------------------------------------------------------------------------- /unikube/authentication/web.py: -------------------------------------------------------------------------------- 1 | import os 2 | from http.server import BaseHTTPRequestHandler, HTTPServer 3 | from socket import AF_INET, SOCK_STREAM, gethostbyname, socket 4 | from threading import Thread 5 | from urllib.parse import parse_qs 6 | 7 | from oic.oic import AccessTokenResponse, AuthorizationResponse, Client 8 | 9 | from unikube.authentication.types import AuthenticationData 10 | from unikube.cli import console 11 | from unikube.context import ClickContext 12 | 13 | CALLBACK_PORT_RANGE = range(44444, 44448) 14 | 15 | 16 | def get_callback_port() -> int: 17 | t_IP = gethostbyname("localhost") 18 | for port in CALLBACK_PORT_RANGE: 19 | conn = (s := socket(AF_INET, SOCK_STREAM)).connect_ex((t_IP, port)) 20 | s.close() 21 | if conn: 22 | break 23 | else: 24 | raise Exception("No port in the range 44444-44447 is available.") 25 | return port 26 | 27 | 28 | def run_callback_server(state: str, nonce: str, client: Client, ctx: ClickContext) -> int: 29 | class CallbackHandler(BaseHTTPRequestHandler): 30 | """ 31 | This handles the redirect from the Keycloak after the web login. 32 | A simple http server is started when the user is sent to the keycloak 33 | web frontend to authenticate. 34 | """ 35 | 36 | def get_post_data(self) -> dict: 37 | post_body = self.rfile.read(int(self.headers.get("content-length", 0))) 38 | return {k.decode(): v[0].decode() for k, v in parse_qs(post_body).items()} 39 | 40 | def send_text_response(self, response_body): 41 | self.send_response(200) 42 | self.send_header("Content-Type", "text/html") 43 | self.send_header("Content-Length", str(len(response_body))) 44 | self.end_headers() 45 | self.wfile.write(response_body) 46 | 47 | def do_POST(self): 48 | POST = self.get_post_data() 49 | 50 | if POST["state"] != state: 51 | raise Exception(f"Invalid state: {POST['state']}") 52 | 53 | response = ctx.auth._get_requesting_party_token(POST["access_token"]) 54 | 55 | login_file = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "login.html")) 56 | text = login_file.read() 57 | login_file.close() 58 | 59 | # select response 60 | if not response["success"]: 61 | console.error("Login failed!") 62 | text = ( 63 | "Login failed! Could not retrieve requesting party token. " 64 | "Please try again or contact your System administrator" 65 | ) 66 | else: 67 | try: 68 | token = ctx.auth.token_from_response(response) 69 | except Exception as e: 70 | console.debug(e) 71 | console.debug(response) 72 | console.error("Login failed!") 73 | text = "Login failed! Your token does not match." 74 | else: 75 | ctx.auth.general_data.authentication = AuthenticationData( 76 | email=token["email"], 77 | access_token=response["response"]["access_token"], 78 | refresh_token=response["response"]["refresh_token"], 79 | requesting_party_token=True, 80 | ) 81 | ctx.auth.local_storage_general.set(ctx.auth.general_data) 82 | 83 | if given_name := token.get("given_name", ""): 84 | greeting = f"Hello {given_name}!" 85 | else: 86 | greeting = "Hello!" 87 | 88 | html_close = "close" 89 | 90 | text_html = ( 91 | f"You have successfully logged in. You can {html_close} this browser tab and return " 92 | f"to the shell." 93 | ) 94 | 95 | text_plain = ( 96 | "You have successfully logged in. You can close this browser tab and return to the shell." 97 | ) 98 | 99 | greeting_text = "{greeting} {text}".format(greeting=greeting, text=text_plain) 100 | 101 | greeting_html = "{greeting} {text}".format(greeting=greeting, text=text_html) 102 | 103 | text = text.replace("##text_placeholder##", greeting_html) 104 | text = text.replace("##headline##", "Login Successful") 105 | console.success(f"{greeting_text} You are now logged in!") 106 | response_body = text.encode("utf-8") 107 | self.send_text_response(response_body) 108 | Thread(target=server.shutdown).start() 109 | 110 | def log_request(self, *args, **kwargs): 111 | return 112 | 113 | port = get_callback_port() 114 | server = HTTPServer(("", port), CallbackHandler) 115 | Thread(target=server.serve_forever).start() 116 | return port 117 | -------------------------------------------------------------------------------- /unikube/context/helper.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from uuid import UUID 3 | 4 | from slugify import slugify 5 | 6 | from unikube.cli import console 7 | from unikube.graphql_utils import GraphQL 8 | 9 | 10 | class ArgumentError(Exception): 11 | pass 12 | 13 | 14 | # uuid validation 15 | def is_valid_uuid4(uuid: str): 16 | try: 17 | _ = UUID(uuid, version=4) 18 | return True 19 | except Exception: 20 | return False 21 | 22 | 23 | # context arguments 24 | def __select_result(argument_value: str, results: list, exception_message: str = "context"): 25 | # slugify 26 | if slugify(argument_value) != argument_value: 27 | title_list = [item["title"] for item in results] 28 | else: 29 | title_list = [slugify(item["title"]) for item in results] 30 | 31 | # check if name/title exists and is unique 32 | count = title_list.count(argument_value) 33 | if count == 0: 34 | raise ArgumentError(f"{exception_message.capitalize()} name/slug does not exist.") 35 | 36 | if count > 1: 37 | raise ArgumentError(f"{exception_message.capitalize()} name/slug is not unique.") 38 | 39 | # find index 40 | try: 41 | index = title_list.index(argument_value) 42 | except Exception: 43 | raise ArgumentError(f"Invalid {exception_message} name/slug.") 44 | 45 | # convert name/title to uuid 46 | return results[index]["id"] 47 | 48 | 49 | def convert_organization_argument_to_uuid(auth, argument_value: str) -> str: 50 | # uuid provided (no conversion required) 51 | if is_valid_uuid4(argument_value): 52 | return argument_value 53 | 54 | # get available context options or use provided data (e.g. from previous query) 55 | graph_ql = GraphQL(authentication=auth) 56 | data = graph_ql.query( 57 | """ 58 | query { 59 | allOrganizations { 60 | results { 61 | title 62 | id 63 | } 64 | } 65 | } 66 | """ 67 | ) 68 | 69 | results = data["allOrganizations"]["results"] 70 | return __select_result(argument_value, results, exception_message="organization") 71 | 72 | 73 | def convert_project_argument_to_uuid(auth, argument_value: str, organization_id: str = None) -> str: 74 | # uuid provided (no conversion required) 75 | if is_valid_uuid4(argument_value): 76 | return argument_value 77 | 78 | # get available context options or use provided data (e.g. from previous query) 79 | graph_ql = GraphQL(authentication=auth) 80 | data = graph_ql.query( 81 | """ 82 | query($organization_id: UUID) { 83 | allProjects(organizationId: $organization_id) { 84 | results { 85 | title 86 | id 87 | } 88 | } 89 | } 90 | """, 91 | query_variables={ 92 | "organization_id": organization_id, 93 | }, 94 | ) 95 | 96 | results = data["allProjects"]["results"] 97 | return __select_result(argument_value, results, exception_message="project") 98 | 99 | 100 | def convert_deck_argument_to_uuid( 101 | auth, argument_value: str, organization_id: str = None, project_id: str = None 102 | ) -> str: 103 | # uuid provided (no conversion required) 104 | if is_valid_uuid4(argument_value): 105 | return argument_value 106 | 107 | # get available context options or use provided data (e.g. from previous query) 108 | graph_ql = GraphQL(authentication=auth) 109 | data = graph_ql.query( 110 | """ 111 | query($organization_id: UUID, $project_id: UUID) { 112 | allDecks(organizationId: $organization_id, projectId: $project_id) { 113 | results { 114 | title 115 | id 116 | } 117 | } 118 | } 119 | """, 120 | query_variables={ 121 | "organization_id": organization_id, 122 | "project_id": project_id, 123 | }, 124 | ) 125 | 126 | results = data["allDecks"]["results"] 127 | return __select_result(argument_value, results, exception_message="deck") 128 | 129 | 130 | def convert_context_arguments( 131 | auth, organization_argument: str = None, project_argument: str = None, deck_argument: str = None 132 | ) -> Tuple[str, str, str]: 133 | try: 134 | # organization 135 | if organization_argument: 136 | organization_id = convert_organization_argument_to_uuid(auth, organization_argument) 137 | else: 138 | organization_id = None 139 | 140 | # project 141 | if project_argument: 142 | project_id = convert_project_argument_to_uuid(auth, project_argument, organization_id=organization_id) 143 | else: 144 | project_id = None 145 | 146 | # deck 147 | if deck_argument: 148 | deck_id = convert_deck_argument_to_uuid( 149 | auth, deck_argument, organization_id=organization_id, project_id=project_id 150 | ) 151 | else: 152 | deck_id = None 153 | except Exception as e: 154 | console.error(e, _exit=True) 155 | 156 | return organization_id, project_id, deck_id 157 | -------------------------------------------------------------------------------- /unikube/context/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC, abstractmethod 3 | from typing import List, Tuple, Union 4 | 5 | from unikube import settings 6 | from unikube.cli import console 7 | from unikube.context.helper import convert_context_arguments, is_valid_uuid4 8 | from unikube.context.types import ContextData 9 | from unikube.storage.user import LocalStorageUser, get_local_storage_user 10 | from unikube.unikubefile.selector import unikube_file_selector 11 | from unikube.unikubefile.unikube_file import UnikubeFile 12 | 13 | 14 | class ContextError(Exception): 15 | pass 16 | 17 | 18 | class IContext(ABC): 19 | @abstractmethod 20 | def get(self, **kwargs) -> ContextData: 21 | raise NotImplementedError 22 | 23 | 24 | class ClickOptionContext(IContext): 25 | def __init__(self, click_options): 26 | self.click_options = click_options 27 | 28 | def get(self, **kwargs) -> ContextData: 29 | def _get_and_validate_argument_id(argument_name: str): 30 | argument = self.click_options.get(argument_name, None) 31 | if argument: 32 | if is_valid_uuid4(argument): 33 | argument_id = argument 34 | else: 35 | raise ContextError(f"Invalid {argument_name} id.") 36 | else: 37 | argument_id = None 38 | 39 | return argument_id 40 | 41 | # arguments 42 | organization_id = _get_and_validate_argument_id("organization") 43 | project_id = _get_and_validate_argument_id("project") 44 | deck_id = _get_and_validate_argument_id("deck") 45 | 46 | return ContextData( 47 | organization_id=organization_id, 48 | project_id=project_id, 49 | deck_id=deck_id, 50 | ) 51 | 52 | 53 | class UnikubeFileContext(IContext): 54 | def __init__(self, path_unikube_file: Union[UnikubeFile, None]): 55 | self.path_unikube_file = path_unikube_file 56 | 57 | def get(self, **kwargs) -> ContextData: 58 | # get unikube file 59 | unikube_file = unikube_file_selector.get(path_unikube_file=self.path_unikube_file) 60 | 61 | # check if unikube file was loaded 62 | if not unikube_file: 63 | return ContextData() 64 | 65 | return unikube_file.get_context() 66 | 67 | 68 | class LocalContext(IContext): 69 | def __init__(self, local_storage_user: Union[LocalStorageUser, None]): 70 | self.local_storage_user = local_storage_user 71 | 72 | def get(self, **kwargs) -> ContextData: 73 | if not self.local_storage_user: 74 | return ContextData() 75 | 76 | user_data = self.local_storage_user.get() 77 | return user_data.context 78 | 79 | 80 | class ContextLogic: 81 | def __init__(self, context_order: List[IContext]): 82 | self.context_order = context_order 83 | 84 | def get(self) -> ContextData: 85 | context = ContextData() 86 | 87 | for context_object in self.context_order: 88 | # get context variables from current context 89 | try: 90 | context_current = context_object.get(current_context=context) 91 | except Exception as e: 92 | console.debug(e) 93 | context_current = ContextData() 94 | 95 | # update context 96 | context_dict = context.dict() 97 | for key, value in context_current.dict().items(): 98 | if context_dict[key] is None: 99 | context_dict[key] = value 100 | context = ContextData(**context_dict) 101 | 102 | # check if all context variables have already been set 103 | if None not in context.dict().values(): 104 | break 105 | 106 | return context 107 | 108 | 109 | class Context: 110 | def __init__(self, auth): 111 | self._auth = auth 112 | 113 | def get(self, **kwargs) -> ContextData: 114 | local_storage_user = get_local_storage_user() 115 | 116 | context_logic = ContextLogic( 117 | [ 118 | ClickOptionContext( 119 | click_options={key: kwargs[key] for key in ("organization", "project", "deck") if key in kwargs} 120 | ), 121 | UnikubeFileContext(path_unikube_file="unikube.yaml"), 122 | LocalContext(local_storage_user=local_storage_user), 123 | ] 124 | ) 125 | context = context_logic.get() 126 | 127 | # show context 128 | if settings.CLI_ALWAYS_SHOW_CONTEXT: 129 | from unikube.cli.context import show_context 130 | 131 | show_context(ctx=None, context=context) 132 | 133 | return context 134 | 135 | def get_context_ids_from_arguments( 136 | self, organization_argument: str = None, project_argument: str = None, deck_argument: str = None 137 | ) -> Tuple[str, str, str]: 138 | # convert context argments into ids 139 | organization_id, project_id, deck_id = convert_context_arguments( 140 | auth=self._auth, 141 | organization_argument=organization_argument, 142 | project_argument=project_argument, 143 | deck_argument=deck_argument, 144 | ) 145 | 146 | # consider context 147 | context = self.get(organization=organization_id, project=project_id, deck=deck_id) 148 | return context.organization_id, context.project_id, context.deck_id 149 | -------------------------------------------------------------------------------- /unikube/cli/console/input.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Callable, List, Union 3 | 4 | from InquirerPy import inquirer 5 | from InquirerPy.utils import InquirerPyValidate 6 | 7 | import unikube.cli.console as console 8 | from unikube.settings import INQUIRER_STYLE 9 | 10 | 11 | def get_identifier_or_pass(selection: str) -> str: 12 | # get identifier if available 13 | # example: "PROJECT_NAME (IDENTIFIER)" 14 | 15 | identifier_search = re.search("(?<=\\()[^)]*(?=\\))", selection) 16 | try: 17 | project_argument = identifier_search.group(0) 18 | except Exception: 19 | project_argument = selection 20 | 21 | return project_argument 22 | 23 | 24 | def resolve_duplicates( 25 | choices: List[str], 26 | identifiers: List[str], 27 | help_texts: Union[List[str], None] = None, 28 | help_texts_always: bool = False, 29 | ) -> List[str]: 30 | # detect duplicates 31 | duplicates_mask = [True if choices.count(choice) > 1 else False for choice in choices] 32 | 33 | # add identifiers to duplicates 34 | choices_resolved = [] 35 | for choice, identifier, duplicate in zip(choices, identifiers, duplicates_mask): 36 | if duplicate: 37 | choices_resolved.append(f"{choice} ({identifier})") 38 | else: 39 | choices_resolved.append(choice) 40 | 41 | # help texts 42 | def add_help_text(choice, help_text): 43 | return f"{choice} - {help_text}" 44 | 45 | choices_resolved_with_help_text = [] 46 | if help_texts: 47 | for choice, help_text, duplicate in zip(choices_resolved, help_texts, duplicates_mask): 48 | # add help_text always 49 | if help_texts_always and help_text: 50 | choices_resolved_with_help_text.append(add_help_text(choice, help_text)) 51 | continue 52 | 53 | # add help_text for duplicates only 54 | if duplicate and help_text: 55 | choices_resolved_with_help_text.append(add_help_text(choice, help_text)) 56 | else: 57 | choices_resolved_with_help_text.append(choice) 58 | 59 | choices_resolved = choices_resolved_with_help_text 60 | 61 | return choices_resolved 62 | 63 | 64 | def filter_by_identifiers(choices: List[str], identifiers: List[str], filter: Union[List[str], None]) -> List[str]: 65 | if filter is None: 66 | return choices 67 | 68 | choices_filtered = [] 69 | for choice, identifier in zip(choices, identifiers): 70 | if any(f in choice for f in filter) or identifier in filter: 71 | choices_filtered.append(choice) 72 | return choices_filtered 73 | 74 | 75 | def exclude_by_identifiers(choices: List[str], identifiers: List[str], excludes: Union[List[str], None]) -> List[str]: 76 | if not excludes: 77 | return choices 78 | 79 | choices_excluded = [] 80 | for choice, identifier in zip(choices, identifiers): 81 | if any(exclude in choice for exclude in excludes) or identifier in excludes: 82 | continue 83 | choices_excluded.append(choice) 84 | return choices_excluded 85 | 86 | 87 | # input 88 | def list( 89 | message: str, 90 | choices: List[str], 91 | identifiers: Union[List[str], None] = None, 92 | filter: Union[List[str], None] = None, 93 | excludes: Union[List[str], None] = None, 94 | help_texts: Union[List[str], None] = None, 95 | allow_duplicates: bool = False, 96 | message_no_choices: str = "No choices available!", 97 | multiselect: bool = False, 98 | transformer: Callable[[Any], str] = None, 99 | ) -> Union[None, List[str]]: 100 | # choices exist 101 | if not len(choices) > 0: 102 | console.info(message_no_choices) 103 | return None 104 | 105 | # handle duplicates 106 | if not allow_duplicates: 107 | if identifiers: 108 | choices_duplicates = resolve_duplicates(choices=choices, identifiers=identifiers, help_texts=help_texts) 109 | else: 110 | choices_duplicates = set(choices) 111 | else: 112 | choices_duplicates = choices 113 | 114 | # filter 115 | choices_filtered = filter_by_identifiers(choices=choices_duplicates, identifiers=identifiers, filter=filter) 116 | 117 | # exclude 118 | choices_excluded = exclude_by_identifiers(choices=choices_filtered, identifiers=identifiers, excludes=excludes) 119 | 120 | # prompt 121 | answer = inquirer.fuzzy( 122 | message=message, 123 | choices=choices_excluded, 124 | multiselect=multiselect, 125 | transformer=transformer, 126 | keybindings={"toggle": [{"key": "space"}]}, 127 | style=INQUIRER_STYLE, 128 | amark="✔", 129 | ).execute() 130 | if not answer: 131 | return None 132 | 133 | return answer 134 | 135 | 136 | def input( 137 | text: str, 138 | default: str = "", 139 | mandatory: bool = False, 140 | validate: InquirerPyValidate = None, 141 | invalid_message: str = "", 142 | ): 143 | kwargs = {} 144 | if mandatory: 145 | kwargs.update( 146 | { 147 | "validate": lambda result: len(result) > 0, 148 | "invalid_message": "Input cannot be empty.", 149 | } 150 | ) 151 | if validate and invalid_message: 152 | kwargs.update({"validate": validate, "invalid_message": invalid_message}) 153 | result = inquirer.text( 154 | text, default=default, style=INQUIRER_STYLE, mandatory=mandatory, amark="✔", **kwargs 155 | ).execute() 156 | return result 157 | 158 | 159 | def confirm( 160 | question: str = "Do want to continue? [N/y]: ", 161 | values: List[str] = ["y", "Y", "yes", "Yes"], 162 | ) -> bool: 163 | # confirm action by user input 164 | 165 | confirm = input(question) 166 | if confirm not in values: 167 | return False 168 | return True 169 | -------------------------------------------------------------------------------- /tests/console/test_input.py: -------------------------------------------------------------------------------- 1 | from unikube.cli.console.input import ( 2 | exclude_by_identifiers, 3 | filter_by_identifiers, 4 | get_identifier_or_pass, 5 | resolve_duplicates, 6 | ) 7 | 8 | CHOICE_01 = "choice (1)" 9 | CHOICE_02 = "choice (2)" 10 | 11 | 12 | class TestGetIdentifierOrPass: 13 | def test_with_identifier(self): 14 | selection = "NAME (IDENTIFIER)" 15 | selection = get_identifier_or_pass(selection=selection) 16 | assert selection == "IDENTIFIER" 17 | 18 | def test_without_identifier(self): 19 | selection = "NAME" 20 | selection = get_identifier_or_pass(selection=selection) 21 | assert selection == "NAME" 22 | 23 | 24 | class TestResolveDuplicates: 25 | def test_with_duplicates(self): 26 | choices = ["choice", "choice"] 27 | identifiers = ["1", "2"] 28 | 29 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 30 | assert choices_resolved == [CHOICE_01, CHOICE_02] 31 | 32 | def test_without_duplicates(self): 33 | choices = ["01", "02"] 34 | identifiers = ["1", "2"] 35 | 36 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 37 | assert choices_resolved == ["01", "02"] 38 | 39 | def test_with_duplicates_and_help_text(self): 40 | choices = ["choice", "choice"] 41 | identifiers = ["1", "2"] 42 | help_texts = ["help", "help"] 43 | 44 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers, help_texts=help_texts) 45 | assert choices_resolved == ["choice (1) - help", "choice (2) - help"] 46 | 47 | def test_without_duplicates_and_help_text(self): 48 | choices = ["01", "02"] 49 | identifiers = ["1", "2"] 50 | help_texts = ["help", "help"] 51 | 52 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers, help_texts=help_texts) 53 | assert choices_resolved == ["01", "02"] 54 | 55 | def test_without_duplicates_and_help_text_always(self): 56 | choices = ["01", "02"] 57 | identifiers = ["1", "2"] 58 | help_texts = ["help", "help"] 59 | 60 | choices_resolved = resolve_duplicates( 61 | choices=choices, identifiers=identifiers, help_texts=help_texts, help_texts_always=True 62 | ) 63 | assert choices_resolved == ["01 - help", "02 - help"] 64 | 65 | 66 | class TestFilterByIdentifiers: 67 | def test_filter_none(self): 68 | choices = ["choice", "choice"] 69 | identifiers = ["1", "2"] 70 | filter_ = None 71 | 72 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 73 | choices_filtered = filter_by_identifiers(choices=choices_resolved, identifiers=identifiers, filter=filter_) 74 | assert choices_filtered == [CHOICE_01, CHOICE_02] 75 | 76 | def test_filter_empty_list(self): 77 | choices = ["choice", "choice"] 78 | identifiers = ["1", "2"] 79 | filter_ = [] 80 | 81 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 82 | choices_filtered = filter_by_identifiers(choices=choices_resolved, identifiers=identifiers, filter=filter_) 83 | assert choices_filtered == [] 84 | 85 | def test_filter_existing_01(self): 86 | choices = ["choice", "choice"] 87 | identifiers = ["1", "2"] 88 | filter_ = ["1"] 89 | 90 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 91 | choices_filtered = filter_by_identifiers(choices=choices_resolved, identifiers=identifiers, filter=filter_) 92 | assert choices_filtered == [CHOICE_01] 93 | 94 | def test_filter_existing_02(self): 95 | choices = ["different", "choice"] 96 | identifiers = ["1", "2"] 97 | filter_ = ["2"] 98 | 99 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 100 | choices_filtered = filter_by_identifiers(choices=choices_resolved, identifiers=identifiers, filter=filter_) 101 | assert choices_filtered == ["choice"] 102 | 103 | def test_filter_non_existing(self): 104 | choices = ["choice", "choice"] 105 | identifiers = ["1", "2"] 106 | filter_ = ["3"] 107 | 108 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 109 | choices_filtered = filter_by_identifiers(choices=choices_resolved, identifiers=identifiers, filter=filter_) 110 | assert choices_filtered == [] 111 | 112 | 113 | class TestExcludeByIdentifiers: 114 | def test_excludes_none(self): 115 | choices = ["choice", "choice"] 116 | identifiers = ["1", "2"] 117 | excludes = None 118 | 119 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 120 | choices_excluded = exclude_by_identifiers(choices=choices_resolved, identifiers=identifiers, excludes=excludes) 121 | assert choices_excluded == [CHOICE_01, CHOICE_02] 122 | 123 | def test_excludes_existing(self): 124 | choices = ["choice", "choice"] 125 | identifiers = ["1", "2"] 126 | excludes = ["1"] 127 | 128 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 129 | choices_excluded = exclude_by_identifiers(choices=choices_resolved, identifiers=identifiers, excludes=excludes) 130 | assert choices_excluded == [CHOICE_02] 131 | 132 | def test_excludes_non_existing(self): 133 | choices = ["choice", "choice"] 134 | identifiers = ["1", "2"] 135 | excludes = ["3"] 136 | 137 | choices_resolved = resolve_duplicates(choices=choices, identifiers=identifiers) 138 | choices_excluded = exclude_by_identifiers(choices=choices_resolved, identifiers=identifiers, excludes=excludes) 139 | assert choices_excluded == [CHOICE_01, CHOICE_02] 140 | -------------------------------------------------------------------------------- /unikube/completion/completion.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from typing import List 3 | 4 | import click 5 | 6 | from unikube.cli import console 7 | 8 | from .templates import TEMPLATES 9 | 10 | 11 | def align_spacing(string: str, spaces: int) -> str: 12 | """ 13 | Prefix multiline string with given number of spaces. 14 | 15 | Just a helper to make the scripts more readable. 16 | """ 17 | return textwrap.indent(textwrap.dedent(string), " " * spaces) 18 | 19 | 20 | def get_subcommands(command: dict) -> dict: 21 | """Extract subcommands from Click's information dictionary retrieved via `to_info_dict`.""" 22 | if "commands" in command: 23 | commands = command["commands"] 24 | return {key: get_options(command["commands"][key]) for key in commands.keys()} 25 | return {} 26 | 27 | 28 | def get_options(command: dict) -> List[str]: 29 | """Extract options from Click's information dictionary retrieved via `to_info_dict`.""" 30 | params = command["params"] 31 | result = [] 32 | for param in params: 33 | result.extend(filter(lambda x: "--" in x, param["opts"])) 34 | return result 35 | 36 | 37 | def render_subcommand_completion(current_command: str, commands: List): 38 | """Renders case statement for subcommand completion.""" 39 | return """ 40 | ({current_command}) 41 | coms="{commands}" 42 | COMPREPLY=($(compgen -W "${{coms}}" -- ${{cur}})) 43 | __ltrim_colon_completions "$cur" 44 | return 0; 45 | ;; 46 | """.format( 47 | current_command=current_command, commands=" ".join(commands) 48 | ) 49 | 50 | 51 | def render_options_case(command: str, func_name: str): 52 | """Renders case statement (function call) for options completion.""" 53 | return align_spacing( 54 | """ 55 | ({command}) 56 | {func_name} 57 | ;;""".format( 58 | command=command, func_name=func_name 59 | ), 60 | 12, 61 | ) 62 | 63 | 64 | def render_command_options(command: str, options: List[str]) -> str: 65 | """Generate bash option string for a command.""" 66 | return """ 67 | ({command}) 68 | opts="${{opts}} {options}" 69 | ;;""".format( 70 | command=command, options=" ".join(options) 71 | ) 72 | 73 | 74 | def render_flag_completion_func(command_dict: dict, name: str) -> (str, str): 75 | """Renders function for flag completion of command and its subcommands.""" 76 | subcommands = get_subcommands(command_dict) 77 | options = get_options(command_dict) 78 | 79 | func_name = "__unikube_complete_flags_{name}".format(name=name) 80 | 81 | if subcommands: 82 | subcommand_cases = align_spacing( 83 | "\n".join([render_command_options(c[0], c[1]) for c in subcommands.items()]), 16 84 | ) 85 | 86 | return func_name, align_spacing( 87 | """ 88 | {func_name}() {{ 89 | if [[ $com == $prev ]]; then 90 | opts="${{opts}} {options}" 91 | else 92 | case "$prev" in 93 | {subcommand_cases} 94 | esac 95 | fi 96 | }} 97 | """.format( 98 | func_name=func_name, options=" ".join(options), subcommand_cases=subcommand_cases 99 | ), 100 | 0, 101 | ) 102 | return func_name, align_spacing( 103 | """ 104 | {func_name}() {{ 105 | opts="${{opts}} {options}" 106 | }} 107 | """.format( 108 | func_name=func_name, 109 | options=" ".join(options), 110 | ), 111 | 0, 112 | ) 113 | 114 | 115 | def render_bash(cli): 116 | """Renders bash completion script and prints it.""" 117 | with click.Context(cli) as ctx: 118 | info = ctx.to_info_dict() 119 | 120 | template = TEMPLATES["bash"] 121 | 122 | # static information for rendering 123 | # could be dynamic in the future (e.g. for aliases) 124 | function = "_unikube_complete" 125 | aliases = ["unikube"] 126 | compdefs = "\n".join(["complete -o default -F {} {}".format(function, alias) for alias in aliases]) 127 | 128 | # Based on click's info dict retrieve information about commands and flags. 129 | # These are then used to render certain parts of the completion. 130 | commands = info["command"]["commands"].keys() 131 | command_list = [] 132 | subcommands = [] 133 | functions = [] 134 | for command in commands: 135 | subs = get_subcommands(info["command"]["commands"][command]) 136 | name, func = render_flag_completion_func(info["command"]["commands"][command], command) 137 | desc = [] 138 | if name and func: 139 | functions.append(func) 140 | desc = [render_options_case(command, name)] 141 | 142 | if len(subs.keys()): 143 | subcommands.append(render_subcommand_completion(command, list(subs.keys()))) 144 | 145 | if len(desc): 146 | command_list.append("\n".join(desc)) 147 | 148 | # Render template with all retrieved information. 149 | output = template.format( 150 | function=function, 151 | flag_complete_functions="\n".join(functions), 152 | opts=" ".join(sorted([])), 153 | coms=" ".join(commands), 154 | command_list="\n".join(command_list), 155 | compdefs=compdefs, 156 | subcommands="\n".join(subcommands), 157 | ) 158 | 159 | console.echo(output) 160 | 161 | 162 | def render_completion_script(cli, shell: str): 163 | """Renders a completion for a given shell.""" 164 | SUPPORTED_SHELLS = ["bash"] 165 | 166 | if shell not in SUPPORTED_SHELLS: 167 | console.error( 168 | "{} is not supported. Following shells are supported: {}.".format(shell, ", ".join(SUPPORTED_SHELLS)), 169 | _exit=True, 170 | ) 171 | 172 | if shell == "bash": 173 | render_bash(cli) 174 | -------------------------------------------------------------------------------- /unikube/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from pathlib import Path 4 | from urllib.parse import urljoin 5 | 6 | import click_spinner 7 | import pkg_resources 8 | import requests 9 | from requests import HTTPError, Session 10 | 11 | import unikube.cli.console as console 12 | from unikube import settings 13 | from unikube.authentication.authentication import TokenAuthentication 14 | from unikube.context import ClickContext 15 | from unikube.graphql_utils import EnvironmentType 16 | from unikube.local.providers.types import K8sProviderType 17 | from unikube.local.system import Telepresence 18 | 19 | 20 | def get_requests_session(access_token) -> Session: 21 | session = requests.Session() 22 | session.headers.update({"Content-type": "application/json", "Authorization": "Bearer " + str(access_token)}) 23 | return session 24 | 25 | 26 | def download_specs(access_token: str, environment_id: str): 27 | session = get_requests_session(access_token=access_token) 28 | 29 | manifest_url = urljoin(settings.MANIFEST_DEFAULT_HOST, environment_id) 30 | with click_spinner.spinner(beep=False, disable=False, force=False, stream=sys.stdout): 31 | response = session.get(manifest_url) 32 | response.raise_for_status() 33 | 34 | manifest = response.json() 35 | return manifest 36 | 37 | 38 | def download_manifest(deck: dict, authentication: TokenAuthentication, access_token: str, environment_index: int = 0): 39 | try: 40 | environment_id = deck["environment"][environment_index]["id"] 41 | console.info("Requesting manifests. This process may take a few seconds.") 42 | manifest = download_specs( 43 | access_token=access_token, 44 | environment_id=environment_id, 45 | ) 46 | except HTTPError as e: 47 | project_id = deck["project"]["id"] 48 | if e.response.status_code == 404: 49 | console.warning( 50 | "This deck does potentially not specify a valid Environment of type 'local'. " 51 | f"Please go to https://app.unikube.io/project/{project_id}/decks " 52 | f"and save a valid values path." 53 | ) 54 | exit(1) 55 | elif e.response.status_code == 403: 56 | console.warning("Refreshing access token") 57 | environment_id = deck["environment"][environment_index]["id"] 58 | response = authentication.refresh() 59 | if not response["success"]: 60 | console.exit_login_required() 61 | 62 | access_token = response["response"]["access_token"] 63 | try: 64 | manifest = download_specs( 65 | access_token=access_token, 66 | environment_id=environment_id, 67 | ) 68 | except HTTPError as e: 69 | console.warning(f"Even after refreshing access token download specs fails with {e}") 70 | exit(1) 71 | else: 72 | console.error("Could not load manifest: " + str(e), _exit=True) 73 | 74 | return manifest 75 | 76 | 77 | # environment 78 | def environment_type_from_string(environment_type: str): 79 | try: 80 | environment_type = EnvironmentType(environment_type) 81 | except Exception as e: 82 | console.debug(e) 83 | environment_type = None 84 | 85 | return environment_type 86 | 87 | 88 | def check_environment_type_local_or_exit(deck: dict, environment_index: int = 0): 89 | if ( 90 | environment_type_from_string(environment_type=deck["environment"][environment_index]["type"]) 91 | != EnvironmentType.LOCAL 92 | ): 93 | console.error("This deck cannot be installed locally.", _exit=True) 94 | 95 | 96 | def check_running_cluster(ctx: ClickContext, cluster_provider_type: K8sProviderType.k3d, project_instance: dict): 97 | for cluster_data in ctx.cluster_manager.get_all(): 98 | cluster = ctx.cluster_manager.select(cluster_data=cluster_data, cluster_provider_type=cluster_provider_type) 99 | if cluster.exists() and cluster.ready(): 100 | if cluster.name == project_instance["title"] and cluster.id == project_instance["id"]: 101 | Telepresence(cluster.storage.get()).start() 102 | console.info(f"Kubernetes cluster for '{cluster.display_name}' is already running.", _exit=True) 103 | else: 104 | console.error( 105 | f"You cannot start multiple projects at the same time. Project {cluster.name} ({cluster.id}) is " 106 | f"currently running. Please run 'unikube project down {cluster.id}' first and " 107 | f"try again.", 108 | _exit=True, 109 | ) 110 | 111 | 112 | def compare_current_and_latest_versions(): 113 | try: 114 | current_version = None 115 | try: 116 | path = Path(__file__).parent / "../VERSION" 117 | with path.open("r") as f: 118 | current_version = f.read() 119 | except (FileNotFoundError, PermissionError): 120 | console.debug("Could not read current version.") 121 | 122 | if not current_version: 123 | dist = pkg_resources.working_set.by_key.get("unikube") 124 | if dist: 125 | current_version = dist.version 126 | 127 | release = requests.get("https://api.github.com/repos/unikubehq/cli/releases/latest") 128 | if release.status_code == 403: 129 | console.info("Versions cannot be compared, as API rate limit was exceeded") 130 | return None 131 | latest_release_version = release.json()["tag_name"].replace("-", ".") 132 | if current_version != latest_release_version: 133 | console.info( 134 | f"You are using unikube version {current_version}; however, version {latest_release_version} is " 135 | f"available." 136 | ) 137 | 138 | return current_version 139 | except pkg_resources.DistributionNotFound as e: 140 | console.warning(f"Version of the package could not be found: {e}") 141 | except Exception: 142 | import traceback 143 | 144 | console.info(f"Versions cannot be compared, because of error {traceback.format_exc()}") 145 | 146 | 147 | def compare_decorator(f): 148 | compare_current_and_latest_versions() 149 | -------------------------------------------------------------------------------- /unikube/cli/context.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | import unikube.cli.console as console 4 | from unikube.cli.console.helpers import ( 5 | deck_id_2_display_name, 6 | organization_id_2_display_name, 7 | project_id_2_display_name, 8 | ) 9 | from unikube.context.helper import convert_context_arguments 10 | from unikube.graphql_utils import GraphQL 11 | from unikube.storage.user import get_local_storage_user 12 | 13 | 14 | def show_context(ctx, context): 15 | organization = organization_id_2_display_name(ctx=ctx, id=context.organization_id) 16 | project = project_id_2_display_name(ctx=ctx, id=context.project_id) 17 | deck = deck_id_2_display_name(ctx=ctx, id=context.deck_id) 18 | 19 | console.info("Context:") 20 | console.echo(f"- organization: {organization}") 21 | console.echo(f"- project: {project}") 22 | console.echo(f"- deck: {deck}") 23 | console.echo("") 24 | 25 | 26 | @click.command() 27 | @click.option("--organization", "-o", help="Select an organization") 28 | @click.option("--project", "-p", help="Select a project") 29 | @click.option("--deck", "-d", help="Select a deck") 30 | @click.pass_obj 31 | def set(ctx, organization=None, project=None, deck=None, **kwargs): 32 | """ 33 | Set the local context. 34 | """ 35 | 36 | organization_id, project_id, deck_id = convert_context_arguments( 37 | auth=ctx.auth, organization_argument=organization, project_argument=project, deck_argument=deck 38 | ) 39 | 40 | if not (organization or project or deck): 41 | organization_id = console.organization_list(ctx=ctx) 42 | project_id = console.project_list(ctx=ctx, organization_id=organization_id) 43 | deck_id = console.deck_list(ctx=ctx, organization_id=organization_id, project_id=project_id) 44 | console.echo("") 45 | 46 | # user_data / context 47 | local_storage_user = get_local_storage_user() 48 | user_data = local_storage_user.get() 49 | 50 | if organization_id: 51 | # set organization 52 | user_data.context.deck_id = None 53 | user_data.context.project_id = None 54 | user_data.context.organization_id = organization_id 55 | local_storage_user.set(user_data) 56 | 57 | if project_id: 58 | if not organization_id: 59 | try: 60 | graph_ql = GraphQL(authentication=ctx.auth) 61 | data = graph_ql.query( 62 | """ 63 | query($id: UUID) { 64 | project(id: $id) { 65 | organization { 66 | id 67 | } 68 | } 69 | } 70 | """, 71 | query_variables={ 72 | "id": project_id, 73 | }, 74 | ) 75 | organization_id = data["project"]["organization"]["id"] 76 | except Exception as e: 77 | console.debug(e) 78 | console.exit_generic_error() 79 | 80 | # set project 81 | user_data.context.deck_id = None 82 | user_data.context.project_id = project_id 83 | user_data.context.organization_id = organization_id 84 | local_storage_user.set(user_data) 85 | 86 | if deck_id: 87 | if not organization_id or not project_id: 88 | try: 89 | graph_ql = GraphQL(authentication=ctx.auth) 90 | data = graph_ql.query( 91 | """ 92 | query($id: UUID) { 93 | deck(id: $id) { 94 | project { 95 | id 96 | organization { 97 | id 98 | } 99 | } 100 | } 101 | } 102 | """, 103 | query_variables={ 104 | "id": deck_id, 105 | }, 106 | ) 107 | organization_id = data["deck"]["project"]["organization"]["id"] 108 | project_id = data["deck"]["project"]["id"] 109 | except Exception as e: 110 | console.debug(e) 111 | console.exit_generic_error() 112 | 113 | # set deck 114 | user_data.context.deck_id = deck_id 115 | user_data.context.project_id = project_id 116 | user_data.context.organization_id = organization_id 117 | local_storage_user.set(user_data) 118 | 119 | show_context(ctx=ctx, context=user_data.context) 120 | 121 | 122 | @click.command() 123 | @click.option("--organization", "-o", is_flag=True, default=False, help="Remove organization context") 124 | @click.option("--project", "-p", is_flag=True, default=False, help="Remove project context") 125 | @click.option("--deck", "-d", is_flag=True, default=False, help="Remove deck context") 126 | @click.pass_obj 127 | def remove(ctx, organization=None, project=None, deck=None, **kwargs): 128 | """ 129 | Remove the local context. 130 | """ 131 | 132 | # user_data / context 133 | local_storage_user = get_local_storage_user() 134 | user_data = local_storage_user.get() 135 | 136 | if organization: 137 | user_data.context.deck_id = None 138 | user_data.context.project_id = None 139 | user_data.context.organization_id = None 140 | local_storage_user.set(user_data) 141 | console.success("Organization context removed.", _exit=True) 142 | 143 | if project: 144 | user_data.context.deck_id = None 145 | user_data.context.project_id = None 146 | local_storage_user.set(user_data) 147 | console.success("Project context removed.", _exit=True) 148 | 149 | if deck: 150 | user_data.context.deck_id = None 151 | local_storage_user.set(user_data) 152 | console.success("Deck context removed.", _exit=True) 153 | 154 | # remove complete context 155 | user_data.context.deck_id = None 156 | user_data.context.project_id = None 157 | user_data.context.organization_id = None 158 | local_storage_user.set(user_data) 159 | console.success("Context removed.", _exit=True) 160 | 161 | 162 | @click.command() 163 | @click.pass_obj 164 | def show(ctx, **kwargs): 165 | """ 166 | Show the context. 167 | """ 168 | 169 | # user_data / context 170 | local_storage_user = get_local_storage_user() 171 | user_data = local_storage_user.get() 172 | 173 | show_context(ctx=ctx, context=user_data.context) 174 | -------------------------------------------------------------------------------- /unikube/commands.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | from click_didyoumean import DYMGroup 5 | 6 | import unikube.cli.console as console 7 | from unikube.cli import app as app_cmd 8 | from unikube.cli import auth as auth_cmd 9 | from unikube.cli import context as context_cmd 10 | from unikube.cli import deck as deck_cmd 11 | from unikube.cli import init as init_cmd 12 | from unikube.cli import orga as orga_cmd 13 | from unikube.cli import project as project_cmd 14 | from unikube.cli import system as system_cmd 15 | from unikube.cli import unikube as unikube_cmd 16 | from unikube.completion.completion import render_completion_script 17 | from unikube.context import ClickContext 18 | from unikube.helpers import compare_current_and_latest_versions 19 | 20 | version = sys.version_info 21 | if version.major == 2: 22 | console.error("Python 2 is not supported for Unikube. Please upgrade python.", _exit=True) 23 | 24 | 25 | @click.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 26 | @click.pass_context 27 | def cli(ctx, **kwargs): 28 | """ 29 | The Unikube CLI provides several command groups to manage all required aspects to develop cloud native 30 | software on a Kubernetes-based environment. 31 | 32 | There are a couple of shortcut commands directly available from here. 33 | """ 34 | ctx.obj = ClickContext() 35 | 36 | 37 | # click ----- 38 | @click.command() 39 | def version(): 40 | """ 41 | Check unikube version. 42 | """ 43 | version = compare_current_and_latest_versions() 44 | if version is None: 45 | console.error("Could not determine version.") 46 | 47 | console.info(f"unikube, version {version}") 48 | 49 | 50 | cli.add_command(version) 51 | cli.add_command(unikube_cmd.ps) 52 | 53 | 54 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 55 | @click.pass_obj 56 | def system(ctx): 57 | """ 58 | The ``system`` command group includes commands to manage system dependencies on your local machine. 59 | Using :ref:`reference/system:install` and :ref:`reference/system:verify` you can install all necessary 60 | dependencies for Unikube and verify their versions. 61 | """ 62 | 63 | 64 | @click.command() 65 | @click.argument("shell", required=True) 66 | def completion(shell): 67 | """ 68 | Generate tab completion script for a given shell. 69 | Supported shells: bash. 70 | """ 71 | 72 | render_completion_script(cli, shell) 73 | 74 | 75 | # system 76 | system.add_command(system_cmd.install) 77 | system.add_command(system_cmd.verify) 78 | system.add_command(completion) 79 | 80 | 81 | # organization 82 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 83 | @click.pass_obj 84 | def orga(ctx): 85 | """ 86 | Every registered user can belong to one or multiple organisations and can get authorized for the projects of that 87 | organisation. This command group manages information about your organisations. 88 | You can see all organizations you belong to with the :ref:`list command`. It presents a 89 | tabular view of organisations with ``id`` and ``name``. The :ref:`info command` can be used to 90 | get more detailed information about a particular organisation. This command displays the ``id``, ``title`` and the 91 | optional description of the organisation. The organisation belongs to the group of selection commands, thus it gives 92 | three possible options: 93 | 94 | 1. you can either manually enter the ``organization_id`` as an optional argument 95 | 96 | 2. you can have a context already set with ``organization_id``, then the info for the set organisation will be 97 | displayed 98 | 99 | 3. if none of the above options is specified, you will be prompted with the selection of all possible 100 | organisations you have access to. 101 | 102 | """ 103 | 104 | 105 | orga.add_command(orga_cmd.list) 106 | orga.add_command(orga_cmd.info) 107 | 108 | 109 | # project 110 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 111 | @click.pass_obj 112 | def project(ctx): 113 | """ 114 | Manage your projects. 115 | """ 116 | 117 | 118 | project.add_command(project_cmd.list) 119 | project.add_command(project_cmd.info) 120 | project.add_command(project_cmd.up) 121 | project.add_command(project_cmd.down) 122 | project.add_command(project_cmd.delete) 123 | project.add_command(project_cmd.prune) 124 | 125 | 126 | # deck 127 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 128 | @click.pass_obj 129 | def deck(ctx): 130 | """ 131 | Manage all decks you have access to. 132 | """ 133 | 134 | 135 | deck.add_command(deck_cmd.list) 136 | deck.add_command(deck_cmd.info) 137 | deck.add_command(deck_cmd.install) 138 | deck.add_command(deck_cmd.uninstall) 139 | deck.add_command(deck_cmd.ingress) 140 | 141 | 142 | # application 143 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 144 | @click.pass_obj 145 | def app(ctx): 146 | """ 147 | Manage your applications. 148 | """ 149 | 150 | 151 | app.add_command(app_cmd.info) 152 | app.add_command(app_cmd.list) 153 | app.add_command(app_cmd.shell) 154 | app.add_command(app_cmd.switch) 155 | app.add_command(app_cmd.logs) 156 | app.add_command(app_cmd.env) 157 | app.add_command(app_cmd.exec) 158 | app.add_command(app_cmd.update) 159 | 160 | 161 | # authentication 162 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 163 | def auth(): 164 | """ 165 | The authentication command group unites all subcommands for managing Unikube's authentication process. Besides the 166 | standard :ref:`reference/auth:login` and :ref:`reference/auth:logout` commands, you can check your current 167 | authentication status by using :ref:`reference/auth:status` command. 168 | A valid login state is required for most of the unikube CLI commands. 169 | """ 170 | 171 | 172 | auth.add_command(auth_cmd.login) 173 | auth.add_command(auth_cmd.logout) 174 | auth.add_command(auth_cmd.status) 175 | 176 | 177 | # context 178 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 179 | @click.pass_obj 180 | def context(ctx): 181 | """ 182 | The ``context`` command group enables you to modify the local context. 183 | You can :ref:`reference/context:set` and :ref:`reference/context:remove` the organization, project 184 | and deck context. Use :ref:`reference/context:show` to show the current context. 185 | """ 186 | 187 | 188 | context.add_command(context_cmd.set) 189 | context.add_command(context_cmd.remove) 190 | context.add_command(context_cmd.show) 191 | 192 | # init 193 | cli.add_command(init_cmd.init) 194 | 195 | # shortcut 196 | # -> include auth check in functions if required 197 | cli.add_command(auth_cmd.login) 198 | cli.add_command(auth_cmd.logout) 199 | cli.add_command(project_cmd.up) 200 | cli.add_command(deck_cmd.install) 201 | cli.add_command(app_cmd.shell) 202 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | .vscode/ 3 | 4 | ### Python template 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/uoat 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | staticfiles/ 58 | media/ 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | # pyenv 67 | .python-version 68 | 69 | 70 | 71 | # Environments 72 | .venv 73 | venv/ 74 | .env 75 | ENV/ 76 | 77 | # Rope project settings 78 | .ropeproject 79 | 80 | # mkdocs documentation 81 | /site 82 | 83 | # mypy 84 | .mypy_cache/ 85 | 86 | 87 | ### Node template 88 | # Logs 89 | logs 90 | *.log 91 | npm-debug.log* 92 | yarn-debug.log* 93 | yarn-error.log* 94 | 95 | # Runtime data 96 | pids 97 | *.pid 98 | *.seed 99 | *.pid.lock 100 | 101 | # Directory for instrumented libs generated by jscoverage/JSCover 102 | lib-cov 103 | 104 | # Coverage directory used by tools like istanbul 105 | coverage 106 | 107 | # nyc test coverage 108 | .nyc_output 109 | 110 | # Bower dependency directory (https://bower.io/) 111 | bower_components 112 | 113 | # node-waf configuration 114 | .lock-wscript 115 | 116 | # Compiled binary addons (http://nodejs.org/api/addons.html) 117 | build/Release 118 | 119 | # Dependency directories 120 | node_modules/ 121 | jspm_packages/ 122 | 123 | # Typescript v1 declaration files 124 | typings/ 125 | 126 | # Optional npm cache directory 127 | .npm 128 | 129 | # Optional eslint cache 130 | .eslintcache 131 | 132 | # Optional REPL history 133 | .node_repl_history 134 | 135 | # Output of 'npm pack' 136 | *.tgz 137 | 138 | # Yarn Integrity file 139 | .yarn-integrity 140 | 141 | 142 | ### Linux template 143 | *~ 144 | 145 | # temporary files which can be created if a process still has a handle open of a deleted file 146 | .fuse_hidden* 147 | 148 | # KDE directory preferences 149 | .directory 150 | 151 | # Linux trash folder which might appear on any partition or disk 152 | .Trash-* 153 | 154 | # .nfs files are created when an open file is removed but is still being accessed 155 | .nfs* 156 | 157 | 158 | ### VisualStudioCode template 159 | .vscode/* 160 | !.vscode/settings.json 161 | !.vscode/tasks.json 162 | !.vscode/launch.json 163 | !.vscode/extensions.json 164 | 165 | 166 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 167 | # In case of local modifications made by Pycharm, use update-index command 168 | # for each changed file, like this: 169 | # git update-index --assume-unchanged .idea/loyalty_engine.iml 170 | ### JetBrains template 171 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 172 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 173 | 174 | # User-specific stuff: 175 | .idea/**/workspace.xml 176 | .idea/**/tasks.xml 177 | .idea/dictionaries 178 | 179 | # Sensitive or high-churn files: 180 | .idea/** 181 | .idea/**/dataSources/ 182 | .idea/**/dataSources.ids 183 | .idea/**/dataSources.xml 184 | .idea/**/dataSources.local.xml 185 | .idea/**/sqlDataSources.xml 186 | .idea/**/dynamic.xml 187 | .idea/**/uiDesigner.xml 188 | 189 | # Gradle: 190 | .idea/**/gradle.xml 191 | .idea/**/libraries 192 | 193 | # CMake 194 | cmake-build-debug/ 195 | 196 | # Mongo Explorer plugin: 197 | .idea/**/mongoSettings.xml 198 | 199 | ## File-based project format: 200 | *.iws 201 | 202 | ## Plugin-specific files: 203 | 204 | # IntelliJ 205 | out/ 206 | 207 | # mpeltonen/sbt-idea plugin 208 | .idea_modules/ 209 | 210 | # JIRA plugin 211 | atlassian-ide-plugin.xml 212 | 213 | # Cursive Clojure plugin 214 | .idea/replstate.xml 215 | 216 | # Crashlytics plugin (for Android Studio and IntelliJ) 217 | com_crashlytics_export_strings.xml 218 | crashlytics.properties 219 | crashlytics-build.properties 220 | fabric.properties 221 | 222 | 223 | 224 | ### Windows template 225 | # Windows thumbnail cache files 226 | Thumbs.db 227 | ehthumbs.db 228 | ehthumbs_vista.db 229 | 230 | # Dump file 231 | *.stackdump 232 | 233 | # Folder config file 234 | Desktop.ini 235 | 236 | # Recycle Bin used on file shares 237 | $RECYCLE.BIN/ 238 | 239 | # Windows Installer files 240 | *.cab 241 | *.msi 242 | *.msm 243 | *.msp 244 | 245 | # Windows shortcuts 246 | *.lnk 247 | 248 | 249 | ### macOS template 250 | # General 251 | *.DS_Store 252 | .AppleDouble 253 | .LSOverride 254 | 255 | # Icon must end with two \r 256 | Icon 257 | 258 | # Thumbnails 259 | ._* 260 | 261 | # Files that might appear in the root of a volume 262 | .DocumentRevisions-V100 263 | .fseventsd 264 | .Spotlight-V100 265 | .TemporaryItems 266 | .Trashes 267 | .VolumeIcon.icns 268 | .com.apple.timemachine.donotpresent 269 | 270 | # Directories potentially created on remote AFP share 271 | .AppleDB 272 | .AppleDesktop 273 | Network Trash Folder 274 | Temporary Items 275 | .apdisk 276 | 277 | 278 | ### SublimeText template 279 | # Cache files for Sublime Text 280 | *.tmlanguage.cache 281 | *.tmPreferences.cache 282 | *.stTheme.cache 283 | 284 | # Workspace files are user-specific 285 | *.sublime-workspace 286 | 287 | # Project files should be checked into the repository, unless a significant 288 | # proportion of contributors will probably not be using Sublime Text 289 | # *.sublime-project 290 | 291 | # SFTP configuration file 292 | sftp-config.json 293 | 294 | # Package control specific files 295 | Package Control.last-run 296 | Package Control.ca-list 297 | Package Control.ca-bundle 298 | Package Control.system-ca-bundle 299 | Package Control.cache/ 300 | Package Control.ca-certs/ 301 | Package Control.merged-ca-bundle 302 | Package Control.user-ca-bundle 303 | oscrypto-ca-bundle.crt 304 | bh_unicode_properties.cache 305 | 306 | # Sublime-github package stores a github token in this file 307 | # https://packagecontrol.io/packages/sublime-github 308 | GitHub.sublime-settings 309 | .kube 310 | 311 | ### Vim template 312 | # Swap 313 | [._]*.s[a-v][a-z] 314 | [._]*.sw[a-p] 315 | [._]s[a-v][a-z] 316 | [._]sw[a-p] 317 | 318 | # Session 319 | Session.vim 320 | 321 | # Temporary 322 | .netrwhist 323 | 324 | # Auto-generated tag files 325 | tags 326 | 327 | .pytest_cache/ 328 | 329 | .local-dev/ 330 | 331 | .docker/data/ 332 | -------------------------------------------------------------------------------- /unikube/local/providers/k3d/k3d.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | from time import sleep 5 | from typing import Dict, List, Optional 6 | 7 | from semantic_version import Version 8 | 9 | import unikube.cli.console as console 10 | from unikube import settings 11 | from unikube.local.providers.abstract_provider import AbstractK8sProvider 12 | from unikube.local.providers.k3d.storage import K3dStorage 13 | from unikube.local.providers.types import K8sProviderType 14 | from unikube.local.system import CMDWrapper 15 | 16 | 17 | class K3d(AbstractK8sProvider, CMDWrapper): 18 | kubernetes_cluster_type = K8sProviderType.k3d 19 | 20 | base_command = "k3d" 21 | _cluster = [] 22 | 23 | def __init__( 24 | self, 25 | id, 26 | name: str = None, 27 | prefix: str = settings.K3D_CLUSTER_PREFIX, 28 | _debug_output=False, 29 | ): 30 | # storage 31 | storage = K3dStorage(id=id) 32 | 33 | # abstract kubernetes cluster 34 | AbstractK8sProvider.__init__( 35 | self, 36 | id=id, 37 | name=name, 38 | storage=storage, 39 | ) 40 | 41 | # CMDWrapper 42 | self._debug_output = _debug_output 43 | 44 | # cluster name 45 | cluster_name = prefix + self.name.lower() 46 | cluster_name = cluster_name.replace(" ", "-") 47 | self.k3d_cluster_name = cluster_name 48 | 49 | def _clusters(self) -> List[Dict[str, str]]: 50 | if len(self._cluster) == 0: 51 | arguments = ["cluster", "list", "--no-headers"] 52 | process = self._execute(arguments) 53 | list_output = process.stdout.read() 54 | clusters = [] 55 | cluster_list = [item.strip() for item in list_output.split("\n")[:-1]] 56 | for entry in cluster_list: 57 | cluster = [item.strip() for item in entry.split(" ") if item != ""] 58 | # todo handle this output 59 | if len(cluster) != 4: 60 | continue 61 | clusters.append( 62 | { 63 | "name": cluster[0], 64 | "servers": cluster[1], 65 | "agents": cluster[2], 66 | "loadbalancer": cluster[3] == "true", 67 | } 68 | ) 69 | self._cluster = clusters 70 | return self._cluster 71 | 72 | def get_kubeconfig(self, wait=10) -> Optional[str]: 73 | arguments = ["kubeconfig", "get", self.k3d_cluster_name] 74 | # this is a nasty busy wait, but we don't have another chance 75 | for i in range(1, wait): 76 | process = self._execute(arguments) 77 | if process.returncode == 0: 78 | break 79 | else: 80 | console.info(f"Waiting for the cluster to be ready ({i}/{wait}).") 81 | sleep(2) 82 | 83 | if process.returncode != 0: 84 | console.error("Something went completely wrong with the cluster spin up (or we got a timeout).") 85 | else: 86 | # we now need to write the kubekonfig to a file 87 | config = process.stdout.read().strip() 88 | if not os.path.isdir(os.path.join(settings.CLI_KUBECONFIG_DIRECTORY, self.k3d_cluster_name)): 89 | os.mkdir(os.path.join(settings.CLI_KUBECONFIG_DIRECTORY, self.k3d_cluster_name)) 90 | config_path = os.path.join( 91 | settings.CLI_KUBECONFIG_DIRECTORY, 92 | self.k3d_cluster_name, 93 | "kubeconfig.yaml", 94 | ) 95 | file = open(config_path, "w+") 96 | file.write(config) 97 | file.close() 98 | return config_path 99 | 100 | @staticmethod 101 | def _get_random_unused_port() -> int: 102 | import socket 103 | 104 | tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 105 | tcp.bind(("", 0)) 106 | addr, port = tcp.getsockname() 107 | tcp.close() 108 | return port 109 | 110 | def exists(self) -> bool: 111 | for cluster in self._clusters(): 112 | if cluster["name"] == self.k3d_cluster_name: 113 | return True 114 | return False 115 | 116 | def create( 117 | self, 118 | ingress_port=None, 119 | workers=settings.K3D_DEFAULT_WORKERS, 120 | ): 121 | v5plus = self.version().major >= 5 122 | api_port = self._get_random_unused_port() 123 | if not ingress_port: 124 | publisher_port = self._get_random_unused_port() 125 | else: 126 | publisher_port = ingress_port 127 | arguments = [ 128 | "cluster", 129 | "create", 130 | self.k3d_cluster_name, 131 | "--agents", 132 | str(workers), 133 | "--api-port", 134 | str(api_port), 135 | "--port", 136 | f"{publisher_port}:{settings.K3D_DEFAULT_INGRESS_PORT}@agent{':0' if v5plus else '[0]'}", 137 | "--servers", 138 | str(1), 139 | "--wait", 140 | "--timeout", 141 | "120s", 142 | ] 143 | self._execute(arguments) 144 | 145 | data = self.storage.get() 146 | data.name = self.k3d_cluster_name 147 | data.api_port = api_port 148 | data.publisher_port = publisher_port 149 | data.kubeconfig_path = self.get_kubeconfig() 150 | self.storage.set(data) 151 | 152 | return True 153 | 154 | def start(self): 155 | arguments = ["cluster", "start", self.k3d_cluster_name] 156 | p = self._execute(arguments) 157 | if p.returncode != 0: 158 | return False 159 | data = self.storage.get() 160 | data.kubeconfig_path = self.get_kubeconfig() 161 | self.storage.set(data) 162 | return True 163 | 164 | def stop(self): 165 | arguments = ["cluster", "stop", self.k3d_cluster_name] 166 | self._execute(arguments) 167 | return True 168 | 169 | def delete(self): 170 | arguments = ["cluster", "delete", self.k3d_cluster_name] 171 | self._execute(arguments) 172 | self.storage.delete() 173 | return True 174 | 175 | def version(self) -> Version: 176 | process = subprocess.run([self.base_command, "--version"], capture_output=True, text=True) 177 | output = str(process.stdout).strip() 178 | version_str = re.search(r"(\d+\.\d+\.\d+)", output).group(1) 179 | return Version(version_str) 180 | 181 | 182 | class K3dBuilder: 183 | def __init__(self): 184 | self._instances = {} 185 | 186 | def __call__( 187 | self, 188 | id, 189 | name=None, 190 | **_ignored, 191 | ): 192 | # get instance from cache 193 | instance = self._instances.get(id, None) 194 | if instance: 195 | return instance 196 | 197 | # create instance 198 | instance = K3d( 199 | id, 200 | name=name, 201 | prefix=settings.K3D_CLUSTER_PREFIX, 202 | ) 203 | self._instances[id] = instance 204 | 205 | return instance 206 | -------------------------------------------------------------------------------- /unikube/cli/init.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Tuple 2 | 3 | import click 4 | import yaml 5 | from pydantic import BaseModel 6 | 7 | from unikube.cli import console 8 | from unikube.cli.console import confirm, deck_list, organization_list, project_list 9 | 10 | 11 | class UnikubeFileBuild(BaseModel): 12 | context: str = "." 13 | dockerfile: str = "Dockerfile" 14 | target: str = "" 15 | 16 | 17 | class UnikubeFileContext(BaseModel): 18 | organization: str 19 | project: str 20 | deck: str 21 | 22 | 23 | class UnikubeFileApp(BaseModel): 24 | context: UnikubeFileContext 25 | build: UnikubeFileBuild 26 | deployment: str 27 | port: int 28 | command: Optional[str] 29 | volumes: Optional[List[str]] 30 | env: Optional[List[str]] 31 | 32 | 33 | class UnikubeDumper(yaml.SafeDumper): 34 | """Custom dumper for nice list identation. 35 | 36 | See https://stackoverflow.com/questions/25108581/python-yaml-dump-bad-indentation 37 | """ 38 | 39 | def increase_indent(self, flow=False, indentless=False): 40 | return super(UnikubeDumper, self).increase_indent(flow, False) 41 | 42 | 43 | def _generate_unikube_file(apps: List[UnikubeFileApp], to_file: bool = True): 44 | result = {"version": "1", "apps": {}} 45 | for app in apps: 46 | result["apps"].update({"app": app.dict(exclude_unset=True)}) 47 | 48 | if to_file: 49 | with open("unikube.yaml", "w") as unikube_file: 50 | yaml.dump(result, unikube_file, Dumper=UnikubeDumper, sort_keys=False) 51 | else: 52 | console.echo(yaml.dump(result, Dumper=UnikubeDumper, sort_keys=False)) 53 | 54 | return True 55 | 56 | 57 | def prompt_headline(text: str): 58 | return click.echo(click.style(text + "\n", bold=True, underline=True)) 59 | 60 | 61 | def format_question(question, question_only=False): 62 | if not question_only: 63 | mark = click.style("? ", fg=(69, 208, 147), bold=True) 64 | else: 65 | mark = "" 66 | question_ = click.style(question, bold=True) 67 | return f"{mark}{question_}" 68 | 69 | 70 | def prompt_for_choice(question: str, list_func, ctx): 71 | click.echo(format_question(question, question_only=True)) 72 | result = list_func(ctx) 73 | return result 74 | 75 | 76 | def path_validator(input_string: str): 77 | return len(input_string) == 0 or (isinstance(input_string, str) and ":" in input_string) 78 | 79 | 80 | def env_validator(input_string: str): 81 | return len(input_string) == 0 or (isinstance(input_string, str) and "=" in input_string) 82 | 83 | 84 | def get_docker_file(): 85 | return console.input("Dockerfile (default: 'Dockerfile')", "Dockerfile") 86 | 87 | 88 | def get_context(): 89 | console.echo("What's the context for building your docker image?") 90 | console.echo("For more information on 'docker build context' please see:") 91 | console.link("https://docs.docker.com/engine/reference/commandline/build/#extended-description") 92 | return console.input("Build Context (default: '.')", ".") 93 | 94 | 95 | def get_target(): 96 | console.echo("What's the target stage for building your docker image?") 97 | console.echo("For more information on Docker's target stage please see:") 98 | console.link("https://docs.docker.com/engine/reference/commandline/build/#specifying-target-build-stage---target") 99 | return console.input("Target (optional)", "") 100 | 101 | 102 | def get_deployment(): 103 | return console.input("What's the name of the deployment?", mandatory=True) 104 | 105 | 106 | def get_port(): 107 | return console.input( 108 | "What's the port of it's container?", 109 | mandatory=True, 110 | validate=lambda x: x.isnumeric, 111 | invalid_message="Input must be a number.", 112 | ) 113 | 114 | 115 | def get_command(): 116 | return console.input("What command should be executed on start? (optional)", "") 117 | 118 | 119 | def get_env(): 120 | console.input( 121 | "Enter env variables (e.g. DEBUG=true)", "", validate=env_validator, invalid_message="Input must contain '='." 122 | ) 123 | 124 | 125 | def get_volume(): 126 | return console.input( 127 | "Enter a volume mapping (e.g. :)", 128 | "", 129 | validate=path_validator, 130 | invalid_message="Input must contain ':'.", 131 | ) 132 | 133 | 134 | def collect_app_data(ctx) -> UnikubeFileApp: 135 | click.echo("") 136 | click.echo("This command helps to generate a unikube.yaml file.") 137 | click.echo("For detailed information concerning the file please visit:") 138 | console.link("https://unikube.io/docs/guides/developing-with-unikube.html#unikubefile") 139 | click.echo("") 140 | prompt_headline("Unikube Information") 141 | 142 | # TODO this could probably be pulled from the current cli context 143 | organization = prompt_for_choice("Which organization does the project belong to?", organization_list, ctx) 144 | project = prompt_for_choice("Which project does the service run in?", project_list, ctx) 145 | deck = prompt_for_choice("Which deck contains the deployment?", deck_list, ctx) 146 | 147 | unikube_context = UnikubeFileContext(organization=organization, deck=deck, project=project) 148 | 149 | # Collect docker information 150 | click.echo("") 151 | prompt_headline("Docker information") 152 | dockerfile = get_docker_file() 153 | context = get_context() 154 | target = get_target() 155 | 156 | build = UnikubeFileBuild( 157 | dockerfile=dockerfile, 158 | context=context, 159 | target=target, 160 | ) 161 | 162 | deployment = get_deployment() 163 | port = get_port() 164 | command = get_command() 165 | 166 | volumes = [] 167 | volumes_needed = confirm("Add volume mappings [N/y]") 168 | if volumes_needed: 169 | click.echo("To stop, just hit ENTER.") 170 | volume = get_volume() 171 | volumes.append(volume) 172 | while volume: 173 | volume = get_volume() 174 | 175 | env_needed = confirm("Add env variables [N/y]") 176 | envs = [] 177 | if env_needed: 178 | click.echo("To stop, just hit ENTER.") 179 | env = get_env() 180 | envs.append(env) 181 | while env: 182 | env = get_env() 183 | envs.append(env) 184 | 185 | unikube_app_kwargs = { 186 | "build": build, 187 | "context": unikube_context, 188 | "deployment": deployment, 189 | "port": port, 190 | } 191 | 192 | if command: 193 | unikube_app_kwargs.update( 194 | { 195 | "command": command, 196 | } 197 | ) 198 | 199 | if volumes: 200 | unikube_app_kwargs.update( 201 | { 202 | "volumes": volumes, 203 | } 204 | ) 205 | 206 | if envs: 207 | unikube_app_kwargs.update({"env": envs}) 208 | 209 | app = UnikubeFileApp(**unikube_app_kwargs) 210 | 211 | return app 212 | 213 | 214 | # TODO add options to command to shorten prompts / make command scriptable 215 | @click.command() 216 | @click.option("--stdout", "-s", help="Print file output to console.", is_flag=True) 217 | @click.pass_obj 218 | def init(ctx, stdout): 219 | _ = ctx.auth.refresh() 220 | 221 | # We plan to support multiple apps in the future. 222 | results = [collect_app_data(ctx)] 223 | 224 | result = _generate_unikube_file(results, to_file=not stdout) 225 | if result: 226 | console.echo("") 227 | success = click.style("Successfully generated unikube.yaml!", bold=True, underline=True) 228 | console.echo(f"🚀 {success}\n") 229 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (2021-09-07) 4 | 5 | #### Others 6 | 7 | * add changelog 8 | 9 | Full set of changes: [`0.10.0-dev4...1.0.0`](https://github.com/unikubehq/cli/compare/0.10.0-dev4...1.0.0) 10 | 11 | ## 0.10.0-dev4 (2021-09-03) 12 | 13 | 14 | Full set of changes: [`0.10.0-dev3...0.10.0-dev4`](https://github.com/unikubehq/cli/compare/0.10.0-dev3...0.10.0-dev4) 15 | 16 | ## 0.10.0-dev3 (2021-09-03) 17 | 18 | 19 | Full set of changes: [`0.10.0-dev2...0.10.0-dev3`](https://github.com/unikubehq/cli/compare/0.10.0-dev2...0.10.0-dev3) 20 | 21 | ## 0.10.0-dev2 (2021-09-01) 22 | 23 | #### Fixes 24 | 25 | * check if switched container is running for shell command ([#121](https://github.com/unikubehq/cli/issues/121)) 26 | #### Docs 27 | 28 | * fix sentence 29 | 30 | Full set of changes: [`0.10.0-dev1...0.10.0-dev2`](https://github.com/unikubehq/cli/compare/0.10.0-dev1...0.10.0-dev2) 31 | 32 | ## 0.10.0-dev1 (2021-08-27) 33 | 34 | #### Docs 35 | 36 | * fix typos, small rewrites ([#118](https://github.com/unikubehq/cli/issues/118)) 37 | * fix typo 38 | 39 | Full set of changes: [`0.9.0-dev11...0.10.0-dev1`](https://github.com/unikubehq/cli/compare/0.9.0-dev11...0.10.0-dev1) 40 | 41 | ## 0.9.0-dev11 (2021-08-26) 42 | 43 | #### Docs 44 | 45 | * fix typos, add few hints 46 | 47 | Full set of changes: [`0.9.0-dev10...0.9.0-dev11`](https://github.com/unikubehq/cli/compare/0.9.0-dev10...0.9.0-dev11) 48 | 49 | ## 0.9.0-dev10 (2021-08-26) 50 | 51 | #### Docs 52 | 53 | * add missing references 54 | 55 | Full set of changes: [`0.9.0-dev9...0.9.0-dev10`](https://github.com/unikubehq/cli/compare/0.9.0-dev9...0.9.0-dev10) 56 | 57 | ## 0.9.0-dev9 (2021-08-26) 58 | 59 | #### Fixes 60 | 61 | * remove help arg from click.argument 62 | 63 | Full set of changes: [`0.9.0-dev8...0.9.0-dev9`](https://github.com/unikubehq/cli/compare/0.9.0-dev8...0.9.0-dev9) 64 | 65 | ## 0.9.0-dev8 (2021-08-26) 66 | 67 | #### Docs 68 | 69 | * add another hint 70 | 71 | Full set of changes: [`0.9.0-dev7...0.9.0-dev8`](https://github.com/unikubehq/cli/compare/0.9.0-dev7...0.9.0-dev8) 72 | 73 | ## 0.9.0-dev7 (2021-08-26) 74 | 75 | #### Docs 76 | 77 | * fix merge mess 78 | * move text and docs structure, add a lot of documentation 79 | * move text and docs structure, add a lot of documentation 80 | * adds even more basic documentation 81 | * delete redundant docs 82 | 83 | Full set of changes: [`0.9.0-dev6...0.9.0-dev7`](https://github.com/unikubehq/cli/compare/0.9.0-dev6...0.9.0-dev7) 84 | 85 | ## 0.9.0-dev6 (2021-08-26) 86 | 87 | #### New Features 88 | 89 | * duplicate project title 90 | * duplicate project title 91 | * showing specific error message if project cannot be retrieved 92 | * exit on error with specified message 93 | #### Fixes 94 | 95 | * missing imports from helpers 96 | * remove list as parameter 97 | * fixes for minor problems 98 | * project up inconsistency 99 | * fixes for minor problems 100 | * project up inconsistency 101 | * test errors for DistributionNotFound 102 | * version check error 103 | * invalid context error mesage 104 | * invalid context error mesage 105 | #### Refactorings 106 | 107 | * change console info 108 | * consolidate project use tests 109 | * change console info 110 | * consolidate project use tests 111 | * remove exit_error_with_message() 112 | * remove exit_error_with_message() 113 | * remove exit_error_with_message() 114 | * remove exit_error_with_message() 115 | #### Others 116 | 117 | * adjust tests 118 | * added new tests for helpers 119 | 120 | Full set of changes: [`0.9.0-dev4...0.9.0-dev6`](https://github.com/unikubehq/cli/compare/0.9.0-dev4...0.9.0-dev6) 121 | 122 | ## 0.9.0-dev4 (2021-07-16) 123 | 124 | #### New Features 125 | 126 | * showing specific error message if project cannot be retrieved 127 | * exit on error with specified message 128 | #### Fixes 129 | 130 | * login state in test 131 | * login test 132 | * app switch deck_id 133 | * deck selection 134 | * typo ([#68](https://github.com/unikubehq/cli/issues/68)) 135 | * take namespace from environment instead of deck for KubeAPI when uninstalling deck 136 | #### Others 137 | 138 | * add real login test 139 | * added new tests for helpers 140 | 141 | Full set of changes: [`0.9.0-dev3...0.9.0-dev4`](https://github.com/unikubehq/cli/compare/0.9.0-dev3...0.9.0-dev4) 142 | 143 | ## 0.9.0-dev3 (2021-06-28) 144 | 145 | #### Fixes 146 | 147 | * take namespace from environment instead of deck for KubeAPI 148 | 149 | Full set of changes: [`0.9.0-dev2...0.9.0-dev3`](https://github.com/unikubehq/cli/compare/0.9.0-dev2...0.9.0-dev3) 150 | 151 | ## 0.9.0-dev2 (2021-06-28) 152 | 153 | #### New Features 154 | 155 | * add info for running project up command 156 | * exec as shell alias 157 | * exec as shell alias 158 | #### Fixes 159 | 160 | * add oic to requirem 161 | * updating requirement for a higher version of PyYAML 162 | * sonarcloud detected bugs 163 | * create cluster spinner added 164 | * code smell commented code 165 | * install coveralls and add to requirements 166 | * code smells with nested empty function 167 | * install coverage 168 | * create cluster spinner added 169 | #### Docs 170 | 171 | * add coverage badge 172 | #### Others 173 | 174 | * run black on new files, fix flake8 175 | * pin pyyaml to >=5.4 in setup.py 176 | * add pytest to workflow 177 | * test orga command 178 | * test text 179 | * comment failing tests 180 | * update workflows and add coveragerc file 181 | * add tests for app, auth, system commands 182 | 183 | Full set of changes: [`0.8.1-dev13...0.9.0-dev2`](https://github.com/unikubehq/cli/compare/0.8.1-dev13...0.9.0-dev2) 184 | 185 | ## 0.8.1-dev13 (2021-06-14) 186 | 187 | #### Fixes 188 | 189 | * app switch command to clean docker processes after 190 | 191 | Full set of changes: [`0.8.1-dev12...0.8.1-dev13`](https://github.com/unikubehq/cli/compare/0.8.1-dev12...0.8.1-dev13) 192 | 193 | ## 0.8.1-dev12 (2021-06-14) 194 | 195 | #### Fixes 196 | 197 | * app switch command to build docker for projects with whitespace 198 | * follow flag 199 | #### Refactorings 200 | 201 | * rename function 202 | 203 | Full set of changes: [`0.8.1-dev10...0.8.1-dev12`](https://github.com/unikubehq/cli/compare/0.8.1-dev10...0.8.1-dev12) 204 | 205 | ## 0.8.1-dev10 (2021-06-11) 206 | 207 | #### Fixes 208 | 209 | * adds spinner to setup.py 210 | 211 | Full set of changes: [`0.8.1-dev9...0.8.1-dev10`](https://github.com/unikubehq/cli/compare/0.8.1-dev9...0.8.1-dev10) 212 | 213 | ## 0.8.1-dev9 (2021-06-09) 214 | 215 | #### New Features 216 | 217 | * include context in commands 218 | #### Fixes 219 | 220 | * get at least 100 decks (paginator still missing) 221 | * (login): AUTH_DEFAULT_HOST can be changed 222 | #### Refactorings 223 | 224 | * (context): GraphQL request 225 | 226 | Full set of changes: [`0.8.1-dev8...0.8.1-dev9`](https://github.com/unikubehq/cli/compare/0.8.1-dev8...0.8.1-dev9) 227 | 228 | ## 0.8.1-dev8 (2021-06-01) 229 | 230 | 231 | Full set of changes: [`0.8.1-dev7...0.8.1-dev8`](https://github.com/unikubehq/cli/compare/0.8.1-dev7...0.8.1-dev8) 232 | 233 | ## 0.8.1-dev7 (2021-06-01) 234 | 235 | #### Docs 236 | 237 | * add pre-release notice 238 | 239 | Full set of changes: [`0.8.1-dev6...0.8.1-dev7`](https://github.com/unikubehq/cli/compare/0.8.1-dev6...0.8.1-dev7) 240 | 241 | ## 0.8.1-dev6 (2021-06-01) 242 | 243 | #### Docs 244 | 245 | * add cloudbuild config 246 | 247 | Full set of changes: [`0.8.1-dev5...0.8.1-dev6`](https://github.com/unikubehq/cli/compare/0.8.1-dev5...0.8.1-dev6) 248 | 249 | ## 0.8.1-dev5 (2021-06-01) 250 | 251 | 252 | Full set of changes: [`0.8.1-dev4...0.8.1-dev5`](https://github.com/unikubehq/cli/compare/0.8.1-dev4...0.8.1-dev5) 253 | 254 | ## 0.8.1-dev4 (2021-06-01) 255 | 256 | #### Docs 257 | 258 | * fixunikube logo image 259 | 260 | Full set of changes: [`0.8.1-dev3...0.8.1-dev4`](https://github.com/unikubehq/cli/compare/0.8.1-dev3...0.8.1-dev4) 261 | 262 | ## 0.8.1-dev3 (2021-06-01) 263 | 264 | #### Docs 265 | 266 | * change unikube logo image 267 | 268 | Full set of changes: [`0.8.1-dev2...0.8.1-dev3`](https://github.com/unikubehq/cli/compare/0.8.1-dev2...0.8.1-dev3) 269 | 270 | ## 0.8.1-dev2 (2021-06-01) 271 | 272 | #### Docs 273 | 274 | * add long desription to package 275 | 276 | Full set of changes: [`0.8.1-dev1...0.8.1-dev2`](https://github.com/unikubehq/cli/compare/0.8.1-dev1...0.8.1-dev2) 277 | 278 | ## 0.8.1-dev1 (2021-06-01) 279 | 280 | #### New Features 281 | 282 | * (context): package use added 283 | * adds batches in readme 284 | * adds github workflows 285 | * initial release 286 | #### Fixes 287 | 288 | * correct helper string 289 | * correct sonarcloud batch 290 | #### Docs 291 | 292 | * fix readme links 293 | #### Others 294 | 295 | * add github pypi publish workflow 296 | * run black 297 | -------------------------------------------------------------------------------- /unikube/authentication/authentication.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from urllib.parse import urljoin 3 | 4 | import click_spinner 5 | import jwt 6 | import requests 7 | 8 | import unikube.cli.console as console 9 | from unikube import settings 10 | from unikube.authentication.types import AuthenticationData 11 | from unikube.storage.general import LocalStorageGeneral 12 | from unikube.storage.user import LocalStorageUser 13 | 14 | 15 | class IAuthentication: 16 | def check(self): 17 | raise NotImplementedError 18 | 19 | def login(self, email: str, password: str) -> dict: 20 | raise NotImplementedError 21 | 22 | def logout(self): 23 | raise NotImplementedError 24 | 25 | def verify(self) -> dict: 26 | raise NotImplementedError 27 | 28 | def refresh(self) -> dict: 29 | raise NotImplementedError 30 | 31 | def verify_or_refresh(self) -> bool: 32 | raise NotImplementedError 33 | 34 | 35 | class TokenAuthentication(IAuthentication): 36 | def __init__( 37 | self, 38 | local_storage_general: LocalStorageGeneral, 39 | timeout=settings.TOKEN_TIMEOUT, 40 | ): 41 | self.local_storage_general = local_storage_general 42 | 43 | self.general_data = self.local_storage_general.get() 44 | self.timeout = timeout 45 | 46 | self.url_public_key = urljoin(self.__get_host(), settings.TOKEN_PUBLIC_KEY) 47 | self.url_login = urljoin(self.__get_host(), settings.TOKEN_LOGIN_PATH) 48 | self.url_verify = urljoin(self.__get_host(), settings.TOKEN_VERIFY_PATH) 49 | self.url_refresh = urljoin(self.__get_host(), settings.TOKEN_REFRESH_PATH) 50 | 51 | self.client_id = settings.KC_CLIENT_ID 52 | 53 | # RPT 54 | self.requesting_party_token_audience = settings.TOKEN_RPT_AUDIENCE 55 | 56 | def __get_host(self) -> str: 57 | try: 58 | local_storage_user = LocalStorageUser(user_email=self.general_data.authentication.email) 59 | user_data = local_storage_user.get() 60 | auth_host = user_data.config.auth_host 61 | 62 | if not auth_host: 63 | raise Exception("User data config does not specify an authentication host.") 64 | 65 | return auth_host 66 | 67 | except Exception: 68 | return settings.AUTH_DEFAULT_HOST 69 | 70 | def _get_requesting_party_token(self, access_token): 71 | # requesting party token (RPT) 72 | response = self.__request( 73 | url=self.url_login, 74 | data={ 75 | "audience": self.requesting_party_token_audience, 76 | "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", 77 | }, 78 | headers={"Authorization": f"Bearer {access_token}"}, 79 | message_exception="Could not establish a server connection.", 80 | message_200="", 81 | message_400="Wrong user credentials or account does not exist.", 82 | message_500="There was an server error.", 83 | ) 84 | 85 | # select response 86 | if not response["success"]: 87 | return response 88 | 89 | return response 90 | 91 | def check(self): 92 | # login required 93 | with click_spinner.spinner(beep=False, disable=False, force=False, stream=sys.stdout): 94 | if not self.verify_or_refresh(): 95 | console.exit_login_required() 96 | 97 | def login( 98 | self, 99 | email: str, 100 | password: str, 101 | ) -> dict: 102 | # set/update user config 103 | local_storage_user = LocalStorageUser(user_email=email) 104 | user_data = local_storage_user.get() 105 | user_data.config.auth_host = settings.AUTH_DEFAULT_HOST 106 | local_storage_user.set(user_data) 107 | 108 | # access token + refresh token 109 | response_token = self.__request( 110 | url=self.url_login, 111 | data={ 112 | "username": email, 113 | "password": password, 114 | "grant_type": "password", 115 | "client_id": self.client_id, 116 | }, 117 | message_exception="Could not establish a server connection.", 118 | message_200="", 119 | message_400="Wrong user credentials or account does not exist.", 120 | message_500="There was an server error.", 121 | ) 122 | 123 | if not response_token["success"]: 124 | return response_token 125 | 126 | # requesting party token (RPT) 127 | response_RPT = self._get_requesting_party_token(response_token["response"]["access_token"]) 128 | 129 | # select response 130 | if response_RPT["success"]: 131 | response = response_RPT 132 | requesting_party_token = True 133 | else: 134 | response = response_token 135 | requesting_party_token = False 136 | 137 | # set authentication data 138 | self.general_data.authentication = AuthenticationData( 139 | email=email, 140 | access_token=response["response"]["access_token"], 141 | refresh_token=response["response"]["refresh_token"], 142 | requesting_party_token=requesting_party_token, 143 | ) 144 | 145 | self.local_storage_general.set(self.general_data) 146 | 147 | return response 148 | 149 | def logout(self): 150 | self.general_data.authentication = AuthenticationData() 151 | self.local_storage_general.set(self.general_data) 152 | 153 | def verify(self) -> dict: 154 | # keycloak 155 | access_token = self.general_data.authentication.access_token 156 | response = self.__request( 157 | url=self.url_verify, 158 | data={}, 159 | headers={"Authorization": f"Bearer {access_token}"}, 160 | message_exception="Could not establish a server connection.", 161 | message_200="", 162 | message_400="Invalid or expired login data, please log in again with 'unikube login'.", 163 | message_500="There was an server error.", 164 | ) 165 | return response 166 | 167 | def refresh(self) -> dict: 168 | # request 169 | refresh_token = self.general_data.authentication.refresh_token 170 | response_token = self.__request( 171 | url=self.url_refresh, 172 | data={ 173 | "refresh_token": refresh_token, 174 | "grant_type": "refresh_token", 175 | "client_id": self.client_id, 176 | }, 177 | message_exception="Could not establish a server connection.", 178 | message_200="", 179 | message_400="Refresh token expired or account does not exist.", 180 | message_500="There was an server error.", 181 | ) 182 | 183 | if not response_token["success"]: 184 | return response_token 185 | 186 | # requesting party token (RPT) 187 | response_RPT = self._get_requesting_party_token(response_token["response"]["access_token"]) 188 | 189 | # select response 190 | if response_RPT["success"]: 191 | response = response_RPT 192 | requesting_party_token = True 193 | else: 194 | response = response_token 195 | requesting_party_token = False 196 | 197 | # update token 198 | if response["success"]: 199 | self.general_data = self.local_storage_general.get() 200 | self.general_data.authentication.access_token = response["response"]["access_token"] 201 | self.general_data.authentication.refresh_token = response["response"]["refresh_token"] 202 | self.general_data.authentication.requesting_party_token = requesting_party_token 203 | self.local_storage_general.set(self.general_data) 204 | 205 | return response 206 | 207 | def verify_or_refresh(self) -> bool: 208 | # verify 209 | response = self.verify() 210 | if response["success"]: 211 | return True 212 | 213 | # refresh 214 | response = self.refresh() 215 | if response["success"]: 216 | return True 217 | 218 | # exception messsage 219 | console.debug(response["message"]) 220 | 221 | return False 222 | 223 | def __request( 224 | self, 225 | url, 226 | data, 227 | message_exception, 228 | message_200, 229 | message_400, 230 | message_500, 231 | headers=None, 232 | ) -> dict: 233 | # request 234 | try: 235 | req = requests.post( 236 | url, 237 | data, 238 | headers=headers, 239 | timeout=self.timeout, 240 | ) 241 | except Exception as e: 242 | console.debug(e) 243 | return { 244 | "success": False, 245 | "message": message_exception, 246 | "response": None, 247 | } 248 | 249 | # return 250 | if req.status_code == 200: 251 | success = True 252 | message = message_200 253 | 254 | elif req.status_code in [400, 401, 404]: 255 | success = False 256 | message = message_400 257 | 258 | elif req.status_code in [500, 501, 502, 503]: 259 | success = False 260 | message = message_500 261 | 262 | else: 263 | success = False 264 | message = "" 265 | 266 | # get json response 267 | try: 268 | response = req.json() 269 | except Exception: 270 | response = None 271 | 272 | return { 273 | "success": success, 274 | "message": message, 275 | "response": response, 276 | } 277 | 278 | def token_from_response(self, response): 279 | token = jwt.decode( 280 | response["response"]["access_token"], 281 | algorithms=["RS256"], 282 | audience=settings.TOKEN_AUDIENCE, 283 | options={"verify_signature": False}, 284 | ) 285 | return token 286 | 287 | 288 | def get_authentication(): 289 | token_authentication = TokenAuthentication(local_storage_general=LocalStorageGeneral()) 290 | return token_authentication 291 | --------------------------------------------------------------------------------