Skip to content
4 changes: 2 additions & 2 deletions local-k8s/src/local_k8s/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

import click

from local_k8s.groups.chart import chart
from local_k8s.groups.cluster import cluster
from local_k8s.groups.ct import ct
from local_k8s.groups.tools import tools
from local_k8s.shared import get_bin_dir

Expand Down Expand Up @@ -39,4 +39,4 @@ def configure_logging(verbose: int) -> None:

cli.add_command(cluster)
cli.add_command(tools)
cli.add_command(ct)
cli.add_command(chart)
26 changes: 14 additions & 12 deletions local-k8s/src/local_k8s/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from tempfile import TemporaryDirectory

from local_k8s.models import ClusterComponents
from local_k8s.shared import execute
from local_k8s.shared import CommandResult, execute

CLUSTER_NAME = "test-cluster"

Expand Down Expand Up @@ -40,7 +40,9 @@ def create_cluster(kind_config: Path, components: ClusterComponents) -> None:

def _create_kind_cluster(kind_config: Path) -> None:
LOG.info("Creating kind cluster")
for line in kind("get", "clusters", "--quiet").splitlines():
for line in kind(
"get", "clusters", "--quiet", capture_stdout=True
).stdout.splitlines():
if line == CLUSTER_NAME:
LOG.info("Reusing existing cluster: %s", CLUSTER_NAME)
break
Expand All @@ -66,8 +68,8 @@ def _create_kind_cluster(kind_config: Path) -> None:
def _add_helm_repos(components: ClusterComponents) -> None:
if components.helm_repos:
LOG.info("Adding helm repos")
repo_out = helm("repo", "list", "--no-headers")
existing_repos = [tuple(s.split()) for s in repo_out.splitlines()]
repo_out = helm("repo", "list", "--no-headers", capture_stdout=True)
existing_repos = [tuple(s.split()) for s in repo_out.stdout.splitlines()]
for name, repo in components.helm_repos.items():
if (name, repo) in existing_repos:
LOG.info("Not adding %s -> %s as already present", name, repo)
Expand All @@ -77,8 +79,8 @@ def _add_helm_repos(components: ClusterComponents) -> None:


def _install_helm_components(components: ClusterComponents) -> None:
list_out = helm("list", "--all-namespaces", "--deployed", "-q")
installed = list_out.splitlines()
list_out = helm("list", "--all-namespaces", "--deployed", "-q", capture_stdout=True)
installed = list_out.stdout.splitlines()
LOG.info("Installing cluster components")
for desired in components.cluster_components:
if desired.name in installed:
Expand Down Expand Up @@ -140,15 +142,15 @@ def dump_to_stdout(filter_namespaces: list[str], out_dir: Path) -> None:
print(log_file.read_text())


def kind(*args: str) -> str:
return execute("kind", *args)
def kind(*args: str, capture_stdout: bool = False) -> CommandResult:
return execute("kind", *args, capture_stdout=capture_stdout)


def helm(*args: str) -> str:
def helm(*args: str, capture_stdout: bool = False) -> CommandResult:
with kube_guard():
return execute("helm", *args)
return execute("helm", *args, capture_stdout=capture_stdout)


def kubectl(*args: str) -> str:
def kubectl(*args: str, capture_stdout: bool = False) -> CommandResult:
with kube_guard():
return execute("kubectl", *args)
return execute("kubectl", *args, capture_stdout=capture_stdout)
52 changes: 52 additions & 0 deletions local-k8s/src/local_k8s/commands/chart/lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from contextlib import chdir
from pathlib import Path
from subprocess import CalledProcessError

import click
from click import ClickException

from local_k8s.shared import execute, resolve_chart
from local_k8s.static import CT_YAML


@click.command("lint")
@click.option(
"--helm-dir",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
path_type=Path,
),
required=True,
)
@click.option(
"--chart",
type=click.Path(
exists=False,
file_okay=False,
dir_okay=True,
path_type=Path,
),
required=True,
)
def lint(helm_dir: Path, chart: Path) -> None:
helm_dir = helm_dir.resolve()
if not (helm_dir / CT_YAML).is_file():
raise ClickException(f"{CT_YAML} is required in the root of --helm-dir")

resolved_chart = resolve_chart(helm_dir, chart)

with chdir(helm_dir):
try:
execute(
"ct",
"lint",
"--config",
str(CT_YAML),
"--charts",
str(resolved_chart.path_relative_to_helm_dir),
"--check-version-increment=true",
)
except CalledProcessError as e:
raise ClickException(f"lint failed with rc={e.returncode}") from e
67 changes: 67 additions & 0 deletions local-k8s/src/local_k8s/commands/chart/list_changed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import json
from contextlib import chdir
from pathlib import Path
from subprocess import CalledProcessError

import click
from click import ClickException

from local_k8s.shared import execute
from local_k8s.static import CT_YAML


@click.command("list-changed")
@click.option(
"--helm-dir",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
path_type=Path,
),
required=True,
)
@click.option("--target-branch", default="main", show_default=True)
@click.option("--since", default=None)
def list_changed(helm_dir: Path, target_branch: str, since: str | None) -> None:
helm_dir = helm_dir.resolve()
ct_path = helm_dir / CT_YAML
if not ct_path.is_file():
raise ClickException(f"{CT_YAML} is required in the root of --helm-dir")

# Get the root of our git repo.
repo_root = Path(
execute(
"git",
"-C",
str(helm_dir),
"rev-parse",
"--show-toplevel",
skip_resolve=True,
capture_stdout=True,
).stdout.strip()
).resolve()

# ct list-changed operates a bit differently to the other CT commands.
# It must be executed from the root of the repo, and the paths to
# the chart-dir/config need to be relative not absolute. This is
# why we convert to relative below, if that is not done, it asplodes.
with chdir(repo_root):
args = [
"ct",
"list-changed",
"--config",
str(ct_path.relative_to(repo_root)),
"--chart-dirs",
str((helm_dir / "charts").relative_to(repo_root)),
"--target-branch",
target_branch,
]
if since is not None:
args.extend(["--since", since])
try:
out = execute(*args, capture_stdout=True)
except CalledProcessError as e:
raise ClickException(f"list-changed failed with rc={e.returncode}") from e
charts = [line.strip() for line in out.stdout.splitlines() if line.strip()]
click.echo(json.dumps(charts))
27 changes: 27 additions & 0 deletions local-k8s/src/local_k8s/commands/chart/metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import json
from pathlib import Path

import click
from click import ClickException
from pydantic import ValidationError

from local_k8s.models import ChartMetadata


@click.command("metadata")
@click.option(
"--chart",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
path_type=Path,
),
required=True,
)
def metadata(chart: Path) -> None:
try:
meta = ChartMetadata.from_chart_dir(chart)
except (ValueError, ValidationError) as e:
raise ClickException(str(e)) from e
click.echo(json.dumps(meta.model_dump(mode="json")))
146 changes: 146 additions & 0 deletions local-k8s/src/local_k8s/commands/chart/push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import json
from pathlib import Path
from subprocess import CalledProcessError
from tempfile import TemporaryDirectory

import click
from click import ClickException
from pydantic import ValidationError

from local_k8s.models import ChartMetadata
from local_k8s.shared import execute

REGISTRY_HOST = "ghcr.io"


def _detailed_failure_for(step: str, e: CalledProcessError) -> ClickException:
stdout = e.output if isinstance(e.output, str) else ""
msg = f"{step} failed with rc={e.returncode}"
if stdout.strip():
msg = f"{msg}\n{stdout.strip()}"
return ClickException(msg)


@click.command("push")
@click.option(
"--chart",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
path_type=Path,
),
required=True,
)
@click.option(
"--registry-config",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
path_type=Path,
),
required=True,
)
@click.option("--oci-repo", required=True, help="OCI path after host, e.g. owner/repo")
@click.option("--beta", default=None)
@click.option("--fail-if-exists", is_flag=True, default=False)
def push(
chart: Path,
registry_config: Path,
oci_repo: str,
beta: str | None,
fail_if_exists: bool,
) -> None:
try:
meta = ChartMetadata.from_chart_dir(chart)
except (ValueError, ValidationError) as e:
raise ClickException(str(e)) from e

version = f"{meta.version}-beta.{beta}" if beta is not None else meta.version

oci_base = f"oci://{REGISTRY_HOST}/{oci_repo}"
oci_chart = f"{oci_base}/{meta.name}"
archive_name = f"{meta.name}-{version}.tgz"
registry_config_arg = str(registry_config)

if fail_if_exists:
result = execute(
"helm",
"show",
"chart",
oci_chart,
"--version",
version,
"--registry-config",
registry_config_arg,
check=False,
capture_stdout=True,
capture_stderr=True,
)
if result.returncode == 0:
raise ClickException(f"{meta.name}:{version} already exists in registry")
text = f"{result.stderr}\n{result.stdout}".lower()
if "not found" not in text and "manifest unknown" not in text:
raise ClickException(
f"exist check failed (rc={result.returncode}): {result.stderr.strip()}"
)

try:
execute(
"helm",
"dependency",
"build",
str(chart),
"--registry-config",
registry_config_arg,
capture_stdout=True,
)
except CalledProcessError as e:
raise _detailed_failure_for("dependency build", e) from e

with TemporaryDirectory() as tmp:
package_args = [
"helm",
"package",
str(chart),
"--destination",
tmp,
]
if beta is not None:
package_args.extend(["--version", version])
try:
execute(*package_args, capture_stdout=True)
execute(
"helm",
"push",
str(Path(tmp) / archive_name),
oci_base,
"--registry-config",
registry_config_arg,
capture_stdout=True,
)
execute(
Comment thread
facetoe marked this conversation as resolved.
"helm",
"show",
"chart",
oci_chart,
"--version",
version,
"--registry-config",
registry_config_arg,
capture_stdout=True,
)
except CalledProcessError as e:
raise _detailed_failure_for("push", e) from e

click.echo(
json.dumps(
{
"name": meta.name,
"version": version,
"oci_ref": f"{REGISTRY_HOST}/{oci_repo}/{meta.name}:{version}",
"archive": archive_name,
}
)
)
Loading