diff --git a/rockcraft/commands/init.py b/rockcraft/commands/init.py index e0a5deae1..e37a60102 100644 --- a/rockcraft/commands/init.py +++ b/rockcraft/commands/init.py @@ -98,8 +98,13 @@ class InitCommand(AppCommand): most important story about it. Keep it under 100 words though, we live in tweetspace and your description wants to look good in the container registries out there. - platforms: # the platforms this rock should be built on and run on + # the platforms this rock should be built on and run on. + # you can check your architecture with `dpkg --print-architecture` + platforms: amd64: + # arm64: + # ppc64el: + # s390x: # to ensure the flask-framework extension works properly, your Flask application # should have an `app.py` file with an `app` object as the WSGI entrypoint. @@ -171,8 +176,13 @@ class InitCommand(AppCommand): most important story about it. Keep it under 100 words though, we live in tweetspace and your description wants to look good in the container registries out there. - platforms: # The platforms this rock should be built on and run on + # the platforms this rock should be built on and run on. + # you can check your architecture with `dpkg --print-architecture` + platforms: amd64: + # arm64: + # ppc64el: + # s390x: # to ensure the django-framework extension functions properly, your Django project # should have a structure similar to the following with ./{snake_name}/{snake_name}/wsgi.py @@ -201,6 +211,71 @@ class InitCommand(AppCommand): """ ) ), + "go-framework": _InitProfile( + rockcraft_yaml=textwrap.dedent( + """\ + name: {name} + # see {versioned_url}/explanation/bases/ + # for more information about bases and using 'bare' bases for chiselled rocks + base: bare # as an alternative, a ubuntu base can be used + build-base: ubuntu@24.04 # build-base is required when the base is bare + version: '0.1' # just for humans. Semantic versioning is recommended + summary: A summary of your Go application # 79 char long summary + description: | + This is {name}'s description. You have a paragraph or two to tell the + most important story about it. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the + container registries out there. + # the platforms this rock should be built on and run on. + # you can check your architecture with `dpkg --print-architecture` + platforms: + amd64: + # arm64: + # ppc64el: + # s390x: + + # to ensure the go-framework extension functions properly, your Go project + # should have a go.mod file. Check the parts section for the selection of + # the default binary. + # see {versioned_url}/reference/extensions/go-framework + # for more information. + # +-- {snake_name} + # | |-- go.mod + # | |-- migrate.sh + + extensions: + - go-framework + + # uncomment the sections you need and adjust according to your requirements. + # parts: + + # go-framework/install-app: + # # select a specific Go version. Otherwise the current stable one will be used. + # build-snaps: + # - go/1.22/stable + # organize: + # # if the main package is in the base directory and the rockcraft name + # # attribute is equal to the go module name, the name of the server will + # # be selected correctly, otherwise you can adjust it. + # # the file in /usr/local/bin/ with the name of the rockcraft project will be + # # the binary to run your server. + # # you can also include here other binary files to be included in the rock. + # bin/otherbinary: usr/local/bin/projectname + + # go-framework/assets: + # stage: + # # by default, only the files in templates/ and static/ + # # are copied into the image. You can modify the list below to override + # # the default list and include or exclude specific files/directories + # # in your project. + # # note: Prefix each entry with "app/" followed by the local path. + # - app/templates + # - app/static + # - app/otherdirectory + # - app/otherfile + """ + ) + ), } _DEFAULT_PROFILE = "simple" diff --git a/rockcraft/extensions/__init__.py b/rockcraft/extensions/__init__.py index 7ac287843..63d7acd76 100644 --- a/rockcraft/extensions/__init__.py +++ b/rockcraft/extensions/__init__.py @@ -17,6 +17,7 @@ """Extension processor and related utilities.""" from ._utils import apply_extensions +from .go import GoFramework from .gunicorn import DjangoFramework, FlaskFramework from .registry import get_extension_class, get_extension_names, register, unregister @@ -30,3 +31,4 @@ register("django-framework", DjangoFramework) register("flask-framework", FlaskFramework) +register("go-framework", GoFramework) diff --git a/rockcraft/extensions/go.py b/rockcraft/extensions/go.py new file mode 100644 index 000000000..eb907f859 --- /dev/null +++ b/rockcraft/extensions/go.py @@ -0,0 +1,214 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""An extension for Go based applications.""" + +import os +import re +from typing import Any, Dict, Tuple + +from overrides import override + +from ..errors import ExtensionError +from .extension import Extension + + +class GoFramework(Extension): + """An extension class for Go applications.""" + + @staticmethod + @override + def get_supported_bases() -> Tuple[str, ...]: + """Return supported bases.""" + return "bare", "ubuntu@24.04" + + @staticmethod + @override + def is_experimental(base: str | None) -> bool: + """Check if the extension is in an experimental state.""" + return True + + @override + def get_root_snippet(self) -> dict[str, Any]: + """Return the root snippet to apply.""" + self._check_project() + + snippet: Dict[str, Any] = { + "run_user": "_daemon_", + "services": { + "go": { + "override": "replace", + "startup": "enabled", + "command": self.project_name, + "user": "_daemon_", + "working-dir": "/app", + }, + }, + } + + snippet["parts"] = { + # This is needed in case there is no assets part, as the working directory is /app + "go-framework/base-layout": { + "plugin": "nil", + "override-build": "mkdir -p ${CRAFT_PART_INSTALL}/app", + }, + "go-framework/install-app": self._get_install_app_part(), + "go-framework/runtime": { + "plugin": "nil", + "stage-packages": ["ca-certificates_data"], + }, + } + + assets_part = self._get_install_assets_part() + if assets_part: + snippet["parts"]["go-framework/assets"] = assets_part + + return snippet + + @override + def get_parts_snippet(self) -> dict[str, Any]: + """Return the parts to add to parts.""" + return {} + + @override + def get_part_snippet(self) -> dict[str, Any]: + """Return the part snippet to apply to existing parts.""" + return {} + + @property + def project_name(self) -> str: + """Return the normalized name of the rockcraft project.""" + return self.yaml_data["name"] + + def _check_project(self) -> None: + """Check go.mod file exist in project.""" + if not (self.project_root / "go.mod").exists(): + raise ExtensionError( + "missing go.mod file", + doc_slug="/reference/extensions/go-framework", + logpath_report=False, + ) + + def _get_install_app_part(self) -> Dict[str, Any]: + """Generate install-app part with the Go plugin.""" + install_app = self._get_nested( + self.yaml_data, ["parts", "go-framework/install-app"] + ) + + build_environment = install_app.get("build-environment", []) + if self.yaml_data["base"] == "bare": + for env_var in build_environment: + if "CGO_ENABLED" in env_var: + break + else: + build_environment = [{"CGO_ENABLED": "0"}] + + organize = install_app.get("organize", {}) + binary_path = f"usr/local/bin/{self.project_name}" + for path in organize.values(): + if path == binary_path: + break + else: + if not self._get_nested(self.yaml_data, ["services", "go", "command"]): + organize[f"bin/{self.project_name}"] = binary_path + + install_app_part = { + "plugin": "go", + "source": ".", + "organize": organize, + } + + if not self._check_go_overriden(): + build_snaps = install_app.get("build-snaps", []) + build_snaps.append("go") + install_app_part["build-snaps"] = build_snaps + + install_app_part["stage"] = list(organize.values()) + if build_environment: + install_app_part["build-environment"] = build_environment + + return install_app_part + + def _check_go_overriden(self) -> bool: + """Check if the user overrode the go snap or package for the build step.""" + install_app = self._get_nested( + self.yaml_data, ["parts", "go-framework/install-app"] + ) + build_snaps = install_app.get("build-snaps", []) + if build_snaps: + for snap in build_snaps: + if snap.startswith("go"): + return True + build_packages = install_app.get("build-packages", []) + if build_packages: + for package in build_packages: + if package in ["gccgo-go", "golang-go"]: + return True + return False + + def _get_install_assets_part(self) -> Dict[str, Any] | None: + """Generate assets-stage part for extra assets in the project.""" + # if stage is not in exclude mode, use it to generate organize + if ( + self._assets_stage + and self._assets_stage[0] + and self._assets_stage[0][0] != "-" + ): + renaming_map = { + os.path.relpath(file, "app"): file for file in self._assets_stage + } + else: + return None + + return { + "plugin": "dump", + "source": ".", + "organize": renaming_map, + "stage": self._assets_stage, + } + + @property + def _assets_stage(self) -> list[str]: + """Return the assets stage list for the Go project.""" + user_stage = self._get_nested( + self.yaml_data, ["parts", "go-framework/assets"] + ).get("stage", []) + + if not all(re.match("-? *app/", p) for p in user_stage): + raise ExtensionError( + "go-framework extension requires the 'stage' entry in the " + "go-framework/assets part to start with app", + doc_slug="/reference/extensions/go-framework", + logpath_report=False, + ) + if not user_stage: + user_stage = [ + f"app/{f}" + for f in ( + "migrate", + "migrate.sh", + "static", + "templates", + ) + if (self.project_root / f).exists() + ] + return user_stage + + def _get_nested(self, obj: dict, paths: list[str]) -> dict: + """Get a nested object using a path (a list of keys).""" + for key in paths: + obj = obj.get(key, {}) + return obj diff --git a/tests/spread/rockcraft/extension-go/README.md b/tests/spread/rockcraft/extension-go/README.md new file mode 100644 index 000000000..30d74d258 --- /dev/null +++ b/tests/spread/rockcraft/extension-go/README.md @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/tests/spread/rockcraft/extension-go/go.mod b/tests/spread/rockcraft/extension-go/go.mod new file mode 100644 index 000000000..b21ec1fa4 --- /dev/null +++ b/tests/spread/rockcraft/extension-go/go.mod @@ -0,0 +1,3 @@ +module github.com/canonical/NAME + +go 1.22.4 diff --git a/tests/spread/rockcraft/extension-go/main.go b/tests/spread/rockcraft/extension-go/main.go new file mode 100644 index 000000000..1b61e3959 --- /dev/null +++ b/tests/spread/rockcraft/extension-go/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "net/http" +) + +func hello(w http.ResponseWriter, req *http.Request) { + fmt.Fprintf(w, "ok") +} + +func main() { + http.HandleFunc("/", hello) + http.ListenAndServe(":8000", nil) +} diff --git a/tests/spread/rockcraft/extension-go/task.yaml b/tests/spread/rockcraft/extension-go/task.yaml new file mode 100644 index 000000000..1fdcd7916 --- /dev/null +++ b/tests/spread/rockcraft/extension-go/task.yaml @@ -0,0 +1,46 @@ +summary: go extension test +environment: + SCENARIO/bare: bare + SCENARIO/base_2404: ubuntu-24.04 + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS: "true" + +execute: | + NAME="go-${SCENARIO//./-}" + ROCK_FILE="${NAME}_0.1_amd64.rock" + IMAGE="${NAME}:0.1" + + run_rockcraft init --name "${NAME}" --profile go-framework + sed -i "s/^name: .*/name: ${NAME}/g" rockcraft.yaml + sed -i "s/^base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml + if [ "${SCENARIO}" != "bare" ]; then + sed -i "s/^build-base: .*/build-base: ${SCENARIO//-/@}/g" rockcraft.yaml + fi + sed -i "s/NAME/${NAME}/g" go.mod + + run_rockcraft pack + + test -f "${ROCK_FILE}" + test ! -d work + + # Ensure docker does not have this container image + docker rmi --force "${IMAGE}" + # Install container + sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" "docker-daemon:${IMAGE}" + # Ensure container exists + docker images "${IMAGE}" | MATCH "${NAME}" + + # ensure container doesn't exist + docker rm -f "${NAME}-container" + + # test the default go service + docker run --name "${NAME}-container" -d -p 8137:8000 "${IMAGE}" + retry -n 5 --wait 2 curl localhost:8137 + [ "$(curl -sSf localhost:8137)" == "ok" ] + +restore: | + NAME="go-${SCENARIO//./-}" + sed -i "s/${NAME}/NAME/g" go.mod + docker stop "${NAME}-container" + docker rm "${NAME}-container" + rm -f "*.rock" rockcraft.yaml + docker system prune -a -f diff --git a/tests/unit/extensions/test_go.py b/tests/unit/extensions/test_go.py new file mode 100644 index 000000000..67ccea7d2 --- /dev/null +++ b/tests/unit/extensions/test_go.py @@ -0,0 +1,237 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +from rockcraft import extensions +from rockcraft.errors import ExtensionError + + +@pytest.fixture(name="go_input_yaml") +def go_input_yaml_fixture(): + return { + "name": "goprojectname", + "base": "ubuntu@24.04", + "platforms": {"amd64": {}}, + "extensions": ["go-framework"], + } + + +@pytest.fixture +def go_extension(mock_extensions, monkeypatch): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + extensions.register("go-framework", extensions.GoFramework) + + +@pytest.mark.usefixtures("go_extension") +def test_go_extension_default(tmp_path, go_input_yaml): + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + + assert applied == { + "base": "ubuntu@24.04", + "name": "goprojectname", + "platforms": {"amd64": {}}, + "run_user": "_daemon_", + "parts": { + "go-framework/base-layout": { + "override-build": "mkdir -p ${CRAFT_PART_INSTALL}/app", + "plugin": "nil", + }, + "go-framework/install-app": { + "plugin": "go", + "source": ".", + "build-snaps": ["go"], + "organize": {"bin/goprojectname": "usr/local/bin/goprojectname"}, + "stage": ["usr/local/bin/goprojectname"], + }, + "go-framework/runtime": { + "plugin": "nil", + "stage-packages": [ + "ca-certificates_data", + ], + }, + }, + "services": { + "go": { + "command": "goprojectname", + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + }, + }, + } + + +@pytest.mark.usefixtures("go_extension") +def test_go_extension_no_go_mod_file_error(tmp_path, go_input_yaml): + (tmp_path / "somefile").write_text("random text") + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, go_input_yaml) + assert str(exc.value) == "missing go.mod file" + assert str(exc.value.doc_slug) == "/reference/extensions/go-framework" + + +@pytest.mark.usefixtures("go_extension") +@pytest.mark.parametrize("build_environment", [[], [{"OTHER_ENV_VAR": "val"}]]) +def test_go_extension_base_bare(tmp_path, go_input_yaml, build_environment): + go_input_yaml["base"] = "bare" + if build_environment: + go_input_yaml["parts"] = { + "go-framework/install-app": {"build-environment": build_environment}, + } + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + + assert "build-environment" in applied["parts"]["go-framework/install-app"] + applied_build_environment = applied["parts"]["go-framework/install-app"][ + "build-environment" + ] + assert applied_build_environment == [{"CGO_ENABLED": "0"}, *build_environment] + + +@pytest.mark.usefixtures("go_extension") +@pytest.mark.parametrize( + "organize, expected_organize", + [ + ({}, {"bin/goprojectname": "usr/local/bin/goprojectname"}), + ( + { + "bin/anotherbinary": "usr/local/bin/anotherbinary", + }, + { + "bin/goprojectname": "usr/local/bin/goprojectname", + "bin/anotherbinary": "usr/local/bin/anotherbinary", + }, + ), + ( + { + "bin/anotherbinary": "usr/local/bin/goprojectname", + }, + {"bin/anotherbinary": "usr/local/bin/goprojectname"}, + ), + ], +) +def test_go_extension_overrides_organize( + tmp_path, go_input_yaml, organize, expected_organize +): + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + if organize: + go_input_yaml["parts"] = { + "go-framework/install-app": {"organize": organize}, + } + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + assert applied["parts"]["go-framework/install-app"]["organize"] == expected_organize + assert sorted(applied["parts"]["go-framework/install-app"]["stage"]) == sorted( + expected_organize.values() + ) + + +@pytest.mark.usefixtures("go_extension") +@pytest.mark.parametrize( + "build_packages, build_snaps, expected_build_snaps", + [ + ([], [], ["go"]), + ([], ["go/1.22/stable"], ["go/1.22/stable"]), + ([], ["node"], ["go", "node"]), + (["golang-go"], [], []), + (["libp-dev"], [], ["go"]), + ], +) +def test_go_extension_override_build_snaps( + tmp_path, go_input_yaml, build_packages, build_snaps, expected_build_snaps +): + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + if build_snaps or build_packages: + go_input_yaml["parts"] = { + "go-framework/install-app": { + "build-snaps": build_snaps, + "build-packages": build_packages, + }, + } + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + assert sorted( + applied["parts"]["go-framework/install-app"]["build-snaps"] + ) == sorted(expected_build_snaps) + + +@pytest.mark.usefixtures("go_extension") +def test_go_extension_override_service_go_command(tmp_path, go_input_yaml): + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + go_input_yaml["parts"] = { + "go-framework/install-app": { + "organize": { + "bin/randombinary": "usr/local/bin/randombinary", + }, + }, + } + go_input_yaml["services"] = { + "go": {"command": "time /user/local/bin/randombinary"}, + } + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + # No extra binary added to organize or stage, as the user decided to override services.go.command + assert applied["parts"]["go-framework/install-app"]["organize"] == { + "bin/randombinary": "usr/local/bin/randombinary" + } + assert applied["parts"]["go-framework/install-app"]["stage"] == [ + "usr/local/bin/randombinary" + ] + assert applied["services"]["go"]["command"] == "time /user/local/bin/randombinary" + + +@pytest.mark.usefixtures("go_extension") +def test_go_extension_extra_assets(tmp_path, go_input_yaml): + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + (tmp_path / "static").mkdir() + (tmp_path / "templates").mkdir() + (tmp_path / "migrate").write_text("migrate") + (tmp_path / "migrate.sh").write_text("migrate") + (tmp_path / "node_modules").mkdir() + (tmp_path / "test").write_text("test") + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + assert applied["parts"]["go-framework/assets"] == { + "plugin": "dump", + "source": ".", + "organize": { + "static": "app/static", + "migrate": "app/migrate", + "migrate.sh": "app/migrate.sh", + "templates": "app/templates", + }, + "stage": ["app/migrate", "app/migrate.sh", "app/static", "app/templates"], + } + + +@pytest.mark.usefixtures("go_extension") +def test_go_extension_extra_assets_overridden(tmp_path, go_input_yaml): + (tmp_path / "go.mod").write_text("module projectname\n\ngo 1.22.4") + (tmp_path / "static").mkdir() + go_input_yaml["parts"] = { + "go-framework/assets": { + "plugin": "dump", + "source": ".", + "stage": ["app/foobar"], + } + } + applied = extensions.apply_extensions(tmp_path, go_input_yaml) + assert applied["parts"]["go-framework/assets"] == { + "plugin": "dump", + "source": ".", + "organize": { + "foobar": "app/foobar", + }, + "stage": ["app/foobar"], + } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 615cd072f..dbc453872 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -150,8 +150,13 @@ def test_run_init_flask(mocker, emitter, monkeypatch, new_dir, tmp_path): most important story about it. Keep it under 100 words though, we live in tweetspace and your description wants to look good in the container registries out there. - platforms: # the platforms this rock should be built on and run on + # the platforms this rock should be built on and run on. + # you can check your architecture with `dpkg --print-architecture` + platforms: amd64: + # arm64: + # ppc64el: + # s390x: # to ensure the flask-framework extension works properly, your Flask application # should have an `app.py` file with an `app` object as the WSGI entrypoint.