Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for private registry on Megalos #282

Open
wants to merge 3 commits into
base: 283-private-registry-support-on-megalos
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/Kathara/cli/ui/setting/KubernetesOptionsHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,41 @@ def add_items(self, current_menu: ConsoleMenu, menu_formatter: MenuFormatBuilder

image_pull_policy_item = SubmenuItem(image_pull_policy_string, image_pull_policy_menu, current_menu)

# Private Registry docker_config_json Option
docker_config_json_string = "Private Registry Configuration"
docker_config_json_menu = SelectionMenu(
strings=[],
title=docker_config_json_string,
subtitle=setting_utils.current_enabled("docker_config_json"),
prologue_text="""Insert the config.json file path containing the configuration to """
"""access private container registries. The content will be stored as """
"""a base64 string and it will be used to create a Kubernetes Secret """
"""of type `kubernetes.io/dockerconfigjson`.

Default is %s.""" % DEFAULTS['docker_config_json'],
formatter=menu_formatter
)

docker_config_json_menu.append_item(
FunctionItem(
text=docker_config_json_string,
function=setting_utils.read_docker_config_json,
should_exit=True
)
)
docker_config_json_menu.append_item(
FunctionItem(
text="Reset value to Empty String",
function=setting_utils.update_setting_value,
args=["docker_config_json", None],
should_exit=True
)
)

docker_config_json_item = SubmenuItem(docker_config_json_string, docker_config_json_menu, current_menu)

current_menu.append_item(api_url_item)
current_menu.append_item(api_token_item)
current_menu.append_item(host_shared_item)
current_menu.append_item(image_pull_policy_item)
current_menu.append_item(docker_config_json_item)
65 changes: 55 additions & 10 deletions src/Kathara/cli/ui/setting/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from enum import Enum
from typing import Optional, Callable, Any, Tuple, List
import base64

from ....exceptions import SettingsError
from ....setting.Setting import Setting
from ....trdparty.consolemenu import *
from ....trdparty.consolemenu import UserQuit
from ....trdparty.consolemenu.prompt_utils import InputResult
from ....trdparty.consolemenu.validators.base import BaseValidator
from ....validator.DockerConfigJsonValidator import DockerConfigJsonValidator

DEFAULT_DOCKER_CONFIG_JSON_PATH = "~/.docker/config.json"

SAVED_STRING = "Saved successfully!\n"
PRESS_ENTER_STRING = "Press [Enter] to continue."
Expand Down Expand Up @@ -35,6 +39,12 @@ def current_string(attribute_name: str, text: Optional[str] = None) -> Callable[
")" if text else ""
)

def current_enabled(attribute_name: str, text: Optional[str] = None) -> Callable[[], str]:
return lambda: "%sCurrent: %s%s" % (text + " (" if text else "",
"Enabled" if getattr(Setting.get_instance(), attribute_name) else "Disabled",
")" if text else ""
)


def current_enum(attribute_name: str, to_string: Callable[[int], str], text: Optional[str] = None) -> Callable[[], str]:
return lambda: "%sCurrent: %s%s" % (text + " (" if text else "",
Expand Down Expand Up @@ -76,10 +86,16 @@ def update_setting_values(attribute_values: List[Tuple[str, Any]]) -> None:
Screen().input(PRESS_ENTER_STRING)


def read_and_validate_value(prompt_msg: str, validator: BaseValidator, error_msg: str) -> InputResult:
def read_and_validate_value(prompt_msg: str, validator: BaseValidator, error_msg: str = None) -> InputResult:
return read_and_validate_value_with_default(prompt_msg, validator, error_msg)


def read_and_validate_value_with_default(prompt_msg: str, validator: BaseValidator, error_msg: str = None,
default_value: Any = None) -> InputResult:
prompt_utils = PromptUtils(Screen())
answer = prompt_utils.input(prompt=prompt_msg,
validators=validator,
default=default_value,
enable_quit=True
)

Expand All @@ -90,18 +106,25 @@ def read_and_validate_value(prompt_msg: str, validator: BaseValidator, error_msg
return answer


def read_value(attribute_name: str, validator: BaseValidator, prompt_msg: str, error_msg: str) -> None:
def read_value(attribute_name: str, validator: BaseValidator, prompt_msg: str, error_msg: str = None) -> None:
read_value_with_default(attribute_name, validator, prompt_msg, error_msg)


def read_value_with_default(attribute_name: str, validator: BaseValidator, prompt_msg: str, error_msg: str = None,
default_value: Any = None) -> None:
try:
answer = read_and_validate_value(prompt_msg=prompt_msg,
validator=validator,
error_msg=error_msg
)
answer = read_and_validate_value_with_default(prompt_msg=prompt_msg,
validator=validator,
error_msg=error_msg,
default_value=default_value,
)

while not answer.validation_result:
answer = read_and_validate_value(prompt_msg=prompt_msg,
validator=validator,
error_msg=error_msg
)
answer = read_and_validate_value_with_default(prompt_msg=prompt_msg,
validator=validator,
error_msg=error_msg,
default_value=default_value,
)
except UserQuit:
return

Expand All @@ -111,3 +134,25 @@ def read_value(attribute_name: str, validator: BaseValidator, prompt_msg: str, e
print(SAVED_STRING)

Screen().input(PRESS_ENTER_STRING)


def read_docker_config_json() -> None:
try:
answer = read_and_validate_value_with_default(
f"Write the path to the Docker Config JSON file: (Default: {DEFAULT_DOCKER_CONFIG_JSON_PATH})",
DockerConfigJsonValidator(),
default_value=DEFAULT_DOCKER_CONFIG_JSON_PATH
)
while not answer.validation_result:
answer = read_and_validate_value_with_default(
f"Write the path to the Docker Config JSON file: (Default: {DEFAULT_DOCKER_CONFIG_JSON_PATH})",
DockerConfigJsonValidator(),
default_value=DEFAULT_DOCKER_CONFIG_JSON_PATH
)

with open(answer.input_string, 'r') as f:
docker_config_json = f.read()
docker_config_json = base64.b64encode(docker_config_json.encode()).decode()
update_setting_value("docker_config_json", docker_config_json)
except UserQuit:
return
10 changes: 10 additions & 0 deletions src/Kathara/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,13 @@ class DockerPluginError(Exception):
# Kubernetes Exceptions
class KubernetesConfigMapError(Exception):
pass


class InvalidDockerConfigJSONError(ValueError):
__slots__ = ['docker_config_json_path']

def __init__(self, docker_config_json_path: str):
self.docker_config_json_path: str = docker_config_json_path

def __str__(self):
return f"Invalid Docker Config JSON file: {self.docker_config_json_path}"
10 changes: 9 additions & 1 deletion src/Kathara/manager/kubernetes/KubernetesMachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from .KubernetesConfigMap import KubernetesConfigMap
from .KubernetesNamespace import KubernetesNamespace
from .KubernetesSecret import DOCKERCONFIGJSON_SECRET_NAME
from .stats.KubernetesMachineStats import KubernetesMachineStats
from ... import utils
from ...event.EventDispatcher import EventDispatcher
Expand Down Expand Up @@ -458,11 +459,18 @@ def _build_definition(self, machine: Machine, config_map: client.V1ConfigMap) ->
)
))

# Add image_pull_secrets if using private registries
image_pull_secrets_arg = {}
if Setting.get_instance().docker_config_json:
image_pull_secrets_arg = {"image_pull_secrets":
[client.V1LocalObjectReference(name=DOCKERCONFIGJSON_SECRET_NAME)]}

pod_spec = client.V1PodSpec(containers=[container_definition],
hostname=machine.meta['real_name'],
dns_policy="None",
dns_config=dns_config,
volumes=volumes
volumes=volumes,
**image_pull_secrets_arg
GioBar00 marked this conversation as resolved.
Show resolved Hide resolved
)

pod_template = client.V1PodTemplateSpec(metadata=pod_metadata, spec=pod_spec)
Expand Down
7 changes: 5 additions & 2 deletions src/Kathara/manager/kubernetes/KubernetesManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .KubernetesLink import KubernetesLink
from .KubernetesMachine import KubernetesMachine
from .KubernetesNamespace import KubernetesNamespace
from .KubernetesSecret import KubernetesSecret
from .stats.KubernetesLinkStats import KubernetesLinkStats
from .stats.KubernetesMachineStats import KubernetesMachineStats
from ... import utils
Expand All @@ -25,12 +26,13 @@
class KubernetesManager(IManager):
"""Class responsible for interacting with Kubernetes API."""

__slots__ = ['k8s_namespace', 'k8s_machine', 'k8s_link']
__slots__ = ['k8s_secret', 'k8s_namespace', 'k8s_machine', 'k8s_link']

def __init__(self) -> None:
KubernetesConfig.load_kube_config()

self.k8s_namespace: KubernetesNamespace = KubernetesNamespace()
self.k8s_secret: KubernetesSecret = KubernetesSecret()
self.k8s_namespace: KubernetesNamespace = KubernetesNamespace(self.k8s_secret)
self.k8s_machine: KubernetesMachine = KubernetesMachine(self.k8s_namespace)
self.k8s_link: KubernetesLink = KubernetesLink(self.k8s_namespace)

Expand Down Expand Up @@ -303,6 +305,7 @@ def wipe(self, all_users: bool = False) -> None:
if all_users:
logging.warning("User-specific options have no effect on Megalos.")

self.k8s_secret.wipe()
self.k8s_namespace.wipe()

def connect_tty(self, machine_name: str, lab_hash: Optional[str] = None, lab_name: Optional[str] = None,
Expand Down
11 changes: 9 additions & 2 deletions src/Kathara/manager/kubernetes/KubernetesNamespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
from kubernetes.client.api import core_v1_api
from kubernetes.client.rest import ApiException

from .KubernetesSecret import KubernetesSecret, DOCKERCONFIGJSON_SECRET_NAME
from ...setting.Setting import Setting
from ...model.Lab import Lab


class KubernetesNamespace(object):
"""Class responsible for interacting with Kubernetes namespaces."""

__slots__ = ['client']
__slots__ = ['client', 'kubernetes_secret']

def __init__(self) -> None:
def __init__(self, kubernetes_secret: KubernetesSecret) -> None:
self.client: core_v1_api.CoreV1Api = core_v1_api.CoreV1Api()

self.kubernetes_secret = kubernetes_secret

def create(self, lab: Lab) -> Optional[client.V1Namespace]:
"""Return a Kubernetes namespace from a Kathara network scenario.

Expand All @@ -32,6 +36,9 @@ def create(self, lab: Lab) -> Optional[client.V1Namespace]:
try:
self.client.create_namespace(namespace_definition)
self._wait_namespace_creation(lab.hash)
docker_config_json = Setting.get_instance().docker_config_json
if docker_config_json:
self.kubernetes_secret.create_dockerconfigjson_secret(lab.hash, DOCKERCONFIGJSON_SECRET_NAME, docker_config_json)
except ApiException:
return None

Expand Down
108 changes: 108 additions & 0 deletions src/Kathara/manager/kubernetes/KubernetesSecret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import logging
from typing import Optional, Iterable

from kubernetes import client, watch
from kubernetes.client.api import core_v1_api
from kubernetes.client.rest import ApiException

from ...setting.Setting import Setting
from ...model.Lab import Lab

DOCKERCONFIGJSON_SECRET_NAME = 'private-registry'

class KubernetesSecret(object):
"""Class responsible for interacting with Kubernetes secrets."""

__slots__ = ['client']

def __init__(self) -> None:
self.client: core_v1_api.CoreV1Api = core_v1_api.CoreV1Api()

def create_dockerconfigjson_secret(self, lab_hash: str, name: str,
docker_config_json: str) -> Optional[client.V1Secret]:
"""Return a Kubernetes secret of type kubernetes.io/dockerconfigjson from a Kathara network scenario.

Args:
lab_hash (str): The hash of a Kathara network scenario.
name (str): The name of the secret.
docker_config_json (str): A Docker configuration JSON.

Returns:
Optional[client.V1Secret]: The Kubernetes secret for the Docker configuration JSON.
"""
secret_definition = client.V1Secret(
metadata=client.V1ObjectMeta(name=name, namespace=lab_hash, labels={'app': 'kathara'}),
type="kubernetes.io/dockerconfigjson",
data={".dockerconfigjson": docker_config_json}
)

try:
self.client.create_namespaced_secret(lab_hash, secret_definition)
self._wait_secret_creation(lab_hash, name)
except ApiException:
return None

def delete(self, lab_hash: str, name: str) -> None:
"""Delete the Kubernetes secret corresponding to the lab_hash.

Args:
lab_hash (str): The hash of a Kathara network scenario.
name (str): The name of the secret.

Returns:
None
"""
try:
self.client.delete_namespaced_secret(name, lab_hash)
self._wait_secrets_deletion(lab_hash, field_selector=f"metadata.name={name}")
except ApiException:
return

def wipe(self) -> None:
"""Namespace deletion implies automatic deletion of all secrets.

Returns:
None
"""
pass

def _wait_secret_creation(self, lab_hash: str, name: str) -> None:
"""Wait for the secret creation to be completed.

Args:
lab_hash (str): The hash of a Kathara network scenario.
name (str): The name of the secret.

Returns:
None
"""
w = watch.Watch()
for event in w.stream(self.client.list_namespaced_secret, namespace=lab_hash, field_selector=f"metadata.name={name}"):
logging.debug(f"Event: {event['type']} - Secret: {event['object'].metadata.name}")

if event['type'] == "ADDED":
break

def _wait_secrets_deletion(self, lab_hash: str, field_selector: str = None) -> None:
"""Wait the deletion of the specified Kubernetes secrets. Returns when the secrets are deleted.

Args:
lab_hash (str): The hash of a Kathara network scenario.
field_selector (str): The field selector for the secret.

Returns:
None
"""
secrets_to_delete = len(self.client.list_namespaced_secret(lab_hash, field_selector=field_selector).items)

if secrets_to_delete > 0:
w = watch.Watch()
deleted_secrets = 0
for event in w.stream(self.client.list_namespaced_secret, namespace=lab_hash, field_selector=field_selector):
logging.debug(f"Event: {event['type']} - Secret: {event['object'].metadata.name}")

if event['type'] == "DELETED":
deleted_secrets += 1

if deleted_secrets == secrets_to_delete:
w.stop()
Loading