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.