diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/build.py b/apps/agentstack-cli/src/agentstack_cli/commands/build.py index e1067753aa..85f20315df 100644 --- a/apps/agentstack-cli/src/agentstack_cli/commands/build.py +++ b/apps/agentstack-cli/src/agentstack_cli/commands/build.py @@ -5,7 +5,6 @@ import hashlib import json import re -import sys import typing import uuid from asyncio import CancelledError @@ -131,20 +130,14 @@ async def client_side_build( ) console.success(f"Successfully built agent: {tag}") if import_image: - from agentstack_cli.commands.platform import get_driver + from agentstack_cli.commands.platform import ImageImportMode, import_cmd if "agentstack-registry-svc.default" not in tag: source_tag = tag tag = re.sub("^[^/]*/", "agentstack-registry-svc.default:5001/", tag) await run_command(["docker", "tag", source_tag, tag], "Tagging image") - driver = get_driver(vm_name=vm_name) - - if (await driver.status()) != "running": - console.error("Agent Stack platform is not running.") - sys.exit(1) - - await driver.import_image_to_internal_registry(tag) + await import_cmd(tag, vm_name, mode=ImageImportMode.registry) console.success( "Agent was imported to the agent stack internal registry.\n" + f"You can add it using [blue]agentstack add {tag}[/blue]" diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/platform.py b/apps/agentstack-cli/src/agentstack_cli/commands/platform.py new file mode 100644 index 0000000000..bd5c5ef299 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/commands/platform.py @@ -0,0 +1,895 @@ +# Copyright 2025 © BeeAI a Series of LF Projects, LLC +# SPDX-License-Identifier: Apache-2.0 + +import configparser +import datetime +import functools +import importlib.resources +import json +import os +import pathlib +import platform as platform_module +import shlex +import shutil +import sys +import tempfile +import textwrap +import typing +import uuid +from enum import StrEnum +from subprocess import CompletedProcess +from typing import TypedDict + +import anyio +import httpx +import pydantic +import typer +import yaml +from tenacity import ( + AsyncRetrying, + retry_if_exception_type, + stop_after_delay, + wait_fixed, +) + +from agentstack_cli.async_typer import AsyncTyper +from agentstack_cli.configuration import Configuration +from agentstack_cli.console import console +from agentstack_cli.utils import merge, run_command, verbosity + +app = AsyncTyper() +configuration = Configuration() + + +@functools.cache +def detect_driver() -> typing.Literal["lima", "wsl"]: + has_lima = (importlib.resources.files("agentstack_cli") / "data" / "limactl").is_file() or shutil.which("limactl") + arch = "aarch64" if platform_module.machine().lower() == "arm64" else platform_module.machine().lower() + + if platform_module.system() == "Windows" or shutil.which("wsl.exe"): + return "wsl" + elif has_lima and ( + os.path.exists("/System/Library/Frameworks/Virtualization.framework") or shutil.which(f"qemu-system-{arch}") + ): + return "lima" + else: + console.error("Could not find a compatible VM runtime.") + if platform_module.system() == "Darwin": + console.hint("This version of macOS is unsupported, please update the system.") + elif platform_module.system() == "Linux": + if not has_lima: + console.hint( + "This Linux distribution is not suppored by Lima VM binary releases (required: glibc>=2.34). Manually install Lima VM >=1.2.1 through either:\n" + + " - Your distribution's package manager, if available (https://repology.org/project/lima/versions)\n" + + " - Homebrew, which uses its own separate glibc on Linux (https://brew.sh)\n" + + " - Building it yourself, and ensuring that limactl is in PATH (https://lima-vm.io/docs/installation/source/)" + ) + if not shutil.which(f"qemu-system-{arch}"): + console.hint( + f"QEMU is needed on Linux, please install it and ensure that qemu-system-{arch} is in PATH. Refer to https://www.qemu.org/download/ for instructions." + ) + sys.exit(1) + + +@functools.cache +def detect_export_import_paths() -> tuple[str, str]: + if detect_driver() == "lima": + image_dir = pathlib.Path("/tmp/agentstack") + image_dir.mkdir(exist_ok=True, parents=True) + path = str(image_dir / f"{uuid.uuid4()}.tar") + return (path, path) + fd, tmp_path = tempfile.mkstemp(suffix=".tar") + os.close(fd) + windows_path = str(pathlib.Path(tmp_path).resolve().absolute()) + return (windows_path, f"/mnt/{windows_path[0].lower()}/{windows_path[2:].replace('\\', '/').removeprefix('/')}") + + +@functools.cache +def detect_limactl() -> str: + bundled = importlib.resources.files("agentstack_cli") / "data" / "limactl" + return str(bundled) if bundled.is_file() else str(shutil.which("limactl")) + + +class LimaVMStatus(TypedDict): + name: str + status: str + + +async def detect_vm_status(vm_name: str) -> typing.Literal["running", "stopped", "missing"]: + if detect_driver() == "lima": + result = await run_command( + [detect_limactl(), "--tty=false", "list", "--format=json"], + "Looking for existing Agent Stack platform", + env={"LIMA_HOME": str(Configuration().lima_home)}, + cwd="/", + ) + for line in result.stdout.decode().split("\n"): + if line and (status_data := pydantic.TypeAdapter(LimaVMStatus).validate_json(line)).get("name") == vm_name: + return "running" if status_data["status"].lower() == "running" else "stopped" + else: + for status, cmd in [("running", ["--running"]), ("stopped", [])]: + if ( + vm_name + in ( + await run_command( + ["wsl.exe", "--list", "--quiet", *cmd], + f"Looking for {status} Agent Stack platform", + env={"WSL_UTF8": "1", "WSLENV": os.getenv("WSLENV", "") + ":WSL_UTF8"}, + ) + ) + .stdout.decode() + .splitlines() + ): + return "running" if status == "running" else "stopped" + return "missing" + + +async def run_in_vm( + vm_name: str, + command: list[str], + message: str, + env: dict[str, str] | None = None, + input: bytes | None = None, + check: bool = True, +) -> CompletedProcess[bytes]: + if detect_driver() == "lima": + return await run_command( + [detect_limactl(), "shell", f"--tty={sys.stdin.isatty()}", vm_name, "--", "sudo", *command], + message, + env={"LIMA_HOME": str(Configuration().lima_home)} | (env or {}), + cwd="/", + input=input, + check=check, + ) + return await run_command( + ["wsl.exe", "--user", "root", "--distribution", vm_name, "--", *command], + message, + env={**(env or {}), "WSL_UTF8": "1", "WSLENV": os.getenv("WSLENV", "") + ":WSL_UTF8"}, + input=input, + check=check, + ) + + +async def sync_vm_files(vm_name: str, sub_path: typing.Literal["common", "wsl"] = "common"): + async def _sync(traversable, rel_parts: list[str]): + for entry in traversable.iterdir(): + if entry.is_dir(): + await _sync(entry, [*rel_parts, entry.name]) + else: + dest = "".join(f"/{p}" for p in [*rel_parts, entry.name]) + await run_in_vm( + vm_name, + ["bash", "-c", f"mkdir -p $(dirname {shlex.quote(dest)}) && cat > {shlex.quote(dest)}"], + f"Writing {dest}", + input=entry.read_bytes(), + ) + + await _sync(importlib.resources.files("agentstack_cli") / "data" / "vm" / sub_path, []) + + +# ###### ######## ### ######## ######## +# ## ## ## ## ## ## ## ## +# ## ## ## ## ## ## ## +# ###### ## ## ## ######## ## +# ## ## ######### ## ## ## +# ## ## ## ## ## ## ## ## +# ###### ## ## ## ## ## ## + + +def canonify_image_tag(t: str) -> str: + t = t.strip().strip("'").strip('"').replace(" @", "@") + if "@" in t: + base, digest = t.split("@") + last_colon_idx = base.rfind(":") + last_slash_idx = base.rfind("/") + if last_colon_idx > last_slash_idx: + base = base[:last_colon_idx] + t = f"{base}@{digest}" + return t if "." in t.split("/")[0] else f"docker.io/{t}" + + +async def detect_image_shas( + vm_name: str, + platform: str, + loaded_images: set[str], + *, + mode: typing.Literal["guest", "host"], +) -> dict[str, str]: + return { + canon_tag: sha + for line in ( + ( + await run_command(["docker", "images", "--digests"], "Listing host images") + if mode == "host" + else await run_in_vm( + vm_name, + { + "k3s": ["k3s", "ctr", "image", "ls"], + "microshift": ["crictl", "images"], + }[platform], + "Listing guest images", + ) + ) + .stdout.decode() + .splitlines()[1:] + ) + if (x := line.split()) + and len(x) >= 3 + and (x[1] != "") + and (canon_tag := canonify_image_tag(x[0] if mode == "guest" and platform == "k3s" else f"{x[0]}:{x[1]}")) + in loaded_images + and (sha := x[2]) + } + + +class ImagePullMode(StrEnum): + guest = "guest" + host = "host" + hybrid = "hybrid" + skip = "skip" + + +@app.command("start", help="Start Agent Stack platform. [Local only]") +async def start_cmd( + set_values_list: typing.Annotated[ + list[str], typer.Option("--set", help="Set Helm chart values using = syntax", default_factory=list) + ], + image_pull_mode: typing.Annotated[ + ImagePullMode, + typer.Option( + "--image-pull-mode", + help=textwrap.dedent( + """\ + guest = pull all images inside VM [default] + host = pull unavailable images on host, then import all + hybrid = import available images from host, pull the rest in VM + skip = skip explicit pull step (Kubernetes will attempt to pull missing images) + """ + ), + ), + ] = ImagePullMode.guest, + values_file: typing.Annotated[ + pathlib.Path | None, typer.Option("-f", help="Set Helm chart values using yaml values file") + ] = None, + vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", + verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, + skip_login: typing.Annotated[bool, typer.Option(hidden=True)] = False, + no_wait_for_platform: typing.Annotated[bool, typer.Option(hidden=True)] = False, +): + import agentstack_cli.commands.server + + if values_file and not pathlib.Path(values_file).is_file(): + raise FileNotFoundError(f"Values file {values_file} not found.") + + with verbosity(verbose): + Configuration().home.mkdir(exist_ok=True) + match detect_driver(): + case "lima": + lima_env = {"LIMA_HOME": str(Configuration().lima_home)} + match await detect_vm_status(vm_name): + case "missing": + for legacy in [vm_name, "beeai-platform"]: + await run_command( + [detect_limactl(), "--tty=false", "delete", "--force", legacy], + f"Cleaning up remains of {'previous' if legacy == vm_name else 'legacy'} instance", + env=lima_env, + check=False, + cwd="/", + ) + import psutil + + total_memory_gib = psutil.virtual_memory().total // (1024**3) + if total_memory_gib < 4: + console.error("Not enough memory. Agent Stack platform requires at least 4 GB of RAM.") + sys.exit(1) + if total_memory_gib < 8: + console.warning("Less than 8 GB of RAM detected. Performance may be degraded.") + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete_on_close=False) as f: + f.write( + yaml.dump( + { + "env": {"KUBECONFIG": "/kubeconfig"}, + "images": [ + { + "location": "https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img", + "arch": "x86_64", + }, + { + "location": "https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-arm64.img", + "arch": "aarch64", + }, + ], + "portForwards": [ + { + "guestIP": "127.0.0.1", + "guestPortRange": [1024, 65535], + "hostPortRange": [1024, 65535], + "hostIP": "127.0.0.1", + }, + {"guestIP": "0.0.0.0", "proto": "any", "ignore": True}, + ], + "mounts": [ + { + "location": "/tmp/agentstack", + "mountPoint": "/tmp/agentstack", + "writable": True, + } + ], + "containerd": {"system": False, "user": False}, + "hostResolver": {"hosts": {"host.docker.internal": "host.lima.internal"}}, + "memory": f"{round(min(8.0, max(3.0, total_memory_gib / 2)))}GiB", + } + ) + ) + f.flush() + f.close() + await run_command( + [detect_limactl(), "--tty=false", "start", f.name, f"--name={vm_name}"], + "Creating a Lima VM", + env=lima_env, + cwd="/", + ) + case "stopped": + await run_command( + [detect_limactl(), "--tty=false", "start", vm_name], "Starting up", env=lima_env, cwd="/" + ) + case "running": + console.info("Updating an existing instance.") + case "wsl": + if (await run_command(["wsl.exe", "--status"], "Checking for WSL2", check=False)).returncode != 0: + console.error( + "WSL is not installed. Please follow the Agent Stack installation instructions: https://agentstack.beeai.dev/introduction/quickstart#windows" + ) + console.hint( + "Run [green]wsl.exe --install[/green] as administrator. If you just did this, restart your PC and run the same command again. Full installation may require up to two restarts. WSL is properly set up once you reach a working Linux terminal. You can verify this by running [green]wsl.exe[/green] without arguments." + ) + sys.exit(1) + config_file = ( + pathlib.Path.home() + if platform_module.system() == "Windows" + else pathlib.Path( + ( + await run_command( + ["/bin/sh", "-c", '''wslpath "$(cmd.exe /c 'echo %USERPROFILE%')"'''], + "Detecting home path", + ) + ) + .stdout.decode() + .strip() + ) + ) / ".wslconfig" + config_file.touch() + with config_file.open("r+") as f: + config = configparser.ConfigParser() + f.seek(0) + config.read_file(f) + if not config.has_section("wsl2"): + config.add_section("wsl2") + wsl2_networking_mode = config.get("wsl2", "networkingMode", fallback=None) + if wsl2_networking_mode and wsl2_networking_mode != "nat": + config.set("wsl2", "networkingMode", "nat") + f.seek(0) + f.truncate(0) + config.write(f) + if platform_module.system() == "Linux": + console.warning( + "WSL networking mode updated. Please close WSL, run [green]wsl --shutdown[/green] from PowerShell, re-open WSL and run [green]agentstack platform start[/green] again." + ) + sys.exit(1) + await run_command(["wsl.exe", "--shutdown"], "Updating WSL2 networking") + Configuration().home.mkdir(exist_ok=True) + if await detect_vm_status(vm_name) == "missing": + await run_command( + ["wsl.exe", "--unregister", vm_name], "Cleaning up remains of previous instance", check=False + ) + await run_command( + ["wsl.exe", "--unregister", "beeai-platform"], + "Cleaning up remains of legacy instance", + check=False, + ) + await run_command( + ["wsl.exe", "--install", "--name", vm_name, "--no-launch", "--web-download"], + "Creating a WSL distribution", + ) + await sync_vm_files(vm_name, "wsl") + await run_in_vm( + vm_name, + [ + "bash", + "-c", + "rm /etc/resolv.conf && mv /etc/resolv.conf-override /etc/resolv.conf && chattr +i /etc/resolv.conf", + ], + "Setting up DNS configuration", + check=False, + ) + await run_command(["wsl.exe", "--terminate", vm_name], "Restarting Agent Stack VM") + await run_in_vm(vm_name, ["dbus-launch", "true"], "Ensuring persistence of Agent Stack VM") + await run_in_vm( + vm_name, + [ + "bash", + "-c", + "echo $(ip route show | grep -i default | cut -d' ' -f3) host.docker.internal >> /etc/hosts", + ], + "Setting up internal networking", + ) + + await sync_vm_files(vm_name, "common") + + detected_platform = { + "microshift": typing.cast(typing.Literal["microshift"], "microshift"), + "k3s": typing.cast(typing.Literal["k3s"], "k3s"), + "none": None, + }[ + ( + await run_in_vm( + vm_name, + [ + "bash", + "-c", + "command -v k3s || command -v microshift || echo none", + ], + "Detecting Kubernetes platform", + ) + ) + .stdout.decode() + .strip() + .splitlines()[0] + .split("/")[-1] + ] + + match detected_platform: + case None: + await run_in_vm( + vm_name, + [ + "bash", + "-c", + textwrap.dedent("""\ + sysctl -w net.ipv4.ip_forward=1 + mkdir -p /tmp/microshift-install + curl -fsSL "https://github.com/microshift-io/microshift/releases/download/4.21.0_g29f429c21_4.21.0_okd_scos.ec.15/microshift-debs-$(uname -m).tgz" | tar -xz -C /tmp/microshift-install & + eatmydata apt-get update -y -q + eatmydata apt-get install -y -q --no-install-recommends skopeo cri-o cri-tools containernetworking-plugins kubectl + mkdir -p -m 777 /postgresql-data /seaweedfs-data /registry-data /redis-data + systemctl enable --now crio + wait + eatmydata dpkg -i /tmp/microshift-install/microshift_*.deb /tmp/microshift-install/microshift-kindnet_*.deb + rm -rf /tmp/microshift-install + systemctl enable --now microshift + """), + ], + "Installing MicroShift", + ) + case "k3s": + await run_in_vm( + vm_name, + [ + "bash", + "-c", + "apt-get install -y -q skopeo; systemctl is-active --quiet k3s || systemctl enable --now k3s", + ], + "Refreshing existing k3s VM", + ) + case "microshift": + await run_in_vm( + vm_name, + [ + "bash", + "-c", + "systemctl is-active --quiet crio && systemctl is-active --quiet microshift || systemctl enable --now crio && systemctl enable --now microshift", + ], + "Refreshing existing MicroShift VM", + ) + + platform: typing.Literal["k3s", "microshift"] = detected_platform or "microshift" + await run_in_vm( + vm_name, + [ + "bash", + "-c", + f"ln -sf {'/etc/rancher/k3s/k3s.yaml' if platform == 'k3s' else '/var/lib/microshift/resources/kubeadmin/kubeconfig'} /kubeconfig && chmod 644 /kubeconfig", + ], + "Setting up kubeconfig symlink", + ) + await run_in_vm( + vm_name, + [ + "bash", + "-c", + textwrap.dedent("""\ + command -v helm && exit 0 + case $(uname -m) in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; esac + curl -fsSL "https://get.helm.sh/helm-v3.20.0-linux-${ARCH}.tar.gz" | tar -xzf - --strip-components=1 -C /usr/local/bin "linux-${ARCH}/helm" + chmod +x /usr/local/bin/helm + """), + ], + "Installing Helm", + ) + await run_in_vm( + vm_name, + ["bash", "-c", "cat >/tmp/agentstack-chart.tgz"], + "Preparing Helm chart", + input=(importlib.resources.files("agentstack_cli") / "data" / "helm-chart.tgz").read_bytes(), + ) + await run_in_vm( + vm_name, + ["bash", "-c", "cat >/tmp/agentstack-values.yaml"], + "Preparing Helm values", + input=yaml.dump( + merge( + { + "externalRegistries": {"public_github": str(Configuration().agent_registry)}, + "encryptionKey": "Ovx8qImylfooq4-HNwOzKKDcXLZCB3c_m0JlB9eJBxc=", + "trustProxyHeaders": True, + "localStorage": platform == "microshift", # k3s uses local path provisioner instead + "keycloak": { + "uiClientSecret": "agentstack-ui-secret", + "serverClientSecret": "agentstack-server-secret", + "auth": {"adminPassword": "admin"}, + }, + "features": {"uiLocalSetup": True}, + "providerBuilds": {"enabled": True}, + "localDockerRegistry": {"enabled": True}, + "auth": {"enabled": False}, + }, + yaml.safe_load(pathlib.Path(values_file).read_text()) if values_file else {}, + ) + ).encode("utf-8"), + ) + loaded_images = { + canonify_image_tag(typing.cast(str, yaml.safe_load(line))) + for line in ( + await run_in_vm( + vm_name, + [ + "/bin/bash", + "-c", + "helm template agentstack /tmp/agentstack-chart.tgz --values=/tmp/agentstack-values.yaml " + + " ".join(shlex.quote(f"--set={value}") for value in set_values_list) + + " | sed -n '/^\\s*image:/{ /{{/!{ s/.*image:\\s*//p } }'", + ], + "Listing necessary images", + ) + ) + .stdout.decode() + .splitlines() + } + images_to_import_from_host, shas_guest_before = set[str](), {} + if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}: + shas_guest_before = await detect_image_shas(vm_name, platform, loaded_images, mode="guest") + shas_host = await detect_image_shas(vm_name, platform, loaded_images, mode="host") + if image_pull_mode == ImagePullMode.host: + for image in loaded_images - shas_host.keys(): + await run_command(["docker", "pull", image], f"Pulling image {image} on host") + shas_host = await detect_image_shas(vm_name, platform, loaded_images, mode="host") + images_to_import_from_host = dict(shas_host.items() - shas_guest_before.items()).keys() & loaded_images + if images_to_import_from_host: + host_path, guest_path = detect_export_import_paths() + try: + await run_command( + ["docker", "image", "save", "-o", host_path, *images_to_import_from_host], + f"Exporting image{'' if len(images_to_import_from_host) == 1 else 's'} {', '.join(images_to_import_from_host)} from Docker", + ) + await run_in_vm( + vm_name, + [ + "bash", + "-c", + f"k3s ctr images import {guest_path}" + if platform == "k3s" + else "\n".join( + f"skopeo copy docker-archive:{guest_path}:{img} containers-storage:{img} &" + for img in images_to_import_from_host + ) + + "\nwait", + ], + f"Importing image{'' if len(images_to_import_from_host) == 1 else 's'} {', '.join(images_to_import_from_host)} into Agent Stack platform", + ) + finally: + await anyio.Path(host_path).unlink(missing_ok=True) + if image_pull_mode in {ImagePullMode.guest, ImagePullMode.hybrid}: + for image in loaded_images - images_to_import_from_host: + await run_in_vm( + vm_name, + ["k3s", "ctr", "image", "pull", image] + if platform == "k3s" + else ["skopeo", "copy", f"docker://{image}", f"containers-storage:{image}"], + f"Pulling image {image}", + ) + kubeconfig_local = anyio.Path(Configuration().lima_home) / vm_name / "copied-from-guest" / "kubeconfig.yaml" + await kubeconfig_local.parent.mkdir(parents=True, exist_ok=True) + await kubeconfig_local.write_text( + ( + await run_in_vm( + vm_name, + [ + "timeout", + "5m", + "bash", + "-c", + 'until grep -q "current-context:" /kubeconfig 2>/dev/null; do sleep 5; done && cat /kubeconfig', + ], + "Copying kubeconfig from Agent Stack platform", + ) + ).stdout.decode() + ) + await run_in_vm( + vm_name, + [ + "helm", + "upgrade", + "--install", + "agentstack", + "/tmp/agentstack-chart.tgz", + "--namespace=default", + "--create-namespace", + "--values=/tmp/agentstack-values.yaml", + "--timeout=20m", + "--kubeconfig=/kubeconfig", + *(f"--set={value}" for value in set_values_list), + ], + "Deploying Agent Stack platform with Helm", + ) + if shas_guest_before and ( + replaced_digests := set(shas_guest_before.values()) + - set((await detect_image_shas(vm_name, platform, loaded_images, mode="guest")).values()) + ): + for pod in json.loads( + ( + await run_in_vm( + vm_name, + [ + "kubectl", + "--kubeconfig=/kubeconfig", + "get", + "pods", + "-o", + "json", + "--all-namespaces", + ], + "Getting pods", + ) + ).stdout + ).get("items", []): + if any( + cs.get("imageID", "") in replaced_digests + for cs in pod.get("status", {}).get("containerStatuses", []) + ): + await run_in_vm( + vm_name, + [ + "kubectl", + "--kubeconfig=/kubeconfig", + "delete", + "pod", + pod["metadata"]["name"], + "-n", + pod["metadata"]["namespace"], + ], + f"Removing pod with obsolete image {pod['metadata']['namespace']}/{pod['metadata']['name']}", + ) + if platform == "microshift": + await run_in_vm( + vm_name, + [ + "timeout", + "2m", + "bash", + "-c", + "until kubectl --kubeconfig=/kubeconfig wait --for=condition=Ready pod -n openshift-dns -l dns.operator.openshift.io/daemonset-dns=default --timeout=2m; do sleep 5; done", + ], + "Waiting for DNS to be ready", + ) + await run_in_vm( + vm_name, + ["bash"], + "Forwarding VM services to host", + input=textwrap.dedent("""\ + systemctl daemon-reload + kubectl --kubeconfig=/kubeconfig get svc -n default -o 'jsonpath={range .items[*]}{.metadata.name}{":"}{.spec.ports[*].port}{"\\n"}{end}' | while IFS=: read svc ports; do + for port in $ports; do + if [ "$port" -ge 8333 ] && [ "$port" -le 8399 ]; then + systemctl start "kubectl-port-forward@${svc}:${port}" & + fi + done + done + """) + .strip() + .encode(), + ) + + if not no_wait_for_platform: + with console.status("Waiting for Agent Stack platform to be ready...", spinner="dots"): + async with httpx.AsyncClient() as client: + try: + async for attempt in AsyncRetrying( + stop=stop_after_delay(datetime.timedelta(minutes=20)), + wait=wait_fixed(datetime.timedelta(seconds=1)), + retry=retry_if_exception_type((httpx.HTTPError, ConnectionError)), + reraise=True, + ): + with attempt: + (await client.get("http://localhost:8333/healthcheck")).raise_for_status() + except Exception as ex: + raise ConnectionError( + "Server did not start in 20 minutes. Please check your internet connection." + ) from ex + + console.success("Agent Stack platform started successfully!") + if any("phoenix.enabled=true" in value.lower() for value in set_values_list): + console.print( + textwrap.dedent("""\ + + License Notice: + When you enable Phoenix, be aware that Arize Phoenix is licensed under the Elastic License v2 (ELv2), + which has specific terms regarding commercial use and distribution. By enabling Phoenix, you acknowledge + that you are responsible for ensuring compliance with the ELv2 license terms for your specific use case. + Please review the Phoenix license (https://github.com/Arize-ai/phoenix/blob/main/LICENSE) before enabling + this feature in production environments. + """), + style="dim", + ) + + if not skip_login: + await agentstack_cli.commands.server.server_login("http://localhost:8333") + + +# ###### ######## ####### ######## +# ## ## ## ## ## ## ## +# ## ## ## ## ## ## +# ###### ## ## ## ######## +# ## ## ## ## ## +# ## ## ## ## ## ## +# ###### ## ####### ## + + +@app.command("stop", help="Stop Agent Stack platform. [Local only]") +async def stop_cmd( + vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", + verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, +): + with verbosity(verbose): + if not await detect_vm_status(vm_name): + console.info("Agent Stack platform not found. Nothing to stop.") + return + if detect_driver() == "lima": + await run_command( + [detect_limactl(), "--tty=false", "stop", "--force", vm_name], + "Stopping Agent Stack VM", + env={"LIMA_HOME": str(Configuration().lima_home)}, + cwd="/", + ) + else: + await run_command(["wsl.exe", "--terminate", vm_name], "Stopping Agent Stack VM") + console.success("Agent Stack platform stopped successfully.") + + +# ######## ######## ## ######## ######## ######## +# ## ## ## ## ## ## ## +# ## ## ## ## ## ## ## +# ## ## ###### ## ###### ## ###### +# ## ## ## ## ## ## ## +# ## ## ## ## ## ## ## +# ######## ######## ######## ######## ## ######## + + +@app.command("delete", help="Delete Agent Stack platform. [Local only]") +async def delete_cmd( + vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", + verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, +): + with verbosity(verbose): + if detect_driver() == "lima": + await run_command( + [detect_limactl(), "--tty=false", "delete", "--force", vm_name], + "Deleting Agent Stack platform", + env={"LIMA_HOME": str(Configuration().lima_home)}, + check=False, + cwd="/", + ) + else: + await run_command(["wsl.exe", "--unregister", vm_name], "Deleting Agent Stack platform", check=False) + console.success("Agent Stack platform deleted successfully.") + + +# #### ## ## ######## ####### ######## ######## +# ## ### ### ## ## ## ## ## ## ## +# ## #### #### ## ## ## ## ## ## ## +# ## ## ### ## ######## ## ## ######## ## +# ## ## ## ## ## ## ## ## ## +# ## ## ## ## ## ## ## ## ## +# #### ## ## ## ####### ## ## ## + + +class ImageImportMode(StrEnum): + daemon = "daemon" + registry = "registry" + + +@app.command("import", help="Import a local docker image into the Agent Stack platform. [Local only]") +async def import_cmd( + tag: typing.Annotated[str, typer.Argument(help="Docker image tag to import")], + vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", + verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, + mode: typing.Annotated[ImageImportMode, typer.Option("--mode")] = ImageImportMode.daemon, +): + with verbosity(verbose): + if (await detect_vm_status(vm_name)) != "running": + console.error("Agent Stack platform is not running.") + sys.exit(1) + platform = ( + ( + await run_in_vm( + vm_name, + [ + "bash", + "-c", + "systemctl is-active --quiet k3s && echo k3s || systemctl is-active --quiet microshift && echo microshift || exit 1", + ], + "Detecting Kubernetes platform", + ) + ) + .stdout.decode() + .strip() + ) + host_path, guest_path = detect_export_import_paths() + try: + await run_command(["docker", "image", "save", "-o", host_path, tag], f"Exporting image {tag} from Docker") + await run_in_vm( + vm_name, + [ + "skopeo", + "copy", + f"docker-archive:{guest_path}", + f"docker://localhost:30501/{tag.split('/')[-1]}", + "--dest-tls-verify=false", + ] + if mode == ImageImportMode.registry + else ["k3s", "ctr", "images", "import", guest_path] + if platform == "k3s" + else ["skopeo", "copy", f"docker-archive:{guest_path}:{tag}", f"containers-storage:{tag}"], + f"Importing image {tag} into Agent Stack platform {mode}", + ) + finally: + await anyio.Path(host_path).unlink(missing_ok=True) + + +# ######## ## ## ######## ###### +# ## ## ## ## ## ## +# ## ## ## ## ## +# ###### ### ###### ## +# ## ## ## ## ## +# ## ## ## ## ## ## +# ######## ## ## ######## ###### + + +@app.command("exec", help="For debugging -- execute a command inside the Agent Stack platform VM. [Local only]") +async def exec_cmd( + command: typing.Annotated[list[str] | None, typer.Argument()] = None, + vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", + verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, +): + with verbosity(verbose, show_success_status=False): + if (await detect_vm_status(vm_name)) != "running": + console.error("Agent Stack platform is not running.") + sys.exit(1) + if detect_driver() == "lima": + await anyio.run_process( + [detect_limactl(), "shell", f"--tty={sys.stdin.isatty()}", vm_name, "--", *(command or ["/bin/bash"])], + check=False, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + env={**os.environ, "LIMA_HOME": str(Configuration().lima_home)}, + cwd="/", + ) + else: + await anyio.run_process( + ["wsl.exe", "--user", "root", "--distribution", vm_name, "--", *(command or ["/bin/bash"])], + check=False, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + cwd="/", + ) diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/platform/__init__.py b/apps/agentstack-cli/src/agentstack_cli/commands/platform/__init__.py deleted file mode 100644 index 3b1cdd57eb..0000000000 --- a/apps/agentstack-cli/src/agentstack_cli/commands/platform/__init__.py +++ /dev/null @@ -1,196 +0,0 @@ -# Copyright 2025 © BeeAI a Series of LF Projects, LLC -# SPDX-License-Identifier: Apache-2.0 - -import datetime -import functools -import importlib.resources -import os -import pathlib -import platform -import shutil -import sys -import textwrap -import typing - -import httpx -import typer -from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed - -from agentstack_cli.async_typer import AsyncTyper -from agentstack_cli.commands.platform.base_driver import BaseDriver, ImagePullMode -from agentstack_cli.commands.platform.lima_driver import LimaDriver -from agentstack_cli.commands.platform.wsl_driver import WSLDriver -from agentstack_cli.configuration import Configuration -from agentstack_cli.console import console -from agentstack_cli.utils import verbosity - -app = AsyncTyper() - -configuration = Configuration() - - -@functools.cache -def get_driver(vm_name: str = "agentstack") -> BaseDriver: - has_lima = (importlib.resources.files("agentstack_cli") / "data" / "limactl").is_file() or shutil.which("limactl") - has_vz = os.path.exists("/System/Library/Frameworks/Virtualization.framework") - arch = "aarch64" if platform.machine().lower() == "arm64" else platform.machine().lower() - has_qemu = bool(shutil.which(f"qemu-system-{arch}")) - - if platform.system() == "Windows" or shutil.which("wsl.exe"): - return WSLDriver(vm_name=vm_name) - elif has_lima and (has_vz or has_qemu): - return LimaDriver(vm_name=vm_name) - else: - console.error("Could not find a compatible VM runtime.") - if platform.system() == "Darwin": - console.hint("This version of macOS is unsupported, please update the system.") - elif platform.system() == "Linux": - if not has_lima: - console.hint( - "This Linux distribution is not suppored by Lima VM binary releases (required: glibc>=2.34). Manually install Lima VM >=1.2.1 through either:\n" - + " - Your distribution's package manager, if available (https://repology.org/project/lima/versions)\n" - + " - Homebrew, which uses its own separate glibc on Linux (https://brew.sh)\n" - + " - Building it yourself, and ensuring that limactl is in PATH (https://lima-vm.io/docs/installation/source/)" - ) - if not has_qemu: - console.hint( - f"QEMU is needed on Linux, please install it and ensure that qemu-system-{arch} is in PATH. Refer to https://www.qemu.org/download/ for instructions." - ) - sys.exit(1) - - -@app.command("start", help="Start Agent Stack platform. [Local only]") -async def start( - set_values_list: typing.Annotated[ - list[str], typer.Option("--set", help="Set Helm chart values using = syntax", default_factory=list) - ], - image_pull_mode: typing.Annotated[ - ImagePullMode, - typer.Option( - "--image-pull-mode", - help=textwrap.dedent( - """\ - guest = pull all images inside VM - host = pull unavailable images on host, then import all - hybrid = import available images from host, pull the rest in VM - skip = skip explicit pull step (Kubernetes will attempt to pull missing images) - """ - ), - ), - ] = ImagePullMode.guest, - values_file: typing.Annotated[ - pathlib.Path | None, typer.Option("-f", help="Set Helm chart values using yaml values file") - ] = None, - vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", - verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, - skip_login: typing.Annotated[bool, typer.Option(hidden=True)] = False, - no_wait_for_platform: typing.Annotated[bool, typer.Option(hidden=True)] = False, -): - import agentstack_cli.commands.server - - values_file_path = None - if values_file: - values_file_path = pathlib.Path(values_file) - if not values_file_path.is_file(): - raise FileNotFoundError(f"Values file {values_file} not found.") - - with verbosity(verbose): - driver = get_driver(vm_name=vm_name) - await driver.create_vm() - await driver.install_tools() - await driver.deploy( - set_values_list=set_values_list, - values_file=values_file_path, - image_pull_mode=image_pull_mode, - ) - - if not no_wait_for_platform: - with console.status("Waiting for Agent Stack platform to be ready...", spinner="dots"): - timeout = datetime.timedelta(minutes=20) - async with httpx.AsyncClient() as client: - try: - async for attempt in AsyncRetrying( - stop=stop_after_delay(timeout), - wait=wait_fixed(datetime.timedelta(seconds=1)), - retry=retry_if_exception_type((httpx.HTTPError, ConnectionError)), - reraise=True, - ): - with attempt: - resp = await client.get("http://localhost:8333/healthcheck") - resp.raise_for_status() - except Exception as ex: - raise ConnectionError( - f"Server did not start in {timeout}. Please check your internet connection." - ) from ex - - console.success("Agent Stack platform started successfully!") - - if any("phoenix.enabled=true" in value.lower() for value in set_values_list): - console.print( - textwrap.dedent("""\ - - License Notice: - When you enable Phoenix, be aware that Arize Phoenix is licensed under the Elastic License v2 (ELv2), - which has specific terms regarding commercial use and distribution. By enabling Phoenix, you acknowledge - that you are responsible for ensuring compliance with the ELv2 license terms for your specific use case. - Please review the Phoenix license (https://github.com/Arize-ai/phoenix/blob/main/LICENSE) before enabling - this feature in production environments. - """), - style="dim", - ) - - if not skip_login: - await agentstack_cli.commands.server.server_login("http://localhost:8333") - - -@app.command("stop", help="Stop Agent Stack platform. [Local only]") -async def stop( - vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", - verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, -): - with verbosity(verbose): - driver = get_driver(vm_name=vm_name) - if not await driver.status(): - console.info("Agent Stack platform not found. Nothing to stop.") - return - await driver.stop() - console.success("Agent Stack platform stopped successfully.") - - -@app.command("delete", help="Delete Agent Stack platform. [Local only]") -async def delete( - vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", - verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, -): - with verbosity(verbose): - driver = get_driver(vm_name=vm_name) - await driver.delete() - console.success("Agent Stack platform deleted successfully.") - - -@app.command("import", help="Import a local docker image into the Agent Stack platform. [Local only]") -async def import_image_cmd( - tag: typing.Annotated[str, typer.Argument(help="Docker image tag to import")], - vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", - verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, -): - with verbosity(verbose): - driver = get_driver(vm_name=vm_name) - if (await driver.status()) != "running": - console.error("Agent Stack platform is not running.") - sys.exit(1) - await driver.import_images(tag) - - -@app.command("exec", help="For debugging -- execute a command inside the Agent Stack platform VM. [Local only]") -async def exec_cmd( - command: typing.Annotated[list[str] | None, typer.Argument()] = None, - vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack", - verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False, -): - with verbosity(verbose, show_success_status=False): - driver = get_driver(vm_name=vm_name) - if (await driver.status()) != "running": - console.error("Agent Stack platform is not running.") - sys.exit(1) - await driver.exec(command or ["/bin/bash"]) diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/platform/base_driver.py b/apps/agentstack-cli/src/agentstack_cli/commands/platform/base_driver.py deleted file mode 100644 index 7fbb55e422..0000000000 --- a/apps/agentstack-cli/src/agentstack_cli/commands/platform/base_driver.py +++ /dev/null @@ -1,353 +0,0 @@ -# Copyright 2025 © BeeAI a Series of LF Projects, LLC -# SPDX-License-Identifier: Apache-2.0 - -import abc -import importlib.resources -import json -import pathlib -import shlex -import typing -import uuid -from enum import StrEnum -from subprocess import CompletedProcess -from textwrap import dedent - -import anyio -import yaml -from tenacity import AsyncRetrying, stop_after_attempt - -from agentstack_cli.configuration import Configuration -from agentstack_cli.utils import merge, run_command - - -class ImagePullMode(StrEnum): - guest = "guest" - host = "host" - hybrid = "hybrid" - skip = "skip" - - -class BaseDriver(abc.ABC): - vm_name: str - - def __init__(self, vm_name: str = "agentstack"): - self.vm_name = vm_name - self.loaded_images: set[str] = set() - - @abc.abstractmethod - async def run_in_vm( - self, - command: list[str], - message: str, - env: dict[str, str] | None = None, - input: bytes | None = None, - ) -> CompletedProcess[bytes]: ... - - @abc.abstractmethod - async def status(self) -> typing.Literal["running"] | str | None: ... - - @abc.abstractmethod - async def create_vm(self) -> None: ... - - @abc.abstractmethod - async def stop(self) -> None: ... - - @abc.abstractmethod - async def delete(self) -> None: ... - - @abc.abstractmethod - async def exec(self, command: list[str]) -> None: ... - - @abc.abstractmethod - def _get_export_import_paths(self) -> tuple[str, str]: ... - - async def import_images(self, *tags: str) -> None: - if not tags: - return - - host_path, guest_path = self._get_export_import_paths() - - try: - await run_command( - ["docker", "image", "save", "-o", host_path, *tags], - f"Exporting image{'' if len(tags) == 1 else 's'} {', '.join(tags)} from Docker", - ) - await self.run_in_vm( - ["/bin/sh", "-c", f"k3s ctr images import {guest_path}"], - f"Importing image{'' if len(tags) == 1 else 's'} {', '.join(tags)} into Agent Stack platform", - ) - finally: - await anyio.Path(host_path).unlink(missing_ok=True) - - async def import_image_to_internal_registry(self, tag: str) -> None: - host_path, guest_path = self._get_export_import_paths() - - try: - await run_command( - ["docker", "image", "save", "-o", str(host_path), tag], - f"Exporting image {tag} from Docker", - ) - job_name = f"push-{uuid.uuid4().hex[:6]}" - await self.run_in_vm( - ["k3s", "kubectl", "apply", "-f", "-"], - "Starting push job", - input=yaml.dump( - { - "apiVersion": "batch/v1", - "kind": "Job", - "metadata": {"name": job_name, "namespace": "default"}, - "spec": { - "backoffLimit": 0, - "ttlSecondsAfterFinished": 60, - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "name": "crane", - "image": next( - (image for image in self.loaded_images if "alpine/crane" in image), - "ghcr.io/i-am-bee/alpine/crane:0.20.6", - ), - "command": [ - "crane", - "push", - f"/workspace/{pathlib.Path(host_path).name}", - tag, - "--insecure", - ], - "volumeMounts": [{"name": "workspace", "mountPath": "/workspace"}], - } - ], - "volumes": [ - { - "name": "workspace", - "hostPath": {"path": str(pathlib.PurePosixPath(guest_path).parent)}, - } - ], - } - }, - }, - } - ).encode(), - ) - await self.run_in_vm( - ["k3s", "kubectl", "wait", "--for=condition=complete", f"job/{job_name}", "--timeout=300s"], - "Waiting for push to complete", - ) - finally: - await anyio.Path(host_path).unlink(missing_ok=True) - - def _canonify(self, tag: str) -> str: - return tag if "." in tag.split("/")[0] else f"docker.io/{tag}" - - async def _grab_image_shas( - self, - *, - mode: typing.Literal["guest", "host"], - ) -> dict[str, str]: - return { - tag: sha - for line in ( - await run_command( - ["docker", "images", "--digests"], - "Listing host images", - ) - if mode == "host" - else await self.run_in_vm( - ["k3s", "ctr", "image", "ls"], - "Listing guest images", - ) - ) - .stdout.decode() - .splitlines()[1:] - if (x := line.split()) - and (sha := x[2]) - and ((tag := self._canonify((x[0] + ":" + x[1]) if mode == "host" else x[0])) in self.loaded_images) - } - - async def install_tools(self) -> None: - registry_config = dedent( - """\ - mirrors: - "agentstack-registry-svc.default:5001": - endpoint: - - "http://localhost:30501" - configs: - "agentstack-registry-svc.default:5001": - tls: - insecure_skip_verify: true - """ - ) - - await self.run_in_vm( - [ - "sh", - "-c", - ( - f"sudo mkdir -p /etc/rancher/k3s /registry-data && " - f"echo '{registry_config}' | " - "sudo tee /etc/rancher/k3s/registries.yaml > /dev/null" - ), - ], - "Configuring Kubernetes registry", - ) - - await self.run_in_vm( - [ - "sh", - "-c", - "which k3s || curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644 --https-listen-port=16443", - ], - "Installing Kubernetes", - ) - await self.run_in_vm( - [ - "sh", - "-c", - "which helm || curl -sfL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash", - ], - "Installing Helm", - ) - - async def deploy( - self, - set_values_list: list[str], - values_file: pathlib.Path | None = None, - image_pull_mode: ImagePullMode = ImagePullMode.guest, - ) -> None: - _ = await self.run_in_vm( - ["sh", "-c", "mkdir -p /tmp/agentstack && cat >/tmp/agentstack/chart.tgz"], - "Preparing Helm chart", - input=(importlib.resources.files("agentstack_cli") / "data" / "helm-chart.tgz").read_bytes(), - ) - values = { - **{svc: {"service": {"type": "LoadBalancer"}} for svc in ["collector", "docling", "ui", "phoenix"]}, - "service": {"type": "LoadBalancer"}, - "externalRegistries": {"public_github": str(Configuration().agent_registry)}, - "encryptionKey": "Ovx8qImylfooq4-HNwOzKKDcXLZCB3c_m0JlB9eJBxc=", - "trustProxyHeaders": True, - "keycloak": { - "uiClientSecret": "agentstack-ui-secret", - "serverClientSecret": "agentstack-server-secret", - "service": {"type": "LoadBalancer"}, - "auth": {"adminPassword": "admin"}, - }, - "features": {"uiLocalSetup": True}, - "providerBuilds": {"enabled": True}, - "localDockerRegistry": {"enabled": True}, - "auth": {"enabled": False}, - } - if values_file: - values = merge(values, yaml.safe_load(values_file.read_text())) - await self.run_in_vm( - ["sh", "-c", "cat >/tmp/agentstack/values.yaml"], - "Preparing Helm values", - input=yaml.dump(values).encode("utf-8"), - ) - - self.loaded_images = { - self._canonify(typing.cast(str, yaml.safe_load(line))) - for line in ( - await self.run_in_vm( - [ - "/bin/bash", - "-c", - "helm template agentstack /tmp/agentstack/chart.tgz --values=/tmp/agentstack/values.yaml " - + " ".join(shlex.quote(f"--set={value}") for value in set_values_list) - + " | sed -n '/^\\s*image:/{ /{{/!{ s/.*image:\\s*//p } }'", - ], - "Listing necessary images", - ) - ) - .stdout.decode() - .splitlines() - } - - images_to_import_from_host = set[str]() - shas_guest_before = dict[str, str]() - - if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}: - shas_guest_before = await self._grab_image_shas(mode="guest") - shas_host = await self._grab_image_shas(mode="host") - if image_pull_mode == ImagePullMode.host and (images_to_pull := self.loaded_images - shas_host.keys()): - for image in images_to_pull: - await run_command( - ["docker", "pull", image], - f"Pulling image {image} on host", - ) - shas_host = await self._grab_image_shas(mode="host") - images_to_import_from_host = dict(shas_host.items() - shas_guest_before.items()).keys() & self.loaded_images - await self.import_images(*images_to_import_from_host) - - if image_pull_mode in {ImagePullMode.guest, ImagePullMode.hybrid}: - for image in self.loaded_images - images_to_import_from_host: - async for attempt in AsyncRetrying(stop=stop_after_attempt(5)): - with attempt: - attempt_num = attempt.retry_state.attempt_number - await self.run_in_vm( - ["k3s", "ctr", "image", "pull", image], - f"Pulling image {image}" + (f" (attempt {attempt_num})" if attempt_num > 1 else ""), - ) - - kubeconfig_path = anyio.Path(Configuration().lima_home) / self.vm_name / "copied-from-guest" / "kubeconfig.yaml" - await kubeconfig_path.parent.mkdir(parents=True, exist_ok=True) - await kubeconfig_path.write_text( - ( - await self.run_in_vm( - ["/bin/cat", "/etc/rancher/k3s/k3s.yaml"], - "Copying kubeconfig from Agent Stack platform", - ) - ).stdout.decode() - ) - - await self.run_in_vm( - [ - "helm", - "upgrade", - "--install", - "agentstack", - "/tmp/agentstack/chart.tgz", - "--namespace=default", - "--create-namespace", - "--values=/tmp/agentstack/values.yaml", - "--timeout=20m", - "--wait", - "--kubeconfig=/etc/rancher/k3s/k3s.yaml", - *(f"--set={value}" for value in set_values_list), - ], - "Deploying Agent Stack platform with Helm", - ) - - if shas_guest_before and ( - replaced_digests := set(shas_guest_before.values()) - - set((await self._grab_image_shas(mode="guest")).values()) - ): - for pod in dict.get( - json.loads( - ( - await self.run_in_vm( - ["k3s", "kubectl", "get", "pods", "-o", "json", "--all-namespaces"], - "Getting pods", - ) - ).stdout - ), - "items", - [], - ): - if any( - container_status.get("imageID", "") in replaced_digests - for container_status in pod.get("status", {}).get("containerStatuses", []) - ): - await self.run_in_vm( - [ - "k3s", - "kubectl", - "delete", - "pod", - pod["metadata"]["name"], - "-n", - pod["metadata"]["namespace"], - ], - f"Removing pod with obsolete image {pod['metadata']['namespace']}/{pod['metadata']['name']}", - ) diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/platform/lima_driver.py b/apps/agentstack-cli/src/agentstack_cli/commands/platform/lima_driver.py deleted file mode 100644 index c05ebace86..0000000000 --- a/apps/agentstack-cli/src/agentstack_cli/commands/platform/lima_driver.py +++ /dev/null @@ -1,204 +0,0 @@ -# Copyright 2025 © BeeAI a Series of LF Projects, LLC -# SPDX-License-Identifier: Apache-2.0 - -import importlib.resources -import os -import pathlib -import shutil -import sys -import tempfile -import typing -import uuid -from subprocess import CompletedProcess -from typing import TypedDict - -import anyio -import psutil -import pydantic -import yaml - -from agentstack_cli.commands.platform.base_driver import BaseDriver -from agentstack_cli.configuration import Configuration -from agentstack_cli.console import console -from agentstack_cli.utils import run_command - - -class LimaDriver(BaseDriver): - limactl_exe: str - - def __init__(self, vm_name: str = "agentstack"): - super().__init__(vm_name) - bundled_limactl_exe = importlib.resources.files("agentstack_cli") / "data" / "limactl" - if bundled_limactl_exe.is_file(): - self.limactl_exe = str(bundled_limactl_exe) - else: - self.limactl_exe = str(shutil.which("limactl")) - console.warning(f"Using external Lima from {self.limactl_exe}") - - @typing.override - async def run_in_vm( - self, - command: list[str], - message: str, - env: dict[str, str] | None = None, - input: bytes | None = None, - ) -> CompletedProcess[bytes]: - return await run_command( - [self.limactl_exe, "shell", f"--tty={sys.stdin.isatty()}", self.vm_name, "--", "sudo", *command], - message, - env={"LIMA_HOME": str(Configuration().lima_home)} | (env or {}), - cwd="/", - input=input, - ) - - @typing.override - async def status(self) -> typing.Literal["running"] | str | None: - try: - result = await run_command( - [self.limactl_exe, "--tty=false", "list", "--format=json"], - "Looking for existing Agent Stack platform in Lima", - env={"LIMA_HOME": str(Configuration().lima_home)}, - cwd="/", - ) - - for line in result.stdout.decode().split("\n"): - if not line: - continue - - class Status(TypedDict): - name: str - status: str - - status = pydantic.TypeAdapter(Status).validate_json(line) - if status["name"] == self.vm_name: - return status["status"].lower() - return None - except Exception: - return None - - @typing.override - async def create_vm(self): - Configuration().home.mkdir(exist_ok=True) - current_status = await self.status() - - if not current_status: - await run_command( - [self.limactl_exe, "--tty=false", "delete", "--force", self.vm_name], - "Cleaning up remains of previous instance", - env={"LIMA_HOME": str(Configuration().lima_home)}, - check=False, - cwd="/", - ) - - await run_command( - [self.limactl_exe, "--tty=false", "delete", "--force", "beeai-platform"], - "Cleaning up remains of legacy instance", - env={"LIMA_HOME": str(Configuration().lima_home)}, - check=False, - cwd="/", - ) - - total_memory_gib = typing.cast(int, psutil.virtual_memory().total / (1024**3)) - - if total_memory_gib < 4: - console.error("Not enough memory. Agent Stack platform requires at least 4 GB of RAM.") - sys.exit(1) - - if total_memory_gib < 8: - console.warning("Less than 8 GB of RAM detected. Performance may be degraded.") - - vm_memory_gib = round(min(8.0, max(3.0, total_memory_gib / 2))) - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete_on_close=False) as template_file: - template_file.write( - yaml.dump( - { - "images": [ - { - "location": "https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img", - "arch": "x86_64", - }, - { - "location": "https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-arm64.img", - "arch": "aarch64", - }, - ], - "portForwards": [ - { - "guestIP": "127.0.0.1", - "guestPortRange": [1024, 65535], - "hostPortRange": [1024, 65535], - "hostIP": "127.0.0.1", - }, - {"guestIP": "0.0.0.0", "proto": "any", "ignore": True}, - ], - "mounts": [ - {"location": "/tmp/agentstack", "mountPoint": "/tmp/agentstack", "writable": True} - ], - "containerd": {"system": False, "user": False}, - "hostResolver": {"hosts": {"host.docker.internal": "host.lima.internal"}}, - "memory": f"{vm_memory_gib}GiB", - } - ) - ) - template_file.flush() - template_file.close() - await run_command( - [ - self.limactl_exe, - "--tty=false", - "start", - str(template_file.name), - f"--name={self.vm_name}", - ], - "Creating a Lima VM", - env={"LIMA_HOME": str(Configuration().lima_home)}, - cwd="/", - ) - elif current_status != "running": - await run_command( - [self.limactl_exe, "--tty=false", "start", self.vm_name], - "Starting up", - env={"LIMA_HOME": str(Configuration().lima_home)}, - cwd="/", - ) - else: - console.info("Updating an existing instance.") - - @typing.override - async def stop(self): - await run_command( - [self.limactl_exe, "--tty=false", "stop", "--force", self.vm_name], - "Stopping Agent Stack VM", - env={"LIMA_HOME": str(Configuration().lima_home)}, - cwd="/", - ) - - @typing.override - async def delete(self): - await run_command( - [self.limactl_exe, "--tty=false", "delete", "--force", self.vm_name], - "Deleting Agent Stack platform", - env={"LIMA_HOME": str(Configuration().lima_home)}, - check=False, - cwd="/", - ) - - @typing.override - def _get_export_import_paths(self) -> tuple[str, str]: - image_dir = pathlib.Path("/tmp/agentstack") - image_dir.mkdir(exist_ok=True, parents=True) - image_path = str(image_dir / f"{uuid.uuid4()}.tar") - return (image_path, image_path) - - @typing.override - async def exec(self, command: list[str]): - await anyio.run_process( - [self.limactl_exe, "shell", f"--tty={sys.stdin.isatty()}", self.vm_name, "--", *command], - check=False, - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - env={**os.environ, "LIMA_HOME": str(Configuration().lima_home)}, - cwd="/", - ) diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/platform/wsl_driver.py b/apps/agentstack-cli/src/agentstack_cli/commands/platform/wsl_driver.py deleted file mode 100644 index faae603a93..0000000000 --- a/apps/agentstack-cli/src/agentstack_cli/commands/platform/wsl_driver.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright 2025 © BeeAI a Series of LF Projects, LLC -# SPDX-License-Identifier: Apache-2.0 - -import configparser -import os -import pathlib -import platform -import sys -import tempfile -import textwrap -import typing - -import anyio -import pydantic -import yaml - -from agentstack_cli.commands.platform.base_driver import BaseDriver, ImagePullMode -from agentstack_cli.configuration import Configuration -from agentstack_cli.console import console -from agentstack_cli.utils import run_command - - -class WSLDriver(BaseDriver): - @typing.override - async def run_in_vm( - self, - command: list[str], - message: str, - env: dict[str, str] | None = None, - input: bytes | None = None, - check: bool = True, - ): - return await run_command( - ["wsl.exe", "--user", "root", "--distribution", self.vm_name, "--", *command], - message, - env={**(env or {}), "WSL_UTF8": "1", "WSLENV": os.getenv("WSLENV", "") + ":WSL_UTF8"}, - input=input, - check=check, - ) - - @typing.override - async def status(self) -> typing.Literal["running"] | str | None: - try: - for status, cmd in [("running", ["--running"]), ("stopped", [])]: - result = await run_command( - ["wsl.exe", "--list", "--quiet", *cmd], - f"Looking for {status} Agent Stack platform in WSL", - env={"WSL_UTF8": "1", "WSLENV": os.getenv("WSLENV", "") + ":WSL_UTF8"}, - ) - if self.vm_name in result.stdout.decode().splitlines(): - return status - return None - except Exception: - return None - - @typing.override - async def create_vm(self): - if (await run_command(["wsl.exe", "--status"], "Checking for WSL2", check=False)).returncode != 0: - console.error( - "WSL is not installed. Please follow the Agent Stack installation instructions: https://agentstack.beeai.dev/introduction/quickstart#windows" - ) - console.hint( - "Run [green]wsl.exe --install[/green] as administrator. If you just did this, restart your PC and run the same command again. Full installation may require up to two restarts. WSL is properly set up once you reach a working Linux terminal. You can verify this by running [green]wsl.exe[/green] without arguments." - ) - sys.exit(1) - - config_file = ( - pathlib.Path.home() - if platform.system() == "Windows" - else pathlib.Path( - ( - await run_command( - ["/bin/sh", "-c", '''wslpath "$(cmd.exe /c 'echo %USERPROFILE%')"'''], "Detecting home path" - ) - ) - .stdout.decode() - .strip() - ) - ) / ".wslconfig" - config_file.touch() - with config_file.open("r+") as f: - config = configparser.ConfigParser() - f.seek(0) - config.read_file(f) - - if not config.has_section("wsl2"): - config.add_section("wsl2") - - wsl2_networking_mode = config.get("wsl2", "networkingMode", fallback=None) - if wsl2_networking_mode and wsl2_networking_mode != "nat": - config.set("wsl2", "networkingMode", "nat") - f.seek(0) - f.truncate(0) - config.write(f) - - if platform.system() == "Linux": - console.warning( - "WSL networking mode updated. Please close WSL, run [green]wsl --shutdown[/green] from PowerShell, re-open WSL and run [green]agentstack platform start[/green] again." - ) - sys.exit(1) - await run_command(["wsl.exe", "--shutdown"], "Updating WSL2 networking") - - Configuration().home.mkdir(exist_ok=True) - if not await self.status(): - await run_command( - ["wsl.exe", "--unregister", self.vm_name], "Cleaning up remains of previous instance", check=False - ) - await run_command( - ["wsl.exe", "--unregister", "beeai-platform"], "Cleaning up remains of legacy instance", check=False - ) - await run_command( - ["wsl.exe", "--install", "--name", self.vm_name, "--no-launch", "--web-download"], - "Creating a WSL distribution", - ) - - await self.run_in_vm( - [ - "sh", - "-c", - "echo '[network]\ngenerateResolvConf = false\n[boot]\nsystemd=true\n' >/etc/wsl.conf && rm /etc/resolv.conf && echo 'nameserver 1.1.1.1\n' >/etc/resolv.conf && chattr +i /etc/resolv.conf", - ], - "Setting up DNS configuration", - check=False, - ) - - await run_command(["wsl.exe", "--terminate", self.vm_name], "Restarting Agent Stack VM") - await self.run_in_vm(["dbus-launch", "true"], "Ensuring persistence of Agent Stack VM") - - @typing.override - async def deploy( - self, - set_values_list: list[str], - values_file: pathlib.Path | None = None, - image_pull_mode: ImagePullMode = ImagePullMode.guest, - ) -> None: - host_ip = ( - ( - await self.run_in_vm( - ["bash", "-c", "ip route show | grep -i default | cut -d' ' -f3"], - "Detecting host IP address", - ) - ) - .stdout.decode() - .strip() - ) - await self.run_in_vm( - ["k3s", "kubectl", "apply", "-f", "-"], - "Setting up internal networking", - input=yaml.dump( - { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": {"name": "coredns-custom", "namespace": "kube-system"}, - "data": { - "default.server": f"host.docker.internal {{\n hosts {{\n {host_ip} host.docker.internal\n fallthrough\n }}\n}}" - }, - } - ).encode(), - ) - await super().deploy(set_values_list=set_values_list, values_file=values_file, image_pull_mode=image_pull_mode) - await self.run_in_vm( - ["sh", "-c", "cat >/etc/systemd/system/kubectl-port-forward@.service"], - "Installing systemd unit for port-forwarding", - input=textwrap.dedent("""\ - [Unit] - Description=Kubectl Port Forward for service %%i - After=network.target - - [Service] - Type=simple - ExecStart=/bin/bash -c 'IFS=":" read svc port <<< "%i"; exec /usr/local/bin/k3s kubectl port-forward --kubeconfig=/etc/rancher/k3s/k3s.yaml --address=127.0.0.1 svc/$svc $port:$port' - Restart=on-failure - User=root - - [Install] - WantedBy=multi-user.target - """).encode(), - ) - await self.run_in_vm(["systemctl", "daemon-reexec"], "Reloading systemd") - services_json = ( - await self.run_in_vm( - ["k3s", "kubectl", "get", "svc", "--field-selector=spec.type=LoadBalancer", "--output=json"], - "Detecting ports to forward", - ) - ).stdout - ServicePort = typing.TypedDict("ServicePort", {"port": int, "name": str}) - ServiceSpec = typing.TypedDict("ServiceSpec", {"ports": list[ServicePort]}) - ServiceMetadata = typing.TypedDict("ServiceMetadata", {"name": str, "namespace": str}) - Service = typing.TypedDict("Service", {"metadata": ServiceMetadata, "spec": ServiceSpec}) - Services = typing.TypedDict("Services", {"items": list[Service]}) - for service in pydantic.TypeAdapter(Services).validate_json(services_json)["items"]: - name = service["metadata"]["name"] - for port_item in service["spec"]["ports"]: - port = port_item["port"] - await self.run_in_vm( - ["systemctl", "enable", "--now", f"kubectl-port-forward@{name}:{port}.service"], - f"Starting port-forward for {name}:{port}", - ) - - @typing.override - async def stop(self): - await run_command(["wsl.exe", "--terminate", self.vm_name], "Stopping Agent Stack VM") - - @typing.override - async def delete(self): - await run_command(["wsl.exe", "--unregister", self.vm_name], "Deleting Agent Stack platform", check=False) - - @typing.override - async def exec(self, command: list[str]): - await anyio.run_process( - ["wsl.exe", "--user", "root", "--distribution", self.vm_name, "--", *command], - check=False, - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - cwd="/", - ) - - @typing.override - def _get_export_import_paths(self) -> tuple[str, str]: - fd, tmp_path = tempfile.mkstemp(suffix=".tar") - os.close(fd) - windows_path = str(pathlib.Path(tmp_path).resolve().absolute()) - wsl_path = f"/mnt/{windows_path[0].lower()}/{windows_path[2:].replace('\\', '/').removeprefix('/')}" - return (windows_path, wsl_path) diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/self.py b/apps/agentstack-cli/src/agentstack_cli/commands/self.py index bece403331..1a975f0e0f 100644 --- a/apps/agentstack-cli/src/agentstack_cli/commands/self.py +++ b/apps/agentstack-cli/src/agentstack_cli/commands/self.py @@ -130,7 +130,7 @@ async def install( ).execute_async() ): try: - await agentstack_cli.commands.platform.start(set_values_list=[], verbose=verbose) + await agentstack_cli.commands.platform.start_cmd(set_values_list=[], verbose=verbose) already_started = True console.print() except Exception: @@ -190,7 +190,7 @@ async def upgrade( "Upgrading agentstack-cli", env={"PATH": _path()}, ) - await agentstack_cli.commands.platform.start(set_values_list=[], verbose=verbose) + await agentstack_cli.commands.platform.start_cmd(set_values_list=[], verbose=verbose) await version(verbose=verbose) @@ -204,7 +204,7 @@ async def uninstall( raise typer.Exit(1) with verbosity(verbose=verbose): - await agentstack_cli.commands.platform.delete(verbose=verbose) + await agentstack_cli.commands.platform.delete_cmd(verbose=verbose) await run_command( ["uv", "tool", "uninstall", "agentstack-cli"], "Uninstalling agentstack-cli", diff --git a/apps/agentstack-cli/src/agentstack_cli/data/.gitignore b/apps/agentstack-cli/src/agentstack_cli/data/.gitignore index d29675e531..1c8a0f3ab1 100644 --- a/apps/agentstack-cli/src/agentstack_cli/data/.gitignore +++ b/apps/agentstack-cli/src/agentstack_cli/data/.gitignore @@ -1,2 +1 @@ -** -!.gitignore \ No newline at end of file +helm-chart.tgz \ No newline at end of file diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/apt/sources.list.d/cri-o.list b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/apt/sources.list.d/cri-o.list new file mode 100644 index 0000000000..5f8587c423 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/apt/sources.list.d/cri-o.list @@ -0,0 +1 @@ +deb [trusted=yes] https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/deb/ / diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/apt/sources.list.d/kubernetes.list b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/apt/sources.list.d/kubernetes.list new file mode 100644 index 0000000000..6f64eb2b31 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/apt/sources.list.d/kubernetes.list @@ -0,0 +1 @@ +deb [trusted=yes] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ / diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/containers/registries.conf.d/200-microshift-local.conf b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/containers/registries.conf.d/200-microshift-local.conf new file mode 100644 index 0000000000..94b79b327a --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/containers/registries.conf.d/200-microshift-local.conf @@ -0,0 +1,6 @@ +[[registry]] +location = "agentstack-registry-svc.default:5001" +insecure = true +[[registry.mirror]] +location = "localhost:30501" +insecure = true diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/crio/crio.conf.d/14-microshift-cni.conf b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/crio/crio.conf.d/14-microshift-cni.conf new file mode 100644 index 0000000000..954a9ef189 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/crio/crio.conf.d/14-microshift-cni.conf @@ -0,0 +1,2 @@ +[crio.network] +plugin_dirs = ["/usr/lib/cni"] diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/microshift/config.yaml b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/microshift/config.yaml new file mode 100644 index 0000000000..0b96d73624 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/microshift/config.yaml @@ -0,0 +1,11 @@ +apiServer: + port: 16443 +ingress: + status: Removed +storage: + driver: none +dns: + hosts: + status: Enabled +telemetry: + status: Disabled diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/systemd/system/kubectl-port-forward@.service b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/systemd/system/kubectl-port-forward@.service new file mode 100644 index 0000000000..f6d9a16f46 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/common/etc/systemd/system/kubectl-port-forward@.service @@ -0,0 +1,4 @@ +[Service] +ExecStart=/bin/bash -c 'IFS=":" read svc port <<< "%i" && kubectl wait --kubeconfig=/kubeconfig --for=jsonpath={.subsets[*].addresses[0].ip} ep/$svc --timeout=300s && exec kubectl port-forward --kubeconfig=/kubeconfig --address=127.0.0.1 svc/$svc $port:$port' +Restart=on-failure +User=root diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/wsl/etc/resolv.conf-override b/apps/agentstack-cli/src/agentstack_cli/data/vm/wsl/etc/resolv.conf-override new file mode 100644 index 0000000000..8dda3fc099 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/wsl/etc/resolv.conf-override @@ -0,0 +1 @@ +nameserver 1.1.1.1 diff --git a/apps/agentstack-cli/src/agentstack_cli/data/vm/wsl/etc/wsl.conf b/apps/agentstack-cli/src/agentstack_cli/data/vm/wsl/etc/wsl.conf new file mode 100644 index 0000000000..09328f1d65 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/data/vm/wsl/etc/wsl.conf @@ -0,0 +1,4 @@ +[network] +generateResolvConf = false +[boot] +systemd=true diff --git a/apps/agentstack-cli/src/agentstack_cli/utils.py b/apps/agentstack-cli/src/agentstack_cli/utils.py index 71ed494b39..72e8f28a56 100644 --- a/apps/agentstack-cli/src/agentstack_cli/utils.py +++ b/apps/agentstack-cli/src/agentstack_cli/utils.py @@ -8,6 +8,7 @@ import re import subprocess import sys +import time from collections import Counter from collections.abc import AsyncIterator, Mapping, MutableMapping from contextlib import asynccontextmanager @@ -190,6 +191,7 @@ async def run_command( try: with status(message): err_console.print(f"Command: {command}", style="dim") + start_time = time.time() # Track start time async with await anyio.open_process( command, stdin=subprocess.PIPE if input else None, env={**os.environ, **env}, cwd=cwd ) as process: @@ -204,8 +206,16 @@ async def run_command( if check and process.returncode != 0: raise subprocess.CalledProcessError(process.returncode or 0, command, output, errors) + total_seconds = int(time.time() - start_time) + if total_seconds < 5: + duration_str = "" + elif total_seconds < 60: + duration_str = f"({total_seconds}s)" + else: + duration_str = f"({total_seconds // 60}m{total_seconds % 60}s)" + if SHOW_SUCCESS_STATUS.get(): - console.print(f"{message} [[green]DONE[/green]]") + console.print(f"{message} [[green]DONE[/green]] [dim]{duration_str}[/dim]") return subprocess.CompletedProcess(command, process.returncode or 0, output, errors) except FileNotFoundError: console.print(f"{message} [[red]ERROR[/red]]") diff --git a/apps/agentstack-server/tasks.toml b/apps/agentstack-server/tasks.toml index 8ad0da77f4..596295b3b4 100644 --- a/apps/agentstack-server/tasks.toml +++ b/apps/agentstack-server/tasks.toml @@ -216,8 +216,29 @@ NAMESPACE=default VM_NAME="${usage_vm_name?}" eval "$( {{ mise_bin }} run agentstack:shell --vm-name="$VM_NAME" )" +# MicroShift/OpenShift specific setup for Telepresence +kubectl create namespace ambassador --dry-run=client -o yaml | kubectl apply -f - +kubectl label namespace ambassador pod-security.kubernetes.io/enforce=privileged --overwrite +kubectl label namespace ambassador pod-security.kubernetes.io/warn=privileged --overwrite +kubectl label namespace ambassador pod-security.kubernetes.io/audit=privileged --overwrite + +# Create telepresence values file to handle MicroShift restricted SCC +TP_VALUES=$(mktemp) +cat > "$TP_VALUES" <" ''' run = """ #!/bin/bash +set -euxo pipefail + VM_NAME=e2e-test-run export AGENTSTACK__USERNAME=admin @@ -268,7 +291,7 @@ export AGENTSTACK__CLIENT_ID=agentstack-cli NO_CLEAN="${usage_no_clean:-false}" if [ "$NO_CLEAN" != "true" ]; then {{ mise_bin }} run agentstack:stop-all - {{ mise_bin }} run agentstack:delete --vm-name=${VM_NAME} + {{ mise_bin }} run agentstack:delete --vm-name=${VM_NAME} || true curl http://localhost:8333 >/dev/null 2>&1 && echo "Another instance at localhost:8333 is already running" && exit 2 fi @@ -300,14 +323,12 @@ keycloak: roles: ["agentstack-admin"] ' > "$CONFIG_FILE" -{{ mise_bin }} run agentstack:start --vm-name=${VM_NAME} --skip-login -f "$CONFIG_FILE" --set ui.enabled=false - +{{ mise_bin }} run agentstack:start -v --vm-name=${VM_NAME} --skip-login -f "$CONFIG_FILE" --set ui.enabled=false eval "$( {{ mise_bin }} run agentstack:shell --vm-name="$VM_NAME" )" -if [ -z "${TEST_AGENT_IMAGE}" ]; then +if [ -z "${TEST_AGENT_IMAGE:-}" ]; then echo "Building test agent..." - # Build chat agent and push to local registry TEST_AGENT_IMAGE=agentstack-registry-svc.default:5001/chat-test:latest {{ mise_bin }} run agentstack-cli:run -- client-side-build -v "${PWD}/../.." --vm-name=${VM_NAME} --dockerfile "${PWD}/../../agents/chat/Dockerfile" --tag ${TEST_AGENT_IMAGE} fi @@ -434,6 +455,7 @@ exit $result dir = "{{config_root}}/apps/agentstack-server" run = """ #!/bin/bash +set -euxo pipefail VM_NAME=integration-test-run {{ mise_bin }} run agentstack:delete --vm-name="$VM_NAME" @@ -449,6 +471,4 @@ eval "$( {{ mise_bin }} run agentstack:shell --vm-name="$VM_NAME" )" {{ mise_bin }} run agentstack-server:dev:connect --vm-name="$VM_NAME" uv run pytest -m integration -result=$? -exit $result """ diff --git a/apps/agentstack-ui/tasks.toml b/apps/agentstack-ui/tasks.toml index 5109ca8415..2ac0bdd575 100644 --- a/apps/agentstack-ui/tasks.toml +++ b/apps/agentstack-ui/tasks.toml @@ -100,15 +100,13 @@ depends = ["common:setup:pnpm", "agentstack-sdk-ts:build"] dir = "{{config_root}}/apps/agentstack-ui" env.NODE_OPTIONS = "--no-experimental-global-navigator" run = "pnpm next build --webpack" -# TODO: This breaks mise (rust sources file not found error) -# sources = [ -# "*.json", -# "*.js", -# "*.mjs", -# "*.ts", -# "public/**/*", -# "src/**/*", -# ] +sources = [ + "package.json", + "next.config.ts", + "tsconfig.json", + "public/**/*", + "src/**/*", +] outputs = [".next/standalone/**/*"] # schema diff --git a/helm/templates/local-storage-pvs.yaml b/helm/templates/local-storage-pvs.yaml new file mode 100644 index 0000000000..645f0123f6 --- /dev/null +++ b/helm/templates/local-storage-pvs.yaml @@ -0,0 +1,70 @@ +{{- if .Values.localStorage }} +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ .Release.Name }}-postgresql-pv + labels: + app.kubernetes.io/name: {{ include "agentstack.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: postgresql-storage +spec: + capacity: + storage: {{ .Values.postgresql.primary.persistence.size | default "8Gi" }} + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + hostPath: + path: /postgresql-data + type: DirectoryOrCreate + claimRef: + namespace: {{ .Release.Namespace }} + name: data-postgresql-0 + +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ .Release.Name }}-seaweedfs-pv + labels: + app.kubernetes.io/name: {{ include "agentstack.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: seaweedfs-storage +spec: + capacity: + storage: {{ .Values.seaweedfs.allInOne.data.size | default "20Gi" }} + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + hostPath: + path: /seaweedfs-data + type: DirectoryOrCreate + claimRef: + namespace: {{ .Release.Namespace }} + name: seaweedfs-all-in-one-data + +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ .Release.Name }}-redis-pv + labels: + app.kubernetes.io/name: {{ include "agentstack.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: redis-storage +spec: + capacity: + storage: {{ .Values.redis.master.persistence.size | default "8Gi" }} + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + hostPath: + path: /redis-data + type: DirectoryOrCreate + claimRef: + namespace: {{ .Release.Namespace }} + name: data-redis-0 +{{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 8b153fb31a..439e19af49 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -7,6 +7,12 @@ # If false, resources will be created as standard Kubernetes resources useHelmHooks: true +# Local storage configuration for single-node development environments +# When true, creates hostPath-backed PersistentVolumes for PostgreSQL and SeaweedFS +# instead of relying on dynamic provisioning (StorageClass/CSI drivers) +# WARNING: Only use for local development. Data is stored on the node's filesystem. +localStorage: false + features: # UI only features uiLocalSetup: false @@ -745,6 +751,9 @@ redis: password: "redis-password" existingSecret: "" existingSecretPasswordKey: "" + containerSecurityContext: + runAsNonRoot: false + podSecurityContext: {} master: persistence: enabled: false diff --git a/tasks.toml b/tasks.toml index 9549be780a..fccb0181ed 100644 --- a/tasks.toml +++ b/tasks.toml @@ -119,7 +119,7 @@ else PULL_MODE="host" fi -{{ mise_bin }} run agentstack-cli:run -- platform start \ +{{ mise_bin }} run agentstack-cli:run -- platform start -v \ --image-pull-mode="$PULL_MODE" \ --set auth.nextauthDevUrl="http://localhost:3000" \ --set image.tag=local \ @@ -157,12 +157,12 @@ usage = 'flag "--vm-name " default="agentstack"' run = """ cat <