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. _______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp