diff --git a/openserverless/common/kube_api_client.py b/openserverless/common/kube_api_client.py index d78c163..0f2ff4f 100644 --- a/openserverless/common/kube_api_client.py +++ b/openserverless/common/kube_api_client.py @@ -484,6 +484,30 @@ def get_jobs(self, name_filter: str | None = None, namespace="nuvolaris"): except Exception as ex: logging.error(f"get_jobs {ex}") return None + + def get_job(self, job_name: str, namespace="nuvolaris"): + """Get a Kubernetes Job by its exact name. + + A missing Job is a normal condition for the idempotent image builder, + so HTTP 404 is returned as ``None`` without being treated as an API + failure. + """ + url = f"{self.host}/apis/batch/v1/namespaces/{namespace}/jobs/{job_name}" + headers = {"Authorization": self.token} + try: + logging.info(f"GET request to {url}") + response = req.get(url, headers=headers, verify=self.ssl_ca_cert) + if response.status_code == 200: + return json.loads(response.text) + if response.status_code == 404: + return None + logging.error( + f"GET to {url} failed with {response.status_code}. Body {response.text}" + ) + return None + except Exception as ex: + logging.error(f"get_job {ex}") + return None def delete_job(self, job_name: str, namespace="nuvolaris"): """ @@ -702,4 +726,4 @@ def wait_for_init_container_completion(self, job_name: str, init_container_name: time.sleep(2) logging.error(f"Timeout waiting for init container '{init_container_name}' to complete") - return False \ No newline at end of file + return False diff --git a/openserverless/impl/builder/build_catalog.py b/openserverless/impl/builder/build_catalog.py new file mode 100644 index 0000000..f5629f7 --- /dev/null +++ b/openserverless/impl/builder/build_catalog.py @@ -0,0 +1,90 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json +import os +import re + + +BUILDER_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,62}$") +IMAGE_REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,511}$") +SUPPORTED_KINDS = {"python", "nodejs", "php", "java", "go", "ruby", "dotnet"} + + +class BuildCatalogError(ValueError): + pass + + +class BuildCatalog: + """Validated allowlist of images that may be extended by BuildKit.""" + + def __init__(self, builders=None, environ=None): + self.environ = environ if environ is not None else os.environ + self.builders = self._validate(builders if builders is not None else self._load()) + + def _load(self): + raw = self.environ.get("BUILDER_CATALOG_JSON", "").strip() + if raw: + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise BuildCatalogError(f"BUILDER_CATALOG_JSON is invalid: {exc.msg}") from exc + + path = self.environ.get( + "BUILDER_CATALOG_FILE", "/etc/openserverless/builders.json" + ) + try: + with open(path, encoding="utf-8") as catalog_file: + return json.load(catalog_file) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as exc: + raise BuildCatalogError(f"Cannot load builder catalog {path}: {exc}") from exc + + def _validate(self, document): + if not isinstance(document, dict): + raise BuildCatalogError("Builder catalog must be a JSON object") + builders = document.get("builders", document) + if not isinstance(builders, dict): + raise BuildCatalogError("Builder catalog 'builders' must be an object") + + validated = {} + for builder_id, entry in builders.items(): + if not isinstance(builder_id, str) or not BUILDER_ID.fullmatch(builder_id): + raise BuildCatalogError(f"Invalid builder id: {builder_id!r}") + if not isinstance(entry, dict): + raise BuildCatalogError(f"Builder {builder_id} must be an object") + kind = entry.get("kind") + source = entry.get("source") + if kind not in SUPPORTED_KINDS: + raise BuildCatalogError(f"Builder {builder_id} has unsupported kind {kind!r}") + if not isinstance(source, str) or not IMAGE_REFERENCE.fullmatch(source): + raise BuildCatalogError(f"Builder {builder_id} has invalid source image") + validated[builder_id] = {"kind": kind, "source": source} + return validated + + def get(self, builder_id): + try: + return self.builders[builder_id] + except KeyError as exc: + raise BuildCatalogError(f"Unknown builder: {builder_id}") from exc + + def capabilities(self): + return [ + {"id": builder_id, "kind": entry["kind"]} + for builder_id, entry in sorted(self.builders.items()) + ] diff --git a/openserverless/impl/builder/build_service.py b/openserverless/impl/builder/build_service.py index bf501c5..8647743 100644 --- a/openserverless/impl/builder/build_service.py +++ b/openserverless/impl/builder/build_service.py @@ -20,6 +20,8 @@ import os import uuid import logging +import re +import shlex from datetime import datetime, timezone, timedelta from types import SimpleNamespace import random @@ -36,28 +38,33 @@ class BuildService: based on the provided build configuration. """ - def __init__(self, user_env=None): + def __init__(self, user_env=None, build_id=None, kube_client=None): # A super userful Kube Api Client - self.kube_client = KubeApiClient() + self.kube_client = kube_client or KubeApiClient() # generate a unique ID for the build - self.id = str(uuid.uuid4()) + self.id = build_id or str(uuid.uuid4()) # user environment variables self.user_env = user_env if user_env is not None else {} self.user = self.user_env.get('wsk_user_name', '') - # define a unique ConfigMap and Job name based on the ID + # Kubernetes names are deliberately derived from the authenticated + # namespace and the build id. This makes build creation idempotent and + # prevents a client supplied image name from reaching a shell command. + safe_user = re.sub(r"[^a-z0-9-]", "-", self.user.lower()).strip("-") + safe_user = (safe_user or "user")[:24] + safe_id = re.sub(r"[^a-z0-9-]", "", self.id.lower())[:20] if len(self.user) > 0: - self.cm = f"{CM_NAME}-{self.user}-{self.id}" - self.job_name = f"{JOB_NAME}-{self.user}-{self.id}" + self.cm = f"{CM_NAME}-{safe_user}-{safe_id}" + self.job_name = f"{JOB_NAME}-{safe_user}-{safe_id}" else: - self.cm = f"{CM_NAME}-{self.id}" - self.job_name = f"{JOB_NAME}-{self.id}" + self.cm = f"{CM_NAME}-{safe_id}" + self.job_name = f"{JOB_NAME}-{safe_id}" # define registry host - self.registry_host = self.get_registry_host() + self.registry_host = self.get_registry_push_host() logging.info(f"Using registry host: {self.registry_host}") # define registry auth @@ -94,37 +101,51 @@ def create_registry_secret(self, username: str, password: str, registry: str): secret = self.kube_client.post_secret(secret_name=random_name, secret_data=data, type="kubernetes.io/dockerconfigjson") return secret - def get_registry_host(self) -> str: - """ - Retrieve the registry host - - firstly, use local environment - - then, check if the user environment has a registry host set - - otherwise retrieve the OpenServerless config map - - if not present use a default value + def _get_registry_annotation(self, name: str, default: str = "") -> str: + ops_config_map = self.kube_client.get_config_map('config') + if ops_config_map is None: + return default + annotations = ops_config_map.get('metadata', {}).get('annotations', {}) + return str(annotations.get(name, default)).strip() + + def get_registry_push_host(self) -> str: """ + Return the endpoint used by BuildKit pods to push an image. - # Check environment variable (only use if not empty) - registry_host = os.environ.get("REGISTRY_HOST", "").strip() + This is intentionally separate from the host embedded in an action + image reference: a Kubernetes Service is reachable by BuildKit, but it + is generally not resolvable by containerd on a cluster node. + """ + registry_host = os.environ.get("REGISTRY_PUSH_HOST", "").strip() if registry_host: return registry_host + user_registry_host = self.user_env.get('REGISTRY_PUSH_HOST', "").strip() + if user_registry_host: + return user_registry_host + return ( + self._get_registry_annotation("registry_push_host") + or self._get_registry_annotation("registry_internal_host") + or "nuvolaris-registry-svc:5000" + ) - # Check user environment (only use if not empty) - user_registry_host = self.user_env.get('REGISTRY_HOST', "").strip() + def get_registry_pull_host(self) -> str: + """Return the registry host that cluster nodes use for action pulls.""" + registry_host = os.environ.get("REGISTRY_PULL_HOST", "").strip() + if registry_host: + return registry_host + user_registry_host = self.user_env.get('REGISTRY_PULL_HOST', "").strip() if user_registry_host: return user_registry_host + return ( + self._get_registry_annotation("registry_pull_host") + or self._get_registry_annotation("registry_host") + or self.get_registry_push_host() + ) - # Try to get from OpenServerless config map - registry_host = 'nuvolaris-registry-svc:5000' # Default fallback - ops_config_map = self.kube_client.get_config_map('config') - if ops_config_map is not None: - if 'annotations' in ops_config_map.get('metadata', {}): - annotations = ops_config_map['metadata']['annotations'] - if 'registry_host' in annotations: - config_registry_host = annotations.get('registry_host', '').strip() - if config_registry_host: - registry_host = config_registry_host - - return registry_host + # Kept for callers of the alpha builder API. New code must use the + # explicit push/pull methods above. + def get_registry_host(self) -> str: + return self.get_registry_push_host() def get_registry_auth(self) -> str: """ @@ -151,7 +172,7 @@ def get_registry_auth(self) -> str: logging.error(f"Failed to create registry secret for custom credentials") - return 'registry-pull-secret' + return os.environ.get("REGISTRY_PUSH_SECRET", "registry-pull-secret-int") def create_docker_file(self, requirements=None) -> str: """ @@ -282,7 +303,11 @@ def build(self, image_name: str) -> tuple[bool, str]: return (False, "Failed to create ConfigMap for build context") logging.info(f"ConfigMap {self.cm} created successfully") - job_template = self.create_build_job(image_name) + try: + job_template = self.create_build_job(image_name) + except ValueError as exc: + self._cleanup_build_resources() + return (False, str(exc)) job = self.kube_client.post_job(job_template) if not job: @@ -293,27 +318,52 @@ def build(self, image_name: str) -> tuple[bool, str]: logging.info(f"Job {self.job_name} created successfully") - # Wait for the copy-build-context init container to complete before cleaning up resources - # The init container needs to copy the ConfigMap contents to the workspace volume - # Only after it completes (successfully or with error) can we safely delete the ConfigMap and Secret - logging.info(f"Waiting for init container 'copy-build-context' to complete for job {self.job_name}") - init_container_completed = self.kube_client.wait_for_init_container_completion( - job_name=self.job_name, - init_container_name="copy-build-context", - namespace="nuvolaris", - timeout_seconds=120 # 2 minutes should be enough for copying files - ) + # Do not wait for the Job in the HTTP start request. The client polls the + # status endpoint and cleanup is performed after a terminal condition. + return (True, self.job_name) - if init_container_completed: - logging.info(f"Init container completed for job {self.job_name}, cleaning up resources") - self._cleanup_build_resources() - else: - logging.warning(f"Init container did not complete within timeout for job {self.job_name}, resources will not be cleaned up automatically") - # Note: Resources are not cleaned up if init container doesn't complete - # This prevents race conditions where the ConfigMap is deleted while the init container still needs it + def action_image(self, image_name: str) -> str: + """Return the image reference that must be stored on an action.""" + return f"{self.get_registry_pull_host()}/{image_name}" + + def get_build_status(self, image_name: str | None = None) -> dict | None: + """Return a stable state for the deterministic build Job.""" + job = self.kube_client.get_job(self.job_name) + if job is None: + return None + + if image_name is None: + image_name = ( + job.get("metadata", {}) + .get("annotations", {}) + .get("openserverless.apache.org/target-image") + ) - # Success: return True and the job name (string) so callers get a simple identifier - return (True, self.job_name) + status = job.get("status", {}) + conditions = status.get("conditions") or [] + state = "queued" + message = "Build is queued" + for condition in conditions: + if condition.get("type") == "Complete" and condition.get("status") == "True": + state = "succeeded" + message = condition.get("message") or "Build completed" + break + if condition.get("type") == "Failed" and condition.get("status") == "True": + state = "failed" + message = condition.get("message") or condition.get("reason") or "Build failed" + break + else: + if status.get("active", 0) > 0: + state = "running" + message = "Build is running" + + return { + "id": self.id, + "job_name": self.job_name, + "state": state, + "message": message, + "image": self.action_image(image_name) if state == "succeeded" and image_name else None, + } def _cleanup_build_resources(self): """ @@ -407,14 +457,35 @@ def create_build_job(self, image_name: str) -> dict: else: registry_image_name = f"{image_name}" + if "://" in registry_image_name or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,511}", registry_image_name + ): + raise ValueError("Invalid target image reference") + quoted_registry_image_name = shlex.quote(registry_image_name) + # --- MANIFEST DEL JOB --- job_manifest = { "apiVersion": "batch/v1", "kind": "Job", - "metadata": {"name": self.job_name}, + "metadata": { + "name": self.job_name, + "labels": { + "openserverless.apache.org/component": "runtime-builder", + "openserverless.apache.org/build-id": self.id[:63], + }, + "annotations": { + "openserverless.apache.org/target-image": image_name, + }, + }, "spec": { "backoffLimit": 0, "template": { + "metadata": { + "labels": { + "openserverless.apache.org/component": "runtime-builder", + "openserverless.apache.org/build-id": self.id[:63], + } + }, "spec": { "restartPolicy": "Never", "volumes": [ @@ -460,11 +531,13 @@ def create_build_job(self, image_name: str) -> dict: "containers": [ { "name": "buildkit", - "image": "moby/buildkit:master-rootless", + "image": os.environ.get( + "BUILDKIT_IMAGE", "moby/buildkit:v0.30.0-rootless" + ), "command": ["sh", "-c"], "args": [ "rootlesskit buildkitd --config /config/buildkitd.toml & sleep 3 && " - f"buildctl build --progress=plain --frontend=dockerfile.v0 --local context=/workspace --local dockerfile=/workspace --output=type=image,name={registry_image_name},push=true" + f"buildctl build --progress=plain --frontend=dockerfile.v0 --local context=/workspace --local dockerfile=/workspace --output=type=image,name={quoted_registry_image_name},push=true" ], "securityContext": { "runAsUser": 1000, diff --git a/openserverless/rest/build.py b/openserverless/rest/build.py index 066ab60..e6e63e1 100644 --- a/openserverless/rest/build.py +++ b/openserverless/rest/build.py @@ -16,6 +16,11 @@ # under the License. # import os +import base64 +import binascii +import hashlib +import re +import time from openserverless import app from http import HTTPStatus from flask import request, Response @@ -24,6 +29,7 @@ from openserverless.common.utils import env_to_dict from openserverless.error.api_error import AuthorizationError from openserverless.impl.builder.build_service import BuildService +from openserverless.impl.builder.build_catalog import BuildCatalog, BuildCatalogError from openserverless.common.openwhisk_authorize import OpenwhiskAuthorize def authorize() -> Response | dict: @@ -42,6 +48,164 @@ def authorize() -> Response | dict: except AuthorizationError: return res_builder.build_error_message("Invalid authorization", 401) + +BUILD_ID_PATTERN = re.compile(r"^[a-f0-9]{64}$") +NAMESPACE_PATTERN = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") +MAX_BUILD_FILE_BYTES = 512 * 1024 + + +def authenticated_environment(auth_result: dict) -> tuple[dict, str]: + env = env_to_dict(auth_result) + user_env = env_to_dict(auth_result, "userenv") + env.update(user_env) + username = str(auth_result.get("login", "")).lower() + env["wsk_user_name"] = username + return env, username + + +def decode_build_file(encoded_file: str) -> bytes: + if not isinstance(encoded_file, str) or not encoded_file: + raise ValueError("Build file must be a non-empty base64 string") + if len(encoded_file) > 750_000: + raise ValueError("Build file is too large") + try: + decoded = base64.b64decode(encoded_file, validate=True) + decoded.decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as exc: + raise ValueError("Build file must be valid base64-encoded UTF-8 text") from exc + if len(decoded) > MAX_BUILD_FILE_BYTES: + raise ValueError("Decoded build file is too large") + return decoded + + +def build_identity(builder_id: str, builder: dict, file_content: bytes) -> str: + identity = hashlib.sha256() + identity.update(b"openserverless-runtime-builder-v1\0") + identity.update(builder_id.encode()) + identity.update(b"\0") + identity.update(builder["kind"].encode()) + identity.update(b"\0") + identity.update(builder["source"].encode()) + identity.update(b"\0") + identity.update(file_content) + return identity.hexdigest() + + +@app.route('/system/api/v1/build/capabilities', methods=['GET']) +def build_capabilities(): + auth_result = authorize() + if isinstance(auth_result, Response): + return auth_result + try: + catalog = BuildCatalog() + except BuildCatalogError as exc: + return res_builder.build_error_message(str(exc), HTTPStatus.INTERNAL_SERVER_ERROR) + return res_builder.build_response_message( + "Builder capabilities retrieved", {"builders": catalog.capabilities()} + ) + + +@app.route('/system/api/v1/build/ensure', methods=['POST']) +def ensure_build(): + """Ensure a content-addressed custom runtime image exists.""" + auth_result = authorize() + if isinstance(auth_result, Response): + return auth_result + if request.json is None: + return res_builder.build_error_message("No JSON payload provided", HTTPStatus.BAD_REQUEST) + + try: + catalog = BuildCatalog() + builder_id = request.json.get("builder", "") + builder = catalog.get(builder_id) + encoded_file = request.json.get("file", "") + file_content = decode_build_file(encoded_file) + except (BuildCatalogError, ValueError) as exc: + return res_builder.build_error_message(str(exc), HTTPStatus.BAD_REQUEST) + + env, username = authenticated_environment(auth_result) + if not username or not NAMESPACE_PATTERN.fullmatch(username): + return res_builder.build_error_message("Authenticated namespace not found", HTTPStatus.UNAUTHORIZED) + + # The content-addressed endpoint always uses cluster-managed registry + # settings. Do not inherit REGISTRY_HOST or REGISTRY_SECRET overrides from + # a user environment. + env = {"wsk_user_name": username} + + build_id = build_identity(builder_id, builder, file_content) + image_name = f"{username}:{builder['kind']}-{build_id[:20]}" + service = BuildService(user_env=env, build_id=build_id) + current = service.get_build_status(image_name) + if current is not None: + if current["state"] == "failed" and request.json.get("retry", True): + service._cleanup_build_resources() + service.kube_client.delete_job(service.job_name) + for _ in range(40): + if service.kube_client.get_job(service.job_name) is None: + current = None + break + time.sleep(0.25) + if current is not None: + return res_builder.build_error_message( + "Failed build cleanup is still in progress", HTTPStatus.CONFLICT + ) + if current is None: + pass + else: + status_code = HTTPStatus.OK if current["state"] == "succeeded" else HTTPStatus.ACCEPTED + if current["state"] == "failed": + status_code = HTTPStatus.CONFLICT + return res_builder.build_response_message(current["message"], current, status_code) + + service.init( + build_config={ + "source": builder["source"], + "target": image_name, + "kind": builder["kind"], + "file": encoded_file, + } + ) + success, message = service.build(image_name) + if not success: + return res_builder.build_error_message(message, HTTPStatus.INTERNAL_SERVER_ERROR) + + return res_builder.build_response_message( + "Build queued", + { + "id": build_id, + "job_name": service.job_name, + "state": "queued", + "image": None, + }, + HTTPStatus.ACCEPTED, + ) + + +@app.route('/system/api/v1/build/', methods=['GET']) +def build_status(build_id): + auth_result = authorize() + if isinstance(auth_result, Response): + return auth_result + if not BUILD_ID_PATTERN.fullmatch(build_id): + return res_builder.build_error_message("Invalid build id", HTTPStatus.BAD_REQUEST) + + env, username = authenticated_environment(auth_result) + if not username or not NAMESPACE_PATTERN.fullmatch(username): + return res_builder.build_error_message("Authenticated namespace not found", HTTPStatus.UNAUTHORIZED) + env = {"wsk_user_name": username} + service = BuildService(user_env=env, build_id=build_id) + current = service.get_build_status() + if current is None: + return res_builder.build_error_message("Build not found", HTTPStatus.NOT_FOUND) + if current["state"] in ("succeeded", "failed"): + service._cleanup_build_resources() + status_code = HTTPStatus.OK + if current["state"] in ("queued", "running"): + status_code = HTTPStatus.ACCEPTED + elif current["state"] == "failed": + status_code = HTTPStatus.CONFLICT + return res_builder.build_response_message(current["message"], current, status_code) + @app.route('/system/api/v1/build/start', methods=['POST']) def build(): """ @@ -251,4 +415,4 @@ def clean(): if clean_result == -1: return res_builder.build_error_message("Failed to clean up old build jobs.", status_code=HTTPStatus.INTERNAL_SERVER_ERROR) - return res_builder.build_response_message(f"Cleaned up {clean_result} jobs successfully.", status_code=HTTPStatus.OK) \ No newline at end of file + return res_builder.build_response_message(f"Cleaned up {clean_result} jobs successfully.", status_code=HTTPStatus.OK) diff --git a/tests/test_build_api.py b/tests/test_build_api.py new file mode 100644 index 0000000..04e31b8 --- /dev/null +++ b/tests/test_build_api.py @@ -0,0 +1,114 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import base64 +import unittest +from unittest.mock import Mock, patch + +from openserverless import app +import openserverless.rest.build as build_api + + +class BuildApiTest(unittest.TestCase): + def setUp(self): + self.client = app.test_client() + self.auth = patch.object(build_api, "authorize", return_value={"login": "sample-user"}) + self.environment = patch.object( + build_api, + "authenticated_environment", + return_value=({"wsk_user_name": "sample-user"}, "sample-user"), + ) + self.catalog = Mock() + self.catalog.get.return_value = { + "kind": "python", + "source": "registry.example/runtime-python@sha256:abcd", + } + self.catalog_patch = patch.object(build_api, "BuildCatalog", return_value=self.catalog) + self.auth.start() + self.environment.start() + self.catalog_patch.start() + + def tearDown(self): + patch.stopall() + + def payload(self): + return { + "builder": "python:3.13", + "file": base64.b64encode(b"sample-lib==1.0\n").decode(), + } + + def test_ensure_generates_target_from_authenticated_namespace(self): + service = Mock() + service.job_name = "build-sample-user-digest" + service.get_build_status.return_value = None + service.build.return_value = (True, service.job_name) + with patch.object(build_api, "BuildService", return_value=service) as service_class: + response = self.client.post("/system/api/v1/build/ensure", json=self.payload()) + + self.assertEqual(202, response.status_code) + build_config = service.init.call_args.kwargs["build_config"] + self.assertTrue(build_config["target"].startswith("sample-user:python-")) + self.assertNotIn("target", self.payload()) + self.assertEqual( + {"wsk_user_name": "sample-user"}, + service_class.call_args.kwargs["user_env"], + ) + + def test_ensure_rejects_an_invalid_authenticated_namespace(self): + with patch.object( + build_api, + "authenticated_environment", + return_value=({"wsk_user_name": "bad;namespace"}, "bad;namespace"), + ), patch.object(build_api, "BuildService") as service_class: + response = self.client.post("/system/api/v1/build/ensure", json=self.payload()) + + self.assertEqual(401, response.status_code) + service_class.assert_not_called() + + def test_ensure_rejects_a_file_larger_than_a_config_map_context(self): + payload = self.payload() + payload["file"] = base64.b64encode( + b"a" * (build_api.MAX_BUILD_FILE_BYTES + 1) + ).decode() + + response = self.client.post("/system/api/v1/build/ensure", json=payload) + + self.assertEqual(400, response.status_code) + + def test_ensure_returns_completed_cached_image(self): + service = Mock() + service.get_build_status.return_value = { + "id": "a" * 64, + "state": "succeeded", + "message": "Build completed", + "image": "node.example:32000/sample-user:python-digest", + } + with patch.object(build_api, "BuildService", return_value=service): + response = self.client.post("/system/api/v1/build/ensure", json=self.payload()) + + self.assertEqual(200, response.status_code) + self.assertEqual("succeeded", response.get_json()["state"]) + service.build.assert_not_called() + + def test_status_rejects_non_digest_identifier(self): + response = self.client.get("/system/api/v1/build/not-a-digest") + + self.assertEqual(400, response.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_build_catalog.py b/tests/test_build_catalog.py new file mode 100644 index 0000000..7f6065d --- /dev/null +++ b/tests/test_build_catalog.py @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import unittest + +from openserverless.impl.builder.build_catalog import BuildCatalog, BuildCatalogError + + +class BuildCatalogTest(unittest.TestCase): + def test_validates_and_lists_generic_builders(self): + catalog = BuildCatalog( + { + "builders": { + "python:3.13": { + "kind": "python", + "source": "registry.example/runtime-python@sha256:abcd", + } + } + } + ) + + self.assertEqual("python", catalog.get("python:3.13")["kind"]) + self.assertEqual( + [{"id": "python:3.13", "kind": "python"}], catalog.capabilities() + ) + + def test_rejects_unknown_builder(self): + with self.assertRaisesRegex(BuildCatalogError, "Unknown builder"): + BuildCatalog({}).get("python:3.13") + + def test_rejects_shell_characters_in_source(self): + with self.assertRaisesRegex(BuildCatalogError, "invalid source"): + BuildCatalog( + { + "python:3.13": { + "kind": "python", + "source": "runtime;echo unsafe", + } + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_build_service.py b/tests/test_build_service.py new file mode 100644 index 0000000..af8f135 --- /dev/null +++ b/tests/test_build_service.py @@ -0,0 +1,112 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import unittest + +from openserverless.impl.builder.build_service import BuildService + + +class FakeKubeClient: + def __init__(self, job=None): + self.job = job + + def get_config_map(self, name): + return { + "metadata": { + "annotations": { + "registry_push_host": "registry.svc:5000", + "registry_pull_host": "node.example:32000", + } + } + } + + def get_job(self, name): + return self.job + + +class BuildServiceTest(unittest.TestCase): + def service(self, job=None): + return BuildService( + user_env={"wsk_user_name": "sample-user"}, + build_id="a" * 64, + kube_client=FakeKubeClient(job), + ) + + def test_separates_push_and_pull_registry_hosts(self): + service = self.service() + + self.assertEqual("registry.svc:5000", service.get_registry_push_host()) + self.assertEqual("node.example:32000", service.get_registry_pull_host()) + self.assertEqual( + "node.example:32000/sample-user:python-digest", + service.action_image("sample-user:python-digest"), + ) + + def test_reports_completed_job_with_pullable_image(self): + job = { + "metadata": { + "annotations": { + "openserverless.apache.org/target-image": "sample-user:python-digest" + } + }, + "status": { + "conditions": [{"type": "Complete", "status": "True"}] + }, + } + + status = self.service(job).get_build_status() + + self.assertEqual("succeeded", status["state"]) + self.assertEqual( + "node.example:32000/sample-user:python-digest", status["image"] + ) + + def test_reports_failed_job_without_image(self): + job = { + "metadata": {"annotations": {}}, + "status": { + "conditions": [ + { + "type": "Failed", + "status": "True", + "reason": "BackoffLimitExceeded", + } + ] + }, + } + + status = self.service(job).get_build_status() + + self.assertEqual("failed", status["state"]) + self.assertIsNone(status["image"]) + + def test_build_job_uses_a_versioned_multiarch_buildkit_image(self): + service = self.service() + service.registry_auth = "registry-pull-secret-int" + + manifest = service.create_build_job("sample-user:python-digest") + + container = manifest["spec"]["template"]["spec"]["containers"][0] + self.assertEqual("moby/buildkit:v0.30.0-rootless", container["image"]) + + def test_build_job_rejects_a_target_with_shell_syntax(self): + with self.assertRaisesRegex(ValueError, "Invalid target image"): + self.service().create_build_job("sample:tag;touch-/tmp/unsafe") + + +if __name__ == "__main__": + unittest.main()