diff --git a/craft_application/application.py b/craft_application/application.py index 6a4812ed..42296480 100644 --- a/craft_application/application.py +++ b/craft_application/application.py @@ -33,7 +33,7 @@ import craft_providers from xdg.BaseDirectory import save_cache_path # type: ignore[import] -from craft_application import commands, models, util +from craft_application import commands, models, secrets, util from craft_application.models import BuildInfo if TYPE_CHECKING: @@ -94,6 +94,10 @@ class Application: :ivar services: A ServiceFactory for this application """ + # Feature flag for build-time secrets. Applications that want to use the feature + # must set this to True. + enable_build_secrets = False + def __init__( self, app: AppMetadata, @@ -379,6 +383,10 @@ def _transform_project_yaml(self, yaml_data: dict[str, Any]) -> dict[str, Any]: # Perform variable expansion. self._expand_environment(yaml_data) + # Handle build secrets. + if self.enable_build_secrets: + self._render_secrets(yaml_data) + # Perform extra, application-specific transformations. yaml_data = self._extra_yaml_transform(yaml_data) @@ -412,6 +420,12 @@ def _set_global_environment(self, info: craft_parts.ProjectInfo) -> None: } ) + def _render_secrets(self, yaml_data: dict[str, Any]) -> None: + """Render build-secrets, in-place.""" + secret_values = secrets.render_secrets(yaml_data) + + craft_cli.emit.set_secrets(list(secret_values)) + def _extra_yaml_transform(self, yaml_data: dict[str, Any]) -> dict[str, Any]: """Perform additional transformations on a project's yaml data. diff --git a/tests/integration/data/build-secrets/secret-source-folder/source-file.txt b/tests/integration/data/build-secrets/secret-source-folder/source-file.txt new file mode 100644 index 00000000..8f6a3908 --- /dev/null +++ b/tests/integration/data/build-secrets/secret-source-folder/source-file.txt @@ -0,0 +1 @@ +A source file diff --git a/tests/integration/data/build-secrets/testcraft.yaml b/tests/integration/data/build-secrets/testcraft.yaml new file mode 100644 index 00000000..2a3dfb82 --- /dev/null +++ b/tests/integration/data/build-secrets/testcraft.yaml @@ -0,0 +1,15 @@ +name: build-secrets +description: A project with build-time secrets +version: git +base: ["ubuntu", "22.04"] + +parts: + my-part: + plugin: dump + source: $(HOST_SECRET:echo ${HOST_SOURCE_FOLDER})-folder + build-environment: + - SECRET_VAR: $(HOST_SECRET:echo ${HOST_SECRET_VAR}) + override-build: | + craftctl default + echo "Dumping SECRET_VAR: ${SECRET_VAR}" + echo ${SECRET_VAR} >> ${CRAFT_PART_INSTALL}/build-file.txt diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py index f1635462..29525e82 100644 --- a/tests/integration/test_application.py +++ b/tests/integration/test_application.py @@ -257,3 +257,33 @@ def test_global_environment( assert variables["project_name"] == "environment-project" assert variables["project_dir"] == str(tmp_path) assert variables["project_version"] == "1.2.3" + + +def test_build_secrets_destructive(create_app, monkeypatch, tmp_path, capsys): + """Test the use of build secrets in destructive mode.""" + monkeypatch.setenv("CRAFT_DEBUG", "1") + monkeypatch.chdir(tmp_path) + shutil.copytree(TEST_DATA_DIR / "build-secrets", tmp_path, dirs_exist_ok=True) + + # Run in destructive mode + monkeypatch.setattr("sys.argv", ["testcraft", "prime", "-v", "--destructive-mode"]) + + app = create_app() + app.enable_build_secrets = True + + # Set the environment variables that the project needs + monkeypatch.setenv("HOST_SOURCE_FOLDER", "secret-source") + monkeypatch.setenv("HOST_SECRET_VAR", "my-secret") + app.run() + + prime_dir = tmp_path / "prime" + assert (prime_dir / "source-file.txt").read_text().strip() == "A source file" + assert (prime_dir / "build-file.txt").read_text().strip() == "my-secret" + + # Check that the "secrets" were masked in console output and the logfile + _, stderr = capsys.readouterr() + log_contents = craft_cli.emit._log_filepath.read_text() + + for target in (stderr, log_contents): + assert "Dumping SECRET_VAR: my-secret" not in target + assert "Dumping SECRET_VAR: *****" in target diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 3a89f656..95119261 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -513,3 +513,44 @@ def test_application_expand_environment(app_metadata, fake_services): project = app.project assert project.parts["mypart"]["source-tag"] == "v1.2.3" + + +@pytest.fixture() +def build_secrets_project(monkeypatch, tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + project_path = project_dir / "testcraft.yaml" + project_path.write_text( + dedent( + """ + name: myproject + version: 1.2.3 + parts: + mypart: + plugin: nil + source: $(HOST_SECRET:echo ${SECRET_VAR_1})/project + build-environment: + - MY_VAR: $(HOST_SECRET:echo ${SECRET_VAR_2}) + """ + ) + ) + monkeypatch.chdir(project_dir) + + return project_path + + +@pytest.mark.usefixtures("build_secrets_project") +def test_application_build_secrets(app_metadata, fake_services, monkeypatch, mocker): + monkeypatch.setenv("SECRET_VAR_1", "source-folder") + monkeypatch.setenv("SECRET_VAR_2", "secret-value") + spied_set_secrets = mocker.spy(craft_cli.emit, "set_secrets") + + app = application.Application(app_metadata, fake_services) + app.enable_build_secrets = True + project = app.project + + mypart = project.parts["mypart"] + assert mypart["source"] == "source-folder/project" + assert mypart["build-environment"][0]["MY_VAR"] == "secret-value" + + spied_set_secrets.assert_called_once_with(list({"source-folder", "secret-value"}))