diff --git a/Makefile b/Makefile index 51d90e03..9795470a 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,30 @@ test-e2e: manifests generate fmt vet ## Run the e2e tests. Expected an isolated } go test ./test/e2e/ -v -ginkgo.v +.PHONY: deploy-local +deploy-local: manifests generate + @command -v $(KIND) >/dev/null 2>&1 || { \ + echo "Kind is not installed. Please install Kind manually."; \ + exit 1; \ + } + @$(KIND) get clusters | grep -q 'kind' || { \ + echo "No Kind cluster is running. Please start a Kind cluster before deploying ETOS locally."; \ + exit 1; \ + } + python -m local.main -r -i -v up + +.PHONY: undeploy-local +undeploy-local: manifests generate + @command -v $(KIND) >/dev/null 2>&1 || { \ + echo "Kind is not installed. Please install Kind manually."; \ + exit 1; \ + } + @$(KIND) get clusters | grep -q 'kind' || { \ + echo "No Kind cluster is running. Are you sure you want to undeploy?"; \ + exit 1; \ + } + python -m local.main -r -i -v down + .PHONY: lint lint: golangci-lint ## Run golangci-lint linter $(GOLANGCI_LINT) run diff --git a/local/commands/__init__.py b/local/commands/__init__.py new file mode 100644 index 00000000..e4d26f4f --- /dev/null +++ b/local/commands/__init__.py @@ -0,0 +1,16 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Commands.""" diff --git a/local/commands/command.py b/local/commands/command.py new file mode 100644 index 00000000..165aeda9 --- /dev/null +++ b/local/commands/command.py @@ -0,0 +1,45 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Base command.""" +from typing import Union + +from local.utilities.result import Result +from local.utilities.store import Value, get_values + +MINUTE = 60 + + +class Command: + """Base command for all commands to inherit.""" + + default_timeout = 2 * MINUTE + + def __init__( + self, + command: Union[list[str | Value], "Command"], + timeout: int = default_timeout, + ): + self.command = command + self.default_timeout = timeout + + def execute(self) -> Result: + """Execute command and return Result. Override this.""" + raise NotImplementedError + + def __repr__(self) -> str: + if isinstance(self.command, list): + return " ".join(get_values(*self.command)) + return repr(self.command) diff --git a/local/commands/kubectl.py b/local/commands/kubectl.py new file mode 100644 index 00000000..ed2b62ce --- /dev/null +++ b/local/commands/kubectl.py @@ -0,0 +1,264 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Kubectl wrapper.""" +import logging + +from local.utilities.store import Value + +from .command import Command +from .shell import Shell + + +class Resource: + """Kubernetes resource definition. + + A resource can either be a `filename` for `kubectl -f`, + a `kustomize` for `kubectl -k` or a `type`/`name(s)`. + """ + + filename: str | Value | None = None + kustomize: str | Value | None = None + type: str | Value | None = None + names: list[str | Value] | None = None + namespace: str | Value | None = None + selector: list[str | Value] | None = None + + def __init__( + self, + filename: str | Value | None = None, + kustomize: str | Value | None = None, + type: str | Value | None = None, + names: list[str | Value] | str | Value | None = None, + namespace: str | Value | None = None, + selector: list[str | Value] | str | Value | None = None, + ): + self.filename = filename + self.kustomize = kustomize + self.type = type + + if names is not None and not isinstance(names, list): + names = [names] + self.names = names + if selector is not None and not isinstance(selector, list): + selector = [selector] + self.selector = selector + self.namespace = namespace + self.__verify() + + def __verify(self): + """Verify that not too much or too little information is stored in Resource.""" + if any( + [ + ( + self.filename is not None + and ( + self.kustomize is not None + or self.type is not None + or self.names is not None + or self.selector is not None + ) + ), + ( + self.kustomize is not None + and ( + self.filename is not None + or self.type is not None + or self.names is not None + or self.selector is not None + ) + ), + ( + self.type is not None + and self.names is not None + and ( + self.filename is not None + or self.kustomize is not None + or self.selector is not None + ) + ), + ( + self.type is not None + and self.selector is not None + and ( + self.filename is not None + or self.kustomize is not None + or self.names is not None + ) + ), + ] + ): + raise ValueError( + "Only one of filename, kustomize, type/name pair and type/selector pair are allowed" + ) + if ( + self.filename is None + and self.kustomize is None + and self.type is None + and self.names is None + and self.selector is None + ): + raise ValueError( + "One of filename, kustomize and type/name pair is required" + ) + + if self.type is None and (self.names or self.selector): + raise ValueError( + "Type and names or selector come in pairs, must supply both" + ) + if (self.names is None and self.selector is None) and self.type is not None: + raise ValueError( + "Type and names or selector come in pairs, must supply both" + ) + + +class Kubectl: + """Wrapper for the kubectl shell command.""" + + logger = logging.getLogger(__name__) + + def create(self, resource: Resource, *args: str) -> Command: + """Command for running `kubectl create`. + + Resource.selector is not supported. + """ + command: list[str | Value] = ["kubectl", "create"] + if resource.namespace is not None: + command.extend(["--namespace", resource.namespace]) + if resource.filename is not None: + command.extend(["--filename", resource.filename]) + if resource.kustomize is not None: + command.extend(["--kustomize", resource.kustomize]) + if resource.type is not None and resource.names is not None: + command.extend([resource.type, *resource.names]) + command.extend(args) + return Shell(command) + + def delete( + self, + resource: Resource, + ignore_not_found: bool = True, + ) -> Command: + """Command for running `kubectl delete`. + + Resource.selector is not supported. + """ + command: list[str | Value] = ["kubectl", "delete"] + if resource.namespace is not None: + command.extend(["--namespace", resource.namespace]) + if resource.filename is not None: + command.extend(["--filename", resource.filename]) + if resource.kustomize is not None: + command.extend(["--kustomize", resource.kustomize]) + if resource.type is not None and resource.names is not None: + command.extend([resource.type, *resource.names]) + if ignore_not_found: + command.append(f"--ignore-not-found={str(ignore_not_found).lower()}") + return Shell(command) + + def run( + self, + name: str, + namespace: str | Value, + image: str, + overrides: str, + restart_policy: str = "Never", + ) -> Command: + """Command for running `kubectl run`.""" + command = [ + "kubectl", + "run", + name, + "--namespace", + namespace, + f"--restart={restart_policy}", + f"--image={image}", + "--overrides", + overrides, + ] + return Shell(command) + + def label(self, resource: Resource, label: str, overwrite: bool = True) -> Command: + """Command for running `kubectl label`. + + Resource.selector is not supported. + """ + command: list[str | Value] = ["kubectl", "label"] + if resource.namespace is not None: + command.extend(["--namespace", resource.namespace]) + if resource.filename is not None: + command.extend(["--filename", resource.filename]) + if resource.kustomize is not None: + command.extend(["--kustomize", resource.kustomize]) + if resource.type is not None and resource.names is not None: + command.extend([resource.type, *resource.names]) + if overwrite: + command.append("--overwrite") + command.append(label) + return Shell(command) + + def wait( + self, + resource: Resource, + wait_for: str, + timeout: int = Command.default_timeout, + ) -> Command: + """Command for running `kubectl wait`. + + Only Resource.type/Resource.names or Resource.selector pairs are supported. + """ + if resource.type is None or ( + resource.names is None and resource.selector is None + ): + raise ValueError("Wait only supports the type & names resource style") + command: list[str | Value] = [ + "kubectl", + "wait", + "--for", + wait_for, + "--timeout", + f"{timeout}s", + ] + if resource.type is not None and resource.names is not None: + command.extend([resource.type, *resource.names]) + if resource.type is not None and resource.selector is not None: + command.extend([resource.type, "--selector", *resource.selector]) + if resource.namespace is not None: + command.extend(["--namespace", resource.namespace]) + return Shell(command, timeout) + + def get( + self, + resource: Resource, + output: str | None = None, + ) -> Command: + """Command for running `kubectl get`. + + Only Resource.type/Resource.names or Resource.selector pairs are supported. + """ + if resource.type is None or ( + resource.names is None and resource.selector is None + ): + raise ValueError("Get only supports the type & names resource style") + command: list[str | Value] = ["kubectl", "get"] + if resource.namespace is not None: + command.extend(["--namespace", resource.namespace]) + if resource.type is not None and resource.names is not None: + command.extend([resource.type, *resource.names]) + if resource.type is not None and resource.selector is not None: + command.extend([resource.type, "--selector", *resource.selector]) + if output is not None: + command.extend(["--output", output]) + return Shell(command) diff --git a/local/commands/make.py b/local/commands/make.py new file mode 100644 index 00000000..8a7e741b --- /dev/null +++ b/local/commands/make.py @@ -0,0 +1,52 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS make wrapper.""" +import logging + +from local.utilities.store import Value + +from .command import Command +from .shell import Shell + + +class Make: + """Wrapper to the shell command 'make'.""" + + logger = logging.getLogger(__name__) + + def install(self) -> Command: + """Command for running 'make install'.""" + return Shell(["make", "install"]) + + def uninstall(self, ignore_not_found: bool = True) -> Command: + """Command for running 'make uninstall'.""" + return Shell( + ["make", "uninstall", f"ignore-not-found={str(ignore_not_found).lower()}"] + ) + + def deploy(self, image: str | Value) -> Command: + """Command for running 'make deploy'.""" + # TODO: This only works because the image value is set in main, no runtime variables work here + return Shell(["make", "deploy", f"IMG={image}"]) + + def docker_build(self, image: str | Value) -> Command: + return Shell(["make", "docker-build", f"IMG={image}"]) + + def undeploy(self, ignore_not_found: bool = True) -> Command: + """Command for running 'make undeploy'.""" + return Shell( + ["make", "undeploy", f"ignore-not-found={str(ignore_not_found).lower()}"] + ) diff --git a/local/commands/shell.py b/local/commands/shell.py new file mode 100644 index 00000000..dd763a2f --- /dev/null +++ b/local/commands/shell.py @@ -0,0 +1,54 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS shell command wrapper.""" +import logging +from subprocess import run + +from local.utilities.result import Result +from local.utilities.store import get_values + +from .command import Command + + +class Shell(Command): + """Wrapper for subprocess.run.""" + + logger = logging.getLogger(__name__) + + def execute(self) -> Result: + """Execute command.""" + assert isinstance(self.command, list) + return self.__run(get_values(*self.command)) + + def __clean(self, output: str) -> str: + """Clean up output from subprocess.run.""" + return "\n".join( + [line.strip() for line in output.strip('"').strip("'").split("\n") if line] + ) + + def __run(self, command: list[str]) -> Result: + """Run a shell command using subprocess.run, returning Result.""" + process = run( + command, + capture_output=True, + timeout=self.default_timeout, + universal_newlines=True, + ) + return Result( + process.returncode, + stderr=self.__clean(process.stderr), + stdout=self.__clean(process.stdout), + ) diff --git a/local/commands/utilities.py b/local/commands/utilities.py new file mode 100644 index 00000000..ed71f79f --- /dev/null +++ b/local/commands/utilities.py @@ -0,0 +1,180 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utility commands.""" +import logging +import time + +from local.utilities.result import Result +from local.utilities.store import Store + +from .command import Command + + +class WaitUntil(Command): + """Wait until a command has result.code 0. + + Default timeout is 2 minutes and default interval is 1 second. + """ + + logger = logging.getLogger(__name__) + # Force the type to be Command when running WaitUntil. + command: Command + + def __init__(self, command: Command, *args, interval: int = 1, **kwargs): + super().__init__(command, *args, **kwargs) + self.command = command + assert isinstance(self.command, Command) + self.interval = interval + + def execute(self) -> Result: + """Execute a command until it succeeds or timeout is reached. + + If command times out return with a failed Result. + """ + end = time.time() + self.default_timeout + result = Result(code=128, stderr="No command was executed at all") + self.logger.info( + "Waiting for command %r, timeout=%ds (interval=%ds)", + self.command, + self.default_timeout, + self.interval, + ) + while time.time() < end: + result = self.command.execute() + if result.code == 0: + return result + self.logger.debug("Command failed, trying again in %ds", self.interval) + self.logger.debug("stdout: %r", result.stdout) + self.logger.debug("stderr: %r", result.stderr) + time.sleep(self.interval) + self.logger.error("Timed out waiting for command to succeed") + return result + + +class HasLines(Command): + """Check number of lines of a commands stdout.""" + + logger = logging.getLogger(__name__) + # Force the type to be Command when running HasLines. + command: Command + + def __init__(self, command: Command, *args, length: int, **kwargs): + super().__init__(command, *args, **kwargs) + self.command = command + assert isinstance(self.command, Command) + self.length = length + + def execute(self) -> Result: + """Execute a stored command, split stdout on newlines and check the length. + + If the stdout does not match the number of lines, then exit with a failed Result. + """ + result = self.command.execute() + if result.code != 0: + return result + if result.stdout is None: + return Result(code=128, stderr="There's no text in stdout") + lines = len(result.stdout.splitlines()) + if lines != self.length: + return Result( + code=128, + stdout=result.stdout, + stderr=f"Length {lines} does not match {self.length}", + ) + return result + + +class StdoutEquals(Command): + """Test if stdout of a command matches a value.""" + + # Force the type to be Command when running StdoutEquals. + command: Command + + def __init__(self, command: Command, *args, value: str, **kwargs): + super().__init__(command, *args, **kwargs) + self.command = command + assert isinstance(self.command, Command) + self.value = value + + def execute(self) -> Result: + """Execute a command and match its output against a stored value. + + If stdout does not match the value, then exit with a failed Result. + """ + result = self.command.execute() + if result.code != 0: + return result + if result.stdout is None: + return Result(code=128, stderr="There's no text in stdout") + if result.stdout != self.value: + return Result( + code=128, + stdout=result.stdout, + stderr=f"Text in stdout does not match value {self.value!r}", + ) + return result + + +class StdoutLength(Command): + """Check if the stdout of a command matches length.""" + + # Force the type to be Command when running StdoutLength. + command: Command + + def __init__(self, command: Command, *args, length: int, **kwargs): + super().__init__(command, *args, **kwargs) + self.command = command + assert isinstance(self.command, Command) + self.length = length + + def execute(self) -> Result: + """Execute a command and check the stdout length. + + If stdout length does not match the expected length, then exit with a failed Result. + """ + result = self.command.execute() + if result.code != 0: + return result + if result.stdout is None: + return Result(code=128, stderr="There's no text in stdout") + length = len(result.stdout) + if length < self.length: + return Result( + code=128, + stdout=result.stdout, + stderr=f"Length of stdout does not match length {self.length}", + ) + return result + + +class StoreStdout(Command): + """Store the stdout of a command.""" + + # Force the type to be Command when running StoreStdout. + command: Command + + def __init__(self, command: Command, *args, key: str, store: Store, **kwargs): + super().__init__(command, *args, **kwargs) + self.command = command + assert isinstance(self.command, Command) + self.key = key + self.store = store + + def execute(self) -> Result: + """Execute command and store its stdout.""" + result = self.command.execute() + self.store[self.key] = result.stdout + return result diff --git a/local/executor.py b/local/executor.py new file mode 100644 index 00000000..25ec5dee --- /dev/null +++ b/local/executor.py @@ -0,0 +1,77 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Command executor.""" +import logging + +from local.commands.command import Command +from local.utilities.result import Result + + +class Fail(Exception): + """Exception to raise when a command fails. + + Exception contains a `Result` with information about the command execution, + such as stdout, stderr and code. + """ + + result: Result + + def __init__(self, result: Result, *args, **kwargs): + super().__init__(*args, **kwargs) + self.result = result + + +class Executor: + """Executor to execute a list of commands in order.""" + + logger = logging.getLogger(__name__) + + def __init__(self, commands: list[Command]): + self.commands = commands + + def execute(self, ignore_errors=False): + """Execute all commands.""" + self.logger.info("Executing all commands") + failed = False + for command in self.commands: + self.logger.info("Executing command: %r", command) + try: + self.handle_result(command.execute()) + except Fail: + failed = True + if ignore_errors: + self.logger.warning("Error executing command: %r", command) + continue + raise + if failed: + self.logger.warning("Did not successfully execute all commands") + return + self.logger.info("Successfully executed all commands") + + def handle_result(self, result: Result): + """Handle the result of a command, by logging and raising Fail.""" + if result.code != 0: + self.logger.info("Result for pack: code: %d", result.code) + if result.stdout: + self.logger.info(result.stdout) + if result.stderr: + self.logger.error(result.stderr) + raise Fail(result) + self.logger.debug("Result for pack: code: %d", result.code) + if result.stdout: + self.logger.debug(result.stdout) + if result.stderr: + self.logger.warning(result.stderr) diff --git a/local/main.py b/local/main.py new file mode 100644 index 00000000..dae566f3 --- /dev/null +++ b/local/main.py @@ -0,0 +1,171 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS deployment.""" +import argparse +import logging +import os +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Type + +from .commands.command import Command +from .executor import Executor, Fail +from .packs import PACKS +from .packs.base import BasePack +from .utilities.store import Store + +LOGGER = logging.getLogger(__name__) +MAX_LOG_LEVEL = logging.WARNING +REPO_PATH = Path(__file__).parent.parent + + +def setup_logging(loglevel: int): + """Setup logging for the client.""" + logformat = "%(asctime)s %(levelname)s: %(message)s" + logging.basicConfig(stream=sys.stdout, level=loglevel, format=logformat) + + +def parse_args() -> argparse.Namespace: + """Parse input arguments.""" + parser = argparse.ArgumentParser(description="ETOS local bootstrap") + parser.add_argument( + "direction", + type=str, + choices=["up", "down"], + help="Which direction to deploy ETOS, up or down", + ) + parser.add_argument( + "-r", + "--rollback", + action="store_true", + help="Whether or not to rollback changes on failure (only affects direction=up)", + ) + parser.add_argument( + "-i", + "--ignore-errors", + action="store_true", + help="Whether or not to continue after an error. Only affects direction=down and during rollback", + ) + parser.add_argument( + "-v", "--verbose", action="count", default=0, help="Enable more verbose output" + ) + return parser.parse_args() + + +def loglevel(level: int) -> int: + """Convert loglevel count input to a loglevel in logging.""" + return MAX_LOG_LEVEL - (level * 10) + + +def get_packs() -> list[Type[BasePack]]: + """Get all packs that we want to deploy.""" + LOGGER.debug("Fetching all packs") + return PACKS + + +def deploy_commands(packs: list[Type[BasePack]], store: Store) -> list[Command]: + """Extract deploy commands from a list of uninitialized packs.""" + LOGGER.info("Deploying ETOS local") + commands = [] + for uninitialized_pack in packs: + pack = uninitialized_pack(store) + LOGGER.info("Loading create commands from pack %r", pack.name()) + commands.extend(pack.create()) + return commands + + +def undeploy_commands(packs: list[Type[BasePack]], store: Store) -> list[Command]: + """Extract undeploy commands from a list of uninitialized packs.""" + LOGGER.info("Undeploying ETOS local") + commands = [] + for uninitialized_pack in reversed(packs): + pack = uninitialized_pack(store) + LOGGER.info("Loading delete commands from pack %r", pack.name()) + commands.extend(pack.delete()) + return commands + + +def deploy(commands: list[Command], rollback_commands: list[Command]): + """Deploy by running a list of commands.""" + LOGGER.info("Deploying ETOS on kind") + try: + Executor(commands).execute() + except Fail: + LOGGER.critical("Failed to deploy ETOS!") + if rollback_commands: + LOGGER.info("Rolling back changes made") + Executor(rollback_commands).execute(ignore_errors=True) + raise + LOGGER.info("ETOS is up and running and ready for use, happy testing") + + +def undeploy(commands: list[Command], ignore_errors: bool): + """Undeploy by running a list of commands.""" + LOGGER.info("Undeploying ETOS from kind") + try: + Executor(commands).execute(ignore_errors) + except Fail: + LOGGER.critical("Failed to undeploy ETOS!") + raise + LOGGER.info("ETOS has now been completely undeployed") + + +@contextmanager +def chdir(x): + """Change directory as a contextmanager.""" + d = os.getcwd() + os.chdir(x) + try: + yield + finally: + os.chdir(d) + + +def run(args: argparse.Namespace): + """Run the local deployment program.""" + + setup_logging(loglevel(args.verbose)) + store = Store() + # These are hard-coded values used for local deployment, similar to how the e2e tests do it. + store["namespace"] = "etos-system" + store["cluster_namespace"] = "etos-test" + store["cluster_name"] = "cluster-sample" + store["project_image"] = "example.com/etos:v0.0.1" + store["artifact_id"] = "268dd4db-93da-4232-a544-bf4c0fb26dac" + store["artifact_identity"] = "pkg:testrun/etos/eiffel_community" + + try: + with chdir(REPO_PATH): + if args.direction.lower() == "up": + rollback = [] + if args.rollback: + rollback = undeploy_commands(get_packs(), store) + deploy(deploy_commands(get_packs(), store), rollback) + else: + undeploy(undeploy_commands(get_packs(), store), args.ignore_errors) + except Fail as failure: + LOGGER.critical("stderr: %s", failure.result.stderr) + raise SystemExit(1) from failure + + +def main(): + """Main entrypoint to program.""" + run(parse_args()) + + +if __name__ == "__main__": + main() diff --git a/local/packs/__init__.py b/local/packs/__init__.py new file mode 100644 index 00000000..1c7d68d8 --- /dev/null +++ b/local/packs/__init__.py @@ -0,0 +1,24 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS packs.""" +from .cert_manager import CertManager +from .controller import Controller +from .etos import Etos +from .prometheus import Prometheus +from .providers import Providers +from .verify import Verify + +PACKS = [Prometheus, CertManager, Controller, Etos, Providers, Verify] diff --git a/local/packs/base.py b/local/packs/base.py new file mode 100644 index 00000000..8f917332 --- /dev/null +++ b/local/packs/base.py @@ -0,0 +1,37 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS BasePack.""" +from local.commands.command import Command +from local.utilities.store import Store + + +class BasePack: + """Base pack implementation. Does nothing.""" + + def __init__(self, store: Store): + self.local_store = store + + def name(self) -> str: + """Name of pack.""" + return "Base" + + def create(self) -> list[Command]: + """Create nothing. Override this.""" + return [] + + def delete(self) -> list[Command]: + """Delete nothing. Override this.""" + return [] diff --git a/local/packs/cert_manager.py b/local/packs/cert_manager.py new file mode 100644 index 00000000..c44a9e13 --- /dev/null +++ b/local/packs/cert_manager.py @@ -0,0 +1,67 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""CertManager pack.""" +from local.commands.command import MINUTE, Command +from local.commands.kubectl import Kubectl, Resource + +from .base import BasePack + + +class CertManager(BasePack): + """Pack for deploying cert manager.""" + + # Same version that the e2e tests use + version = "v1.16.3" + url = f"https://github.com/cert-manager/cert-manager/releases/download/{version}/cert-manager.yaml" + cert_manager_controller_lease = "cert-manager-controller" + cert_manager_ca_injector_lease = "cert-manager-cainjector-leader-election" + + def name(self) -> str: + """Name of pack.""" + return "CertManager" + + def create(self) -> list[Command]: + """Commands for deploying cert-manager.""" + kubectl = Kubectl() + return [ + kubectl.create(Resource(filename=self.url)), + kubectl.wait( + Resource( + type="deployment.apps", + names="cert-manager-webhook", + namespace="cert-manager", + ), + wait_for="condition=Available", + timeout=5 * MINUTE, + ), + ] + + def delete(self) -> list[Command]: + """Commands for deleting cert-manager and its leases.""" + kubectl = Kubectl() + return [ + kubectl.delete(Resource(filename=self.url)), + kubectl.delete( + Resource( + type="leases", + names=[ + self.cert_manager_controller_lease, + self.cert_manager_ca_injector_lease, + ], + namespace="kube-system", + ) + ), + ] diff --git a/local/packs/controller.py b/local/packs/controller.py new file mode 100644 index 00000000..efaa4f10 --- /dev/null +++ b/local/packs/controller.py @@ -0,0 +1,171 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Controller pack.""" + +from local.commands.command import Command +from local.commands.kubectl import Kubectl, Resource +from local.commands.make import Make +from local.commands.shell import Shell +from local.commands.utilities import (HasLines, StdoutEquals, StdoutLength, + StoreStdout, WaitUntil) + +from .base import BasePack + + +class Controller(BasePack): + """Pack for deploying the ETOS controller.""" + + def name(self) -> str: + """Name of pack.""" + return "Controller" + + def create(self) -> list[Command]: + """Commands for generating a new ETOS controller deployment""" + kubectl = Kubectl() + make = Make() + return [ + kubectl.create( + Resource(type="namespace", names=self.local_store["namespace"]) + ), + kubectl.label( + Resource(type="namespace", names=self.local_store["namespace"]), + "pod-security.kubernetes.io/enforce=restricted", + ), + kubectl.create( + Resource(type="namespace", names=self.local_store["cluster_namespace"]) + ), + make.install(), + make.docker_build(self.local_store["project_image"]), + Shell(["kind", "load", "docker-image", self.local_store["project_image"]]), + make.deploy(self.local_store["project_image"]), + *self.__wait_for_control_plane(kubectl), + *self.__wait_for_webhook_certificates(kubectl), + ] + + def delete(self) -> list[Command]: + """Commands for deleting an ETOS controller deployment""" + kubectl = Kubectl() + make = Make() + return [ + make.undeploy(), + make.uninstall(), + kubectl.delete( + Resource(type="namespace", names=self.local_store["cluster_namespace"]) + ), + kubectl.delete( + Resource(type="namespace", names=self.local_store["namespace"]) + ), + ] + + def __wait_for_control_plane(self, kubectl: Kubectl) -> list[Command]: + """Commands for waiting for the ETOS control plane.""" + return [ + StoreStdout( + WaitUntil( + HasLines( + kubectl.get( + Resource( + type="pods", + selector="control-plane=controller-manager", + namespace=self.local_store["namespace"], + ), + output='go-template="{{ range .items }} {{- if not .metadata.deletionTimestamp }} {{- .metadata.name }} {{ "\\n" }} {{- end -}} {{- end -}}"', + ), + length=1, + ), + ), + key="control_plane_pod_name", + store=self.local_store, + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="pods", + names=self.local_store["control_plane_pod_name"], + namespace=self.local_store["namespace"], + ), + output="jsonpath={.status.phase}", + ), + value="Running", + ), + ), + kubectl.wait( + Resource( + type="pods", + names=self.local_store["control_plane_pod_name"], + namespace=self.local_store["namespace"], + ), + wait_for="condition=ready", + ), + ] + + def __wait_for_webhook_certificates(self, kubectl: Kubectl) -> list[Command]: + """Commands for waiting for the ETOS controller webhooks.""" + commands: list[Command] = [] + for type, name in ( + ( + "validatingwebhookconfigurations.admissionregistration.k8s.io", + "etos-validating-webhook-configuration", + ), + ( + "mutatingwebhookconfigurations.admissionregistration.k8s.io", + "etos-mutating-webhook-configuration", + ), + ): + commands.append( + WaitUntil( + StdoutLength( + kubectl.get( + Resource( + type=type, + names=name, + ), + output="go-template='{{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}'", + ), + length=10, + ), + ) + ) + for type, name in ( + ( + "customresourcedefinitions.apiextensions.k8s.io", + "providers.etos.eiffel-community.github.io", + ), + ( + "customresourcedefinitions.apiextensions.k8s.io", + "testruns.etos.eiffel-community.github.io", + ), + ( + "customresourcedefinitions.apiextensions.k8s.io", + "environmentrequests.etos.eiffel-community.github.io", + ), + ): + commands.append( + WaitUntil( + StdoutLength( + kubectl.get( + Resource( + type=type, + names=name, + ), + output="go-template='{{ .spec.conversion.webhook.clientConfig.caBundle }}'", + ), + length=10, + ), + ) + ) + return commands diff --git a/local/packs/etos.py b/local/packs/etos.py new file mode 100644 index 00000000..928bdd0b --- /dev/null +++ b/local/packs/etos.py @@ -0,0 +1,202 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS pack.""" +from local.commands.command import Command +from local.commands.kubectl import Kubectl, Resource +from local.commands.utilities import StdoutEquals, WaitUntil + +from .base import BasePack + + +class Etos(BasePack): + """Etos pack to create an ETOS cluster. + + Create the cluster spec, deploy GoER (for providers) and inject an + artifact into the system which can be used to verify the deployment. + """ + + cluster_sample = "config/samples/etos_v1alpha1_cluster.yaml" + goer = "testdata/goer.yaml" + + def name(self) -> str: + """Name of the pack.""" + return "ETOS" + + def create(self) -> list[Command]: + """Commands for creating a fully functioning (without providers) ETOS cluster.""" + kubectl = Kubectl() + return [ + kubectl.create( + Resource( + filename=self.cluster_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + kubectl.wait( + Resource( + type="cluster", + names=self.local_store["cluster_name"], + namespace=self.local_store["cluster_namespace"], + ), + wait_for="jsonpath={.status.conditions[?(@.type=='Ready')].status}=True", + ), + *self.__wait_for_deployments(kubectl), + kubectl.create( + Resource( + filename=self.goer, namespace=self.local_store["cluster_namespace"] + ) + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + names="goer", + type="deploy", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath='{.status.readyReplicas}'", + ), + value="1", + ) + ), + *self.__inject_artifact(kubectl), + kubectl.create( + Resource( + type="secret", + # This is a hack, putting generic here because it has to come after 'type' and before 'name' + # and cannot be added to the extra args at the end. + names=["generic", "etos-encryption-key"], + namespace=self.local_store["cluster_namespace"], + ), + "--from-literal=ETOS_ENCRYPTION_KEY=ZmgcW2Qz43KNJfIuF0vYCoPneViMVyObH4GR8R9JE4g=", + ), + ] + + def delete(self) -> list[Command]: + """Commands for deleting the ETOS cluster.""" + kubectl = Kubectl() + return [ + kubectl.delete( + Resource( + type="secret", + names="etos-encryption-key", + namespace=self.local_store["cluster_namespace"], + ) + ), + kubectl.delete( + Resource( + type="pod", + names="artifact-injector", + namespace=self.local_store["cluster_namespace"], + ), + ), + kubectl.delete( + Resource( + filename=self.goer, namespace=self.local_store["cluster_namespace"] + ) + ), + kubectl.delete( + Resource( + filename=self.cluster_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + ] + + def __wait_for_deployments(self, kubectl: Kubectl) -> list[Command]: + """Commands for waiting until the ETOS deployments reach the expected number of ready replicas.""" + commands: list[Command] = [] + cluster_name = self.local_store["cluster_name"].get() + for name, type, count in ( + ("etos-sse", "deploy", "1"), + ("etos-logarea", "deploy", "1"), + ("etos-suite-starter", "deploy", "1"), + ("messagebus", "statefulset", "1"), + ("rabbitmq", "statefulset", "1"), + ("graphql", "deploy", "1"), + ("etos-api", "deploy", "1"), + ("etcd", "statefulset", "3"), + ): + commands.append( + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type=type, + names=f"{cluster_name}-{name}", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath='{.status.readyReplicas}'", + ), + value=count, + ) + ) + ) + return commands + + def __inject_artifact(self, kubectl: Kubectl) -> list[Command]: + """Command for injecting an artifact into the system.""" + # TODO: Injecting multiple artifacts that reference the different ETOS repositories and versions + artifact_id = self.local_store["artifact_id"].get() + artifact_identity = self.local_store["artifact_identity"].get() + cluster_name = self.local_store["cluster_name"].get() + command = f""" +{{ + "spec": {{ + "containers": [{{ + "name": "test", + "image": "ghcr.io/eiffel-community/eiffel-graphql-storage:latest", + "envFrom": [{{"secretRef": {{"name": "{cluster_name}-graphql"}}}}], + "command": ["python", "-c"], + "args": ["from eiffel_graphql_api.graphql.db.database import insert_to_db;from eiffellib.events import EiffelArtifactCreatedEvent;event = EiffelArtifactCreatedEvent(); event.meta.event_id = '{artifact_id}';event.data.data['identity'] = '{artifact_identity}';insert_to_db(event);"], + "securityContext": {{ + "allowPrivilegeEscalation": false, + "capabilities": {{ + "drop": ["ALL"] + }}, + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": {{ + "type": "RuntimeDefault" + }} + }} + }}] + }} +}} + """ + return [ + kubectl.run( + "artifact-injector", + self.local_store["cluster_namespace"], + "ghcr.io/eiffel-community/eiffel-graphql-storage:latest", + command, + ), + kubectl.wait( + Resource( + type="pod", + names="artifact-injector", + namespace=self.local_store["cluster_namespace"], + ), + wait_for="jsonpath={.status.conditions[?(@.type=='Ready')].reason}=PodCompleted", + ), + kubectl.delete( + Resource( + type="pod", + names="artifact-injector", + namespace=self.local_store["cluster_namespace"], + ), + ), + ] diff --git a/local/packs/prometheus.py b/local/packs/prometheus.py new file mode 100644 index 00000000..47c457ef --- /dev/null +++ b/local/packs/prometheus.py @@ -0,0 +1,40 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Prometheus pack.""" +from local.commands.command import Command +from local.commands.kubectl import Kubectl, Resource + +from .base import BasePack + + +class Prometheus(BasePack): + """Pack for deploying prometheus.""" + + # Same version that the e2e tests use + version = "v0.77.1" + url = f"https://github.com/prometheus-operator/prometheus-operator/releases/download/{version}/bundle.yaml" + + def name(self) -> str: + """Name of pack.""" + return "Prometheus" + + def create(self) -> list[Command]: + """Commands for deploying prometheus.""" + return [Kubectl().create(Resource(filename=self.url))] + + def delete(self) -> list[Command]: + """Commands for deleting prometheus.""" + return [Kubectl().delete(Resource(filename=self.url))] diff --git a/local/packs/providers.py b/local/packs/providers.py new file mode 100644 index 00000000..5778956e --- /dev/null +++ b/local/packs/providers.py @@ -0,0 +1,174 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS provider pack.""" +from local.commands.command import Command +from local.commands.kubectl import Kubectl, Resource +from local.commands.utilities import StdoutEquals, WaitUntil + +from .base import BasePack + + +class Providers(BasePack): + """Providers pack to deploy provider services and provider resources.""" + + iut_provider_sample = "config/samples/etos_v1alpha1_iut_provider.yaml" + execution_space_provider_sample = ( + "config/samples/etos_v1alpha1_execution_space_provider.yaml" + ) + log_area_provider_sample = "config/samples/etos_v1alpha1_log_area_provider.yaml" + iut_provider_kustomization = "testdata/iut" + execution_space_provider_kustomization = "testdata/executionspace" + + def name(self) -> str: + """Name of pack.""" + return "Providers" + + def create(self) -> list[Command]: + """Commands for creating provider services and provider resources.""" + kubectl = Kubectl() + return [ + kubectl.create( + Resource( + kustomize=self.execution_space_provider_kustomization, + namespace=self.local_store["cluster_namespace"], + ) + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="deploy", + names="etos-executionspace", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath='{.status.readyReplicas}'", + ), + value="1", + ) + ), + kubectl.create( + Resource( + filename=self.execution_space_provider_sample, + namespace=self.local_store["cluster_namespace"], + ), + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="provider", + names="execution-space-provider-sample", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath={.status.conditions[?(@.type=='Available')].status}", + ), + value="True", + ), + ), + kubectl.create( + Resource( + kustomize=self.iut_provider_kustomization, + namespace=self.local_store["cluster_namespace"], + ) + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="deploy", + names="etos-iut", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath='{.status.readyReplicas}'", + ), + value="1", + ) + ), + kubectl.create( + Resource( + filename=self.iut_provider_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="provider", + names="iut-provider-sample", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath={.status.conditions[?(@.type=='Available')].status}", + ), + value="True", + ), + ), + kubectl.create( + Resource( + filename=self.log_area_provider_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="provider", + names="log-area-provider-sample", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath={.status.conditions[?(@.type=='Available')].status}", + ), + value="True", + ), + ), + ] + + def delete(self) -> list[Command]: + """Commands for deleting provider services and resources.""" + kubectl = Kubectl() + return [ + kubectl.delete( + Resource( + filename=self.log_area_provider_sample, + namespace=self.local_store["cluster_namespace"], + ), + ), + kubectl.delete( + Resource( + filename=self.iut_provider_sample, + namespace=self.local_store["cluster_namespace"], + ), + ), + kubectl.delete( + Resource( + kustomize=self.iut_provider_kustomization, + namespace=self.local_store["cluster_namespace"], + ), + ), + kubectl.delete( + Resource( + filename=self.execution_space_provider_sample, + namespace=self.local_store["cluster_namespace"], + ), + ), + kubectl.delete( + Resource( + kustomize=self.execution_space_provider_kustomization, + namespace=self.local_store["cluster_namespace"], + ), + ), + ] diff --git a/local/packs/verify.py b/local/packs/verify.py new file mode 100644 index 00000000..915db6c6 --- /dev/null +++ b/local/packs/verify.py @@ -0,0 +1,74 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local ETOS deployment verification.""" +from local.commands.command import MINUTE, Command +from local.commands.kubectl import Kubectl, Resource +from local.commands.utilities import StdoutEquals, WaitUntil + +from .base import BasePack + + +class Verify(BasePack): + """Verify the deployment of ETOS by executing a TestRun.""" + + test_run_sample = "config/samples/etos_v1alpha1_testrun.yaml" + + def name(self) -> str: + """Name of the pack.""" + return "Verify" + + def create(self) -> list[Command]: + """Commands for creating and waiting for an ETOS testrun.""" + kubectl = Kubectl() + return [ + kubectl.create( + Resource( + filename=self.test_run_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + WaitUntil( + StdoutEquals( + kubectl.get( + Resource( + type="testrun", + names="testrun-sample", + namespace=self.local_store["cluster_namespace"], + ), + output="jsonpath={.status.verdict}", + ), + value="Passed", + ), + timeout=5 * MINUTE, + ), + Kubectl().delete( + Resource( + filename=self.test_run_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + ] + + def delete(self) -> list[Command]: + """Commands for deleting created testruns.""" + return [ + Kubectl().delete( + Resource( + filename=self.test_run_sample, + namespace=self.local_store["cluster_namespace"], + ) + ), + ] diff --git a/local/utilities/__init__.py b/local/utilities/__init__.py new file mode 100644 index 00000000..3b6342aa --- /dev/null +++ b/local/utilities/__init__.py @@ -0,0 +1,16 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utility modules.""" diff --git a/local/utilities/result.py b/local/utilities/result.py new file mode 100644 index 00000000..759d3c3f --- /dev/null +++ b/local/utilities/result.py @@ -0,0 +1,32 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Result module.""" + + +class Result: + """Result of a command execution.""" + + code: int + stdout: str | None + stderr: str | None + + def __init__(self, code: int, stdout: str | None = None, stderr: str | None = None): + self.code = code + self.stdout = stdout + self.stderr = stderr + + def __repr__(self) -> str: + return f"" diff --git a/local/utilities/store.py b/local/utilities/store.py new file mode 100644 index 00000000..125e3f7f --- /dev/null +++ b/local/utilities/store.py @@ -0,0 +1,82 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Local store.""" +from typing import Any + + +class Store: + """Store variables into a dictionary-like object. + + When getting items from this store a `Value` is returned + instead. + This Store is used in commands before they are executed in order + to get runtime information from previous commands. + + For example + + local_store = Store() + cmds = [ + StoreStdout(Shell(["ls", "-lah"]), key="files", store=local_store) + Shell(["echo", local_store["files"]]) + ] + for cmd in cmds: + cmd.execute() + # The second command (Shell(["echo"...])) will echo the value stored + # in local_store["files"] which was saved by StoreStdout after .execute() + """ + + def __init__(self): + self.__store = {} + + def __setitem__(self, key: Any, value: Any): + self.__store[key] = value + + def __getitem__(self, key) -> "Value": + return Value(self.__store, key) + + def __repr__(self) -> str: + return str(self.__store) + + +class Value: + """Value variable that is stored in Store. + + Must call `.get()` or, if it's a string, `str(Value)` would + also work in most cases. + """ + + def __init__(self, store: dict[Any, Any], key: Any): + self.__store = store + self.__key = key + + def get(self) -> Any: + return self.__store.get(self.__key) + + def __str__(self) -> str: + return str(self.__store.get(self.__key)) + + def __repr__(self) -> str: + return f"" + + +def get_value(value: Any) -> Any: + if isinstance(value, Value): + return value.get() + return value + + +def get_values(*values: Any) -> list[Any]: + return [get_value(value) for value in values] diff --git a/source/index.rst b/source/index.rst index 78b91f55..79900d0d 100644 --- a/source/index.rst +++ b/source/index.rst @@ -15,6 +15,7 @@ Services SSE Protocol Database + Development Server License Authors diff --git a/source/local.rst b/source/local.rst new file mode 100644 index 00000000..87aaa68e --- /dev/null +++ b/source/local.rst @@ -0,0 +1,77 @@ +.. _local: + +##################### +Local dev environment +##################### + +To deploy a local dev environment you first have to install kind and create a cluster. + +:: + + https://kind.sigs.k8s.io/#installation-and-usage + +When you have kind installed just create a default Kubernetes cluster using it + +:: + + kind create cluster + +Once it is up and running we can simply deploy a local version of ETOS using make. + +:: + + make deploy-local + +Once it is finished you will have a Kubernetes cluster running ETOS locally. We do not support the etosctl just yet, since it requires ingresses to work properly. +Deploying a sample testrun straight into Kubernetes is viable though. There is a sample testrun in config/samples/etos_v1alpha1_testrun.yaml which can be deployed immediately. + +:: + + kubectl create -f config/samples/etos_v1alpha1_testrun.yaml + + +Verify changes +============== + +With a running local deployment of ETOS you might want to test other versions of certain ETOS services. These services has to be built using docker locally and loaded into the kind cluster. + +:: + + kind kind load docker-image + +This docker is now available in the cluster and you can update the services. +For the ETOS controller it is easiest to edit it directly. + +:: + + kubectl patch --namespace etos-system deployment etos-controller-manager --patch '{"spec": {"template": {"spec": {"containers": [{"name": "manager", "image": ""}]}}}}' + +For any service deployed with the Cluster spec (suite-runner, suite-starter, api, sse, log-listener, log-area, test-runner) you'll have to update the cluster. +This can be done either by editing/patching the Cluster/cluster-sample resource in the etos-test namespace, or just edit the file config/samples/etos_v1alpha1_cluster.yaml and apply it. + +:: + + kubectl patch --namespace etos-test cluster cluster-sample --type merge --patch '{"spec": {"etos": {"suiteRunner": {"image": ""}}}}' + kubectl patch --namespace etos-test cluster cluster-sample --type merge --patch '{"spec": {"etos": {"suiteRunner": {"logListener": {"image": ""}}}}}' + kubectl patch --namespace etos-test cluster cluster-sample --type merge --patch '{"spec": {"etos": {"suiteStarter": {"image": ""}}}}' + kubectl patch --namespace etos-test cluster cluster-sample --type merge --patch '{"spec": {"etos": {"api": {"image": ""}}}}' + kubectl patch --namespace etos-test cluster cluster-sample --type merge --patch '{"spec": {"etos": {"sse": {"image": ""}}}}' + kubectl patch --namespace etos-test cluster cluster-sample --type merge --patch '{"spec": {"etos": {"logArea": {"image": ""}}}}' + +If you updated the suite-runner, environment-provider or log-listener then you need to restart the API and Suite Starter + +:: + + kubectl rollout restart deployments cluster-sample-etos-api cluster-sample-etos-suite-starter + +Testing a new version of the ETR can be done by pushing the change to a fork of the repository and setting the DEV flag in your dataset. +In the file config/samples/etos_v1alpha1_testrun.yaml add the following to your suite: + +:: + + dataset: + DEV: true + ETR_BRANCH: + ETR_REPO: + +Your testrun will now run using your version of the ETR.