← Back to team overview

launchpad-reviewers team mailing list archive

Re: [Merge] ~ruinedyourlife/launchpad:native-tools-publishing into launchpad:master

 

It mostly looks good to me, but I left some comments I think it is worth to review

Diff comments:

> diff --git a/lib/lp/archiveuploader/craftrecipeupload.py b/lib/lp/archiveuploader/craftrecipeupload.py
> index 7d62488..0596477 100644
> --- a/lib/lp/archiveuploader/craftrecipeupload.py
> +++ b/lib/lp/archiveuploader/craftrecipeupload.py
> @@ -81,12 +81,23 @@ class CraftRecipeUpload:
>                  with tarfile.open(craft_path, "r:xz") as tar:
>                      tar.extractall(path=tmpdir)
>  
> -                # Look for .crate files and metadata.yaml
> +                # Look for .crate files, .jar files, pom.xml, and metadata.yaml
>                  crate_files = list(Path(tmpdir).rglob("*.crate"))
> +                jar_files = list(Path(tmpdir).rglob("*.jar"))
> +                pom_files = list(Path(tmpdir).rglob("pom.xml"))
>                  metadata_path = Path(tmpdir) / "metadata.yaml"
>  
> +                # Check for multiple artifact types
> +                has_crate = bool(crate_files)
> +                has_maven = bool(jar_files and pom_files)
> +
> +                if has_crate and has_maven:

Maybe also worth to check that there is no more than 1 artifact in total per build. Below we are getting only the first element.

> +                    raise UploadError(
> +                        "Archive contains both Rust crate and Maven artifacts."

Adding a space will make it more readable: 
"Archive contains both Rust crate and Maven artifacts. "

> +                        "Only one artifact type is allowed per build."
> +                    )
> +
>                  if crate_files and metadata_path.exists():
> -                    # If we found a crate file and metadata, upload those
>                      try:
>                          metadata = yaml.safe_load(metadata_path.read_text())
>                          crate_name = metadata.get("name")
> diff --git a/lib/lp/archiveuploader/tests/test_craftrecipeupload.py b/lib/lp/archiveuploader/tests/test_craftrecipeupload.py
> index 9796567..aaa48d8 100644
> --- a/lib/lp/archiveuploader/tests/test_craftrecipeupload.py
> +++ b/lib/lp/archiveuploader/tests/test_craftrecipeupload.py
> @@ -87,8 +89,50 @@ class TestCraftRecipeUploads(TestUploadProcessorBase):
>  
>          return archive_path
>  
> -    def test_processes_crate_from_archive(self):
> -        """Test that crates are properly extracted and processed
> +    def _createArchiveWithJarAndPom(
> +        self, upload_dir, jar_name="test-jar", jar_version="0.1.0"
> +    ):
> +        """
> +        Helper to create a tar.xz archive containing a JAR, POM, and metadata.
> +        """
> +        # Create a temporary directory to build our archive
> +        with tempfile.TemporaryDirectory() as tmpdir:
> +            # Create metadata.yaml
> +            metadata = {
> +                "name": jar_name,
> +                "version": jar_version,
> +            }
> +            metadata_path = os.path.join(tmpdir, "metadata.yaml")
> +            with open(metadata_path, "w") as f:
> +                yaml.safe_dump(metadata, f)
> +
> +            # Create dummy JAR file
> +            jar_path = os.path.join(tmpdir, f"{jar_name}-{jar_version}.jar")
> +            with open(jar_path, "wb") as f:
> +                f.write(b"dummy jar contents")
> +
> +            # Create dummy POM file
> +            pom_content = f"""<project>

We can use string inside () concatenation to fit style

> +  <modelVersion>4.0.0</modelVersion>
> +  <groupId>com.example</groupId>
> +  <artifactId>{jar_name}</artifactId>
> +  <version>{jar_version}</version>
> +</project>"""
> +            pom_path = os.path.join(tmpdir, "pom.xml")
> +            with open(pom_path, "w") as f:
> +                f.write(pom_content)
> +
> +            # Create tar.xz archive
> +            archive_path = os.path.join(upload_dir, "output.tar.xz")
> +            with tarfile.open(archive_path, "w:xz") as tar:
> +                tar.add(metadata_path, arcname="metadata.yaml")
> +                tar.add(jar_path, arcname=os.path.basename(jar_path))
> +                tar.add(pom_path, arcname="pom.xml")
> +
> +            return archive_path
> +
> +    def test_processes_artifact_from_archive(self):
> +        """Test that artifacts are properly extracted and processed
>          from archives."""
>          upload_dir = os.path.join(
>              self.incoming_folder, "test", str(self.build.id)
> diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
> index d573665..968d282 100644
> --- a/lib/lp/crafts/model/craftrecipejob.py
> +++ b/lib/lp/crafts/model/craftrecipejob.py
> @@ -337,3 +359,364 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
>              # are to this job's metadata and should be preserved.
>              transaction.commit()
>              raise
> +
> +
> +@implementer(ICraftPublishingJob)
> +@provider(ICraftPublishingJobSource)
> +class CraftPublishingJob(CraftRecipeJobDerived):
> +    """
> +    A Job that publishes craft recipe build artifacts to external
> +    repositories.
> +    """
> +
> +    class_job_type = CraftRecipeJobType.PUBLISH_ARTIFACTS
> +
> +    user_error_types = ()
> +    retry_error_types = ()
> +    max_retries = 5
> +
> +    task_queue = "native_publisher_job"
> +
> +    config = config.ICraftPublishingJobSource
> +
> +    @classmethod
> +    def create(cls, build):
> +        """See `ICraftPublishingJobSource`."""
> +        cls.metadata = {

Why are we using a class variable here?

> +            "build_id": build.id,
> +        }
> +        recipe_job = CraftRecipeJob(
> +            build.recipe, cls.class_job_type, cls.metadata
> +        )
> +        job = cls(recipe_job)
> +        job.celeryRunOnCommit()
> +        IStore(CraftRecipeJob).flush()
> +        return job
> +
> +    @property
> +    def build_id(self):
> +        """See `ICraftPublishingJob`."""
> +        return self.metadata["build_id"]
> +
> +    @cachedproperty
> +    def build(self):
> +        """See `ICraftPublishingJob`."""
> +        return IStore(CraftRecipeBuild).get(CraftRecipeBuild, self.build_id)
> +
> +    @property
> +    def error_message(self):
> +        """See `ICraftPublishingJob`."""
> +        return self.metadata.get("error_message")
> +
> +    @error_message.setter
> +    def error_message(self, message):
> +        """See `ICraftPublishingJob`."""
> +        self.metadata["error_message"] = message
> +
> +    def run(self):
> +        """See `IRunnableJob`."""
> +        try:
> +            # Get the distribution name to access the correct configuration
> +            distribution_name = None
> +            git_repo = self.build.recipe.git_repository
> +            if git_repo is not None:
> +                if IDistributionSourcePackage.providedBy(git_repo.target):
> +                    distribution_name = git_repo.target.distribution.name
> +
> +            if not distribution_name:
> +                self.error_message = (
> +                    "Could not determine distribution for build"
> +                )
> +                raise Exception(self.error_message)
> +
> +            # Get environment variables from configuration
> +            try:
> +                env_vars_json = config["craftbuild." + distribution_name][
> +                    "environment_variables"
> +                ]
> +                if env_vars_json and env_vars_json.lower() != "none":
> +                    env_vars = json.loads(env_vars_json)
> +                    # Replace auth placeholders
> +                    for key, value in env_vars.items():
> +                        if (
> +                            isinstance(value, str)
> +                            and "%(write_auth)s" in value
> +                        ):
> +                            env_vars[key] = value.replace(
> +                                "%(write_auth)s",
> +                                config.artifactory.write_credentials,
> +                            )
> +                else:
> +                    env_vars = {}
> +            except (NoSectionError, KeyError):
> +                self.error_message = (
> +                    f"No configuration found for {distribution_name}"
> +                )
> +                raise Exception(self.error_message)
> +
> +            # Check if we have a .crate file or .jar file
> +            crate_file = None
> +            jar_file = None
> +            pom_file = None
> +
> +            for _, lfa, _ in self.build.getFiles():
> +                if lfa.filename.endswith(".crate"):
> +                    crate_file = lfa
> +                elif lfa.filename.endswith(".jar"):
> +                    jar_file = lfa
> +                elif lfa.filename == "pom.xml":
> +                    pom_file = lfa
> +
> +            # Process the crate file
> +            with tempfile.TemporaryDirectory() as tmpdir:
> +                if crate_file is not None:
> +                    # Download the crate file
> +                    crate_path = os.path.join(tmpdir, crate_file.filename)
> +                    crate_file.open()
> +                    try:
> +                        with open(crate_path, "wb") as f:
> +                            f.write(crate_file.read())
> +                    finally:
> +                        crate_file.close()
> +
> +                    # Create a directory to extract the crate
> +                    crate_extract_dir = os.path.join(tmpdir, "crate_contents")
> +                    os.makedirs(crate_extract_dir, exist_ok=True)
> +
> +                    # Extract the .crate file using system tar command
> +                    result = subprocess.run(
> +                        ["tar", "-xf", crate_path, "-C", crate_extract_dir],
> +                        capture_output=True,
> +                        text=True,
> +                    )
> +
> +                    if result.returncode != 0:
> +                        raise Exception(
> +                            f"Failed to extract crate: {result.stderr}"
> +                        )
> +
> +                    # Find the extracted directory(should be the only one)
> +                    extracted_dirs = [
> +                        d
> +                        for d in os.listdir(crate_extract_dir)
> +                        if os.path.isdir(os.path.join(crate_extract_dir, d))
> +                    ]
> +
> +                    if not extracted_dirs:
> +                        raise Exception(
> +                            "No directory found in extracted crate"
> +                        )
> +
> +                    # Use the first directory as the crate directory
> +                    crate_dir = os.path.join(
> +                        crate_extract_dir, extracted_dirs[0]
> +                    )
> +
> +                    # Publish the Rust crate
> +                    self._publish_rust_crate(crate_dir, env_vars)
> +                elif jar_file is not None and pom_file is not None:
> +                    # Download the jar file
> +                    jar_path = os.path.join(tmpdir, jar_file.filename)
> +                    jar_file.open()
> +                    try:
> +                        with open(jar_path, "wb") as f:
> +                            f.write(jar_file.read())
> +                    finally:
> +                        jar_file.close()
> +
> +                    # Download the pom file
> +                    pom_path = os.path.join(tmpdir, "pom.xml")
> +                    pom_file.open()
> +                    try:
> +                        with open(pom_path, "wb") as f:
> +                            f.write(pom_file.read())
> +                    finally:
> +                        pom_file.close()
> +
> +                    # Publish the Maven artifact
> +                    self._publish_maven_artifact(
> +                        tmpdir,
> +                        env_vars,
> +                        jar_path,
> +                        pom_path,
> +                    )
> +
> +                else:
> +                    raise Exception("No publishable artifacts found in build")
> +
> +        except Exception as e:
> +            self.error_message = str(e)
> +            # The normal job infrastructure will abort the transaction, but
> +            # we want to commit instead: the only database changes we make
> +            # are to this job's metadata and should be preserved.
> +            transaction.commit()
> +            raise
> +
> +    def _publish_rust_crate(self, extract_dir, env_vars):
> +        """Publish Rust crates from the extracted crate directory.
> +
> +        :param extract_dir: Path to the extracted crate directory
> +        :param env_vars: Environment variables from configuration
> +        :raises: Exception if publishing fails
> +        """
> +        # Look for specific Cargo publishing repository configuration
> +        cargo_publish_url = env_vars.get("CARGO_PUBLISH_URL")
> +        cargo_publish_auth = env_vars.get("CARGO_PUBLISH_AUTH")
> +
> +        if not cargo_publish_url or not cargo_publish_auth:
> +            raise Exception(
> +                "Missing Cargo publishing repository configuration"
> +            )
> +
> +        # Replace Cargo.toml with Cargo.toml.orig if it exists
> +        cargo_toml_orig = os.path.join(extract_dir, "Cargo.toml.orig")
> +        cargo_toml = os.path.join(extract_dir, "Cargo.toml")
> +
> +        if os.path.exists(cargo_toml_orig):
> +            import shutil
> +
> +            shutil.move(cargo_toml_orig, cargo_toml)
> +
> +        # Set up cargo config
> +        cargo_dir = os.path.join(extract_dir, ".cargo")
> +        os.makedirs(cargo_dir, exist_ok=True)
> +
> +        # Create config.toml
> +        with open(os.path.join(cargo_dir, "config.toml"), "w") as f:
> +            f.write(
> +                """
> +[registry]
> +global-credential-providers = ["cargo:token"]
> +
> +[registries.launchpad]
> +index = "{}"
> +""".format(
> +                    cargo_publish_url
> +                )
> +            )
> +
> +        # Create credentials.toml
> +        with open(os.path.join(cargo_dir, "credentials.toml"), "w") as f:
> +            f.write(
> +                """
> +[registries.launchpad]
> +token = "Bearer {}"
> +""".format(
> +                    cargo_publish_auth
> +                )
> +            )
> +
> +        # Run cargo publish from the extracted directory
> +        result = subprocess.run(
> +            [
> +                "cargo",
> +                "publish",
> +                "--no-verify",
> +                "--allow-dirty",
> +                "--registry",
> +                "launchpad",
> +            ],
> +            capture_output=True,
> +            cwd=extract_dir,
> +            env={"CARGO_HOME": cargo_dir},
> +        )
> +
> +        if result.returncode != 0:
> +            raise Exception(f"Failed to publish crate: {result.stderr}")
> +
> +    def _publish_maven_artifact(
> +        self, work_dir, env_vars, jar_path=None, pom_path=None
> +    ):
> +        """Publish Maven artifacts.
> +
> +        :param work_dir: Working directory
> +        :param env_vars: Environment variables from configuration
> +        :param jar_path: Path to the JAR file
> +        :param pom_path: Path to the pom.xml file
> +        :raises: Exception if publishing fails
> +        """
> +        # Look for specific Maven publishing repository configuration
> +        maven_publish_url = env_vars.get("MAVEN_PUBLISH_URL")
> +        maven_publish_auth = env_vars.get("MAVEN_PUBLISH_AUTH")
> +
> +        if not maven_publish_url or not maven_publish_auth:
> +            raise Exception(
> +                "Missing Maven publishing repository configuration"
> +            )
> +
> +        if jar_path is None or pom_path is None:
> +            raise Exception("Missing JAR or POM file for Maven publishing")
> +
> +        # Set up Maven settings
> +        maven_dir = os.path.join(work_dir, ".m2")
> +        os.makedirs(maven_dir, exist_ok=True)
> +
> +        # Extract username and password from auth string
> +        if ":" in maven_publish_auth:
> +            username, password = maven_publish_auth.split(":", 1)
> +        else:
> +            username = "token"
> +            password = maven_publish_auth
> +
> +        # Generate settings.xml content
> +        settings_xml = self._get_maven_settings_xml(username, password)
> +
> +        with open(os.path.join(maven_dir, "settings.xml"), "w") as f:
> +            f.write(settings_xml)
> +
> +        # Run mvn deploy using the pom file
> +        result = subprocess.run(
> +            [
> +                "mvn",
> +                "deploy:deploy-file",
> +                f"-DpomFile={pom_path}",
> +                f"-Dfile={jar_path}",
> +                "-DrepositoryId=central",
> +                f"-Durl={maven_publish_url}",
> +                "--settings={}".format(
> +                    os.path.join(maven_dir, "settings.xml")
> +                ),
> +            ],
> +            capture_output=True,
> +            cwd=work_dir,
> +        )
> +
> +        if result.returncode != 0:
> +            raise Exception(
> +                f"Failed to publish Maven artifact: {result.stderr}"
> +            )
> +
> +    def _get_maven_settings_xml(self, username, password):
> +        """Generate Maven settings.xml content.
> +
> +        :param username: Maven repository username
> +        :param password: Maven repository password
> +        :return: XML content as string
> +        """
> +        return f"""<?xml version="1.0" encoding="UTF-8"?>
> +<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0";
> +        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
> +        xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 \
> +http://maven.apache.org/xsd/settings-1.0.0.xsd";>
> +    <servers>
> +        <server>
> +            <id>central</id>
> +            <username>{username}</username>
> +            <password>{password}</password>
> +        </server>
> +    </servers>
> +    <profiles>
> +        <profile>
> +            <id>system</id>
> +            <pluginRepositories>
> +                <pluginRepository>
> +                    <id>central</id>
> +                    <url>file:///usr/share/maven-repo</url>
> +                </pluginRepository>
> +            </pluginRepositories>
> +        </profile>
> +    </profiles>
> +    <activeProfiles>
> +        <activeProfile>system</activeProfile>
> +    </activeProfiles>
> +</settings>"""


-- 
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/483691
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:native-tools-publishing into launchpad:master.



References