-
Notifications
You must be signed in to change notification settings - Fork 35
feat(admin): add jmp admin apply for Jumpstarter manifests #1046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kirkbrauer
wants to merge
4
commits into
main
Choose a base branch
from
cli-admin-apply
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
33cc758
fix(admin): report server errors that carry no reason
kirkbrauer 467016b
feat(admin): add jmp admin apply for Jumpstarter manifests
kirkbrauer f3ff397
docs(admin): reword a comment the typos check rejects
kirkbrauer 2f91fef
fix(cli): report apply conflicts instead of winning them
kirkbrauer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| from http import HTTPStatus | ||
| from typing import IO, Optional | ||
|
|
||
| import click | ||
| from jumpstarter_cli_common.blocking import blocking | ||
| from jumpstarter_cli_common.opt import ( | ||
| OutputType, | ||
| opt_context, | ||
| opt_kubeconfig, | ||
| opt_namespace, | ||
| opt_output_all, | ||
| ) | ||
| from jumpstarter_cli_common.print import model_print | ||
| from jumpstarter_kubernetes import ApplyV1Alpha1Api, ManifestError, load_manifests | ||
| from kubernetes_asyncio.client.exceptions import ApiException | ||
| from kubernetes_asyncio.config.config_exception import ConfigException | ||
|
|
||
| from .k8s import ( | ||
| handle_k8s_api_exception, | ||
| handle_k8s_config_exception, | ||
| ) | ||
|
|
||
|
|
||
| @click.command("apply") | ||
| @click.option( | ||
| "-f", | ||
| "--filename", | ||
| "filenames", | ||
| type=click.File("r"), | ||
| multiple=True, | ||
| required=True, | ||
| help="Manifest to apply, or '-' to read from stdin. Can be set multiple times.", | ||
| ) | ||
| @click.option( | ||
| "--force-conflicts", | ||
| is_flag=True, | ||
| default=False, | ||
| help="Take ownership of fields another manager holds, instead of reporting the conflict.", | ||
| ) | ||
| @click.option( | ||
| "--dry-run", | ||
| is_flag=True, | ||
| default=False, | ||
| help="Send the manifests to the server for validation without persisting them.", | ||
| ) | ||
| @opt_namespace | ||
| @opt_kubeconfig | ||
| @opt_context | ||
| @opt_output_all | ||
| @blocking | ||
| async def apply( | ||
| filenames: tuple[IO, ...], | ||
| force_conflicts: bool, | ||
| dry_run: bool, | ||
| namespace: str, | ||
| kubeconfig: Optional[str], | ||
| context: Optional[str], | ||
| output: OutputType, | ||
| ): | ||
| """Apply Jumpstarter manifests to a Kubernetes cluster | ||
|
|
||
| Creates the resources in a manifest, or updates them in place when they | ||
| already exist. Only Jumpstarter resources (clients, exporters, exporter | ||
| sets, virtual target classes) can be applied. | ||
| """ | ||
| try: | ||
| manifests = [] | ||
| for file in filenames: | ||
| manifests.extend(load_manifests(file.read(), file.name)) | ||
| except ManifestError as e: | ||
| raise click.ClickException(str(e)) from e | ||
|
|
||
| try: | ||
| async with ApplyV1Alpha1Api(namespace, kubeconfig, context) as api: | ||
| applied = await api.apply_all(manifests, dry_run=dry_run, force_conflicts=force_conflicts) | ||
| except ManifestError as e: | ||
| raise click.ClickException(str(e)) from e | ||
| except ApiException as e: | ||
| try: | ||
| handle_k8s_api_exception(e) | ||
| except click.ClickException as error: | ||
| if e.status == HTTPStatus.CONFLICT and not force_conflicts: | ||
| # The conflict names the fields; say how to win them on purpose. | ||
| error.message += "\nRe-run with --force-conflicts to take ownership of those fields." | ||
| raise | ||
| except ConfigException as e: | ||
| handle_k8s_config_exception(e) | ||
|
|
||
| if output is None: | ||
| # Mirror kubectl: one line per resource saying what happened to it. | ||
| suffix = " (dry run)" if dry_run else "" | ||
| for resource in applied.items: | ||
| click.echo(f"{resource.qualified_name} {resource.action}{suffix}") | ||
| else: | ||
| model_print(applied, output, namespace=namespace) |
203 changes: 203 additions & 0 deletions
203
python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| import json | ||
| from unittest.mock import AsyncMock, patch | ||
|
|
||
| from click.testing import CliRunner | ||
| from jumpstarter_kubernetes import ( | ||
| ApplyV1Alpha1Api, | ||
| V1Alpha1AppliedResource, | ||
| V1Alpha1AppliedResourceList, | ||
| ) | ||
| from kubernetes_asyncio.client.exceptions import ApiException | ||
|
|
||
| from .apply import apply | ||
|
|
||
| CLIENT_MANIFEST = """\ | ||
| apiVersion: jumpstarter.dev/v1alpha1 | ||
| kind: Client | ||
| metadata: | ||
| name: hello | ||
| """ | ||
|
|
||
| EXPORTER_SET_MANIFEST = """\ | ||
| apiVersion: virtualtarget.jumpstarter.dev/v1alpha1 | ||
| kind: ExporterSet | ||
| metadata: | ||
| name: pool | ||
| """ | ||
|
|
||
|
|
||
| def applied(kind: str, name: str, action: str, api_version: str = "jumpstarter.dev/v1alpha1"): | ||
| return V1Alpha1AppliedResource( | ||
| apiVersion=api_version, | ||
| kind=kind, | ||
| name=name, | ||
| namespace="default", | ||
| action=action, | ||
| resource={"apiVersion": api_version, "kind": kind, "metadata": {"name": name}}, | ||
| ) | ||
|
|
||
|
|
||
| def run(args, files: dict[str, str], apply_all: AsyncMock, input: str | None = None): | ||
| runner = CliRunner() | ||
| with runner.isolated_filesystem(): | ||
| for filename, contents in files.items(): | ||
| with open(filename, "w") as f: | ||
| f.write(contents) | ||
| with ( | ||
| patch.object(ApplyV1Alpha1Api, "_load_kube_config", AsyncMock()), | ||
| patch.object(ApplyV1Alpha1Api, "apply_all", apply_all), | ||
| ): | ||
| return runner.invoke(apply, args, input=input) | ||
|
|
||
|
|
||
| def test_apply_reports_what_happened_to_each_resource(): | ||
| apply_all = AsyncMock( | ||
| return_value=V1Alpha1AppliedResourceList( | ||
| items=[ | ||
| applied("Client", "hello", "created"), | ||
| applied("ExporterSet", "pool", "configured", "virtualtarget.jumpstarter.dev/v1alpha1"), | ||
| ] | ||
| ) | ||
| ) | ||
|
|
||
| result = run( | ||
| ["-f", "client.yaml", "-f", "set.yaml"], | ||
| {"client.yaml": CLIENT_MANIFEST, "set.yaml": EXPORTER_SET_MANIFEST}, | ||
| apply_all, | ||
| ) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert "client.jumpstarter.dev/hello created" in result.output | ||
| assert "exporterset.virtualtarget.jumpstarter.dev/pool configured" in result.output | ||
| # Both files reach the cluster in the order they were given. | ||
| assert [m["kind"] for m in apply_all.call_args.args[0]] == ["Client", "ExporterSet"] | ||
| assert apply_all.call_args.kwargs["dry_run"] is False | ||
|
|
||
|
|
||
| def test_apply_reads_a_multi_document_manifest(): | ||
| apply_all = AsyncMock( | ||
| return_value=V1Alpha1AppliedResourceList( | ||
| items=[ | ||
| applied("Client", "hello", "created"), | ||
| applied("ExporterSet", "pool", "created", "virtualtarget.jumpstarter.dev/v1alpha1"), | ||
| ] | ||
| ) | ||
| ) | ||
|
|
||
| result = run(["-f", "both.yaml"], {"both.yaml": f"{CLIENT_MANIFEST}---\n{EXPORTER_SET_MANIFEST}"}, apply_all) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert [m["kind"] for m in apply_all.call_args.args[0]] == ["Client", "ExporterSet"] | ||
|
|
||
|
|
||
| def test_apply_says_when_nothing_was_persisted(): | ||
| apply_all = AsyncMock(return_value=V1Alpha1AppliedResourceList(items=[applied("Client", "hello", "created")])) | ||
|
|
||
| result = run(["-f", "client.yaml", "--dry-run"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert "client.jumpstarter.dev/hello created (dry run)" in result.output | ||
| assert apply_all.call_args.kwargs["dry_run"] is True | ||
|
|
||
|
|
||
| def test_apply_prints_names_only(): | ||
| apply_all = AsyncMock(return_value=V1Alpha1AppliedResourceList(items=[applied("Client", "hello", "created")])) | ||
|
|
||
| result = run(["-f", "client.yaml", "-o", "name"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert result.output.strip() == "client.jumpstarter.dev/hello" | ||
|
|
||
|
|
||
| def test_apply_prints_the_stored_resources_as_json(): | ||
| apply_all = AsyncMock(return_value=V1Alpha1AppliedResourceList(items=[applied("Client", "hello", "created")])) | ||
|
|
||
| result = run(["-f", "client.yaml", "-o", "json"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code == 0 | ||
| parsed = json.loads(result.output) | ||
| assert parsed["items"][0]["action"] == "created" | ||
| assert parsed["items"][0]["resource"]["metadata"]["name"] == "hello" | ||
|
|
||
|
|
||
| def test_apply_reads_a_manifest_from_stdin(): | ||
| apply_all = AsyncMock(return_value=V1Alpha1AppliedResourceList(items=[applied("Client", "hello", "created")])) | ||
|
|
||
| result = run(["-f", "-"], {}, apply_all, input=CLIENT_MANIFEST) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert [m["kind"] for m in apply_all.call_args.args[0]] == ["Client"] | ||
|
|
||
|
|
||
| def test_apply_rejects_a_manifest_that_is_not_jumpstarters(): | ||
| apply_all = AsyncMock() | ||
|
|
||
| result = run( | ||
| ["-f", "secret.yaml"], | ||
| {"secret.yaml": "apiVersion: v1\nkind: Secret\nmetadata:\n name: creds\n"}, | ||
| apply_all, | ||
| ) | ||
|
|
||
| assert result.exit_code != 0 | ||
| assert "core Kubernetes resource" in result.output | ||
| # Nothing reaches the cluster when the manifest is refused. | ||
| apply_all.assert_not_awaited() | ||
|
|
||
|
|
||
| def test_apply_requires_a_manifest(): | ||
| result = run([], {}, AsyncMock()) | ||
|
|
||
| assert result.exit_code != 0 | ||
| assert "Missing option" in result.output | ||
|
|
||
|
|
||
| CONFLICT_BODY = json.dumps( | ||
| { | ||
| "kind": "Status", | ||
| "reason": "Conflict", | ||
| "message": 'Apply failed with 1 conflict: conflict with "other-controller": .metadata.labels.owner', | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def conflict() -> ApiException: | ||
| error = ApiException(status=409, reason="Conflict") | ||
| error.body = CONFLICT_BODY | ||
| return error | ||
|
|
||
|
|
||
| def test_apply_says_how_to_resolve_a_field_ownership_conflict(): | ||
| apply_all = AsyncMock(side_effect=conflict()) | ||
|
|
||
| result = run(["-f", "client.yaml"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code != 0 | ||
| assert ".metadata.labels.owner" in result.output | ||
| assert "--force-conflicts" in result.output | ||
|
|
||
|
|
||
| def test_apply_does_not_suggest_a_flag_that_is_already_set(): | ||
| apply_all = AsyncMock(side_effect=conflict()) | ||
|
|
||
| result = run(["--force-conflicts", "-f", "client.yaml"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code != 0 | ||
| assert "--force-conflicts" not in result.output | ||
|
|
||
|
|
||
| def test_apply_asks_the_cluster_to_take_ownership_when_told_to(): | ||
| apply_all = AsyncMock(return_value=V1Alpha1AppliedResourceList(items=[applied("Client", "hello", "configured")])) | ||
|
|
||
| result = run(["--force-conflicts", "-f", "client.yaml"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert apply_all.call_args.kwargs["force_conflicts"] is True | ||
|
|
||
|
|
||
| def test_apply_leaves_conflicting_fields_alone_by_default(): | ||
| apply_all = AsyncMock(return_value=V1Alpha1AppliedResourceList(items=[applied("Client", "hello", "configured")])) | ||
|
|
||
| result = run(["-f", "client.yaml"], {"client.yaml": CLIENT_MANIFEST}, apply_all) | ||
|
|
||
| assert result.exit_code == 0 | ||
| assert apply_all.call_args.kwargs["force_conflicts"] is False |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
57 changes: 57 additions & 0 deletions
57
python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/k8s_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import click | ||
| import pytest | ||
| from kubernetes_asyncio.client.exceptions import ApiException | ||
|
|
||
| from .k8s import handle_k8s_api_exception | ||
|
|
||
|
|
||
| def api_exception(status: int, body, reason: str | None = None) -> ApiException: | ||
| error = ApiException(status=status, reason=reason) | ||
| error.body = body | ||
| return error | ||
|
|
||
|
|
||
| def test_reports_the_reason_and_message_from_the_server(): | ||
| error = api_exception(404, '{"reason":"NotFound","message":"clients.jumpstarter.dev \\"nope\\" not found"}') | ||
|
|
||
| with pytest.raises(click.ClickException, match=r"Error from server \(NotFound\): .*not found"): | ||
| handle_k8s_api_exception(error) | ||
|
|
||
|
|
||
| def test_reports_a_status_that_carries_no_reason(): | ||
| # Schema and admission failures answer with a message and nothing else. | ||
| error = api_exception(500, '{"kind":"Status","message":".provisioner: field not declared in schema","code":500}') | ||
|
|
||
| with pytest.raises(click.ClickException, match="Error from server: .provisioner: field not declared in schema"): | ||
| handle_k8s_api_exception(error) | ||
|
|
||
|
|
||
| def test_falls_back_to_the_http_reason_when_the_body_says_nothing(): | ||
| error = api_exception(503, "{}", reason="Service Unavailable") | ||
|
|
||
| with pytest.raises(click.ClickException, match="Error from server: Service Unavailable"): | ||
| handle_k8s_api_exception(error) | ||
|
|
||
|
|
||
| def test_passes_through_a_body_that_is_not_json(): | ||
| error = api_exception(502, "<html>bad gateway</html>") | ||
|
|
||
| with pytest.raises(click.ClickException, match="Server error: <html>bad gateway</html>"): | ||
| handle_k8s_api_exception(error) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("body", ["null", '"just a string"', "[1,2,3]"]) | ||
| def test_passes_through_json_that_is_not_a_status(body): | ||
| # A proxy in front of the API server can answer with valid JSON that is not | ||
| # a Status object; reading it as one used to raise AttributeError. | ||
| error = api_exception(502, body) | ||
|
|
||
| with pytest.raises(click.ClickException, match="Server error: "): | ||
| handle_k8s_api_exception(error) | ||
|
|
||
|
|
||
| def test_passes_through_a_missing_body(): | ||
| error = api_exception(500, None) | ||
|
|
||
| with pytest.raises(click.ClickException, match="Server error: None"): | ||
| handle_k8s_api_exception(error) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.