package cbt.sonatype import java.util.Base64 import scala.xml.XML /** * Interface for Sonatype staging plugin HTTP API. * All resources are described here: * https://oss.sonatype.org/nexus-staging-plugin/default/docs/index.html * * Publish proccess via HTTP API described here: * https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API?page=1#comment_204178478 */ private final class SonatypeHttpApi(sonatypeURI: String, sonatypeCredentials: String, profileName: String)(log: String => Unit) { import HttpUtils._ private val base64Credentials = new String(Base64.getEncoder.encode(sonatypeCredentials.getBytes)) // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles.html def getStagingProfile: StagingProfile = { log(s"Retrieving info for profile: $profileName") val (_, response) = GET( uri = s"$sonatypeURI/staging/profiles", headers = Map("Authorization" -> s"Basic $base64Credentials") ) val currentProfile = (XML.loadString(response) \\ "stagingProfile" find { profile => (profile \ "name").headOption.exists(_.text == profileName) }).getOrElse(throw new Exception(s"Failed to get profile with name: $profileName")) StagingProfile( id = (currentProfile \ "id").head.text, name = (currentProfile \ "name").head.text, repositoryTargetId = (currentProfile \ "repositoryTargetId").head.text, resourceURI = (currentProfile \ "resourceURI").head.text ) } // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profile_repositories_-profileIdKey-.html def getStagingRepos(profile: StagingProfile): Seq[StagingRepository] = { log(s"Retrieving staging repositories for profile: $profileName") val (_, response) = GET( uri = s"$sonatypeURI/staging/profile_repositories/${profile.id}", headers = Map( "Authorization" -> s"Basic $base64Credentials" ) ) (XML.loadString(response) \\ "stagingProfileRepository") map extractStagingRepository } // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_repository_-repositoryIdKey-.html private def getStagingRepoById(repoId: StagingRepositoryId): StagingRepository = { log(s"Retrieving staging repo with id: ${repoId.repositoryId}") val (_, response) = GET( uri = s"$sonatypeURI/staging/repository/${repoId.repositoryId}", headers = Map( "Authorization" -> s"Basic $base64Credentials" ) ) extractStagingRepository(XML.loadString(response)) } // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_start.html def createStagingRepo(profile: StagingProfile): StagingRepositoryId = { log(s"Creating staging repositories for profile: $profileName") val (responseCode, response) = POST( uri = profile.resourceURI + "/start", body = createRequestBody("Create staging repository [CBT]").getBytes, headers = Map( "Authorization" -> s"Basic $base64Credentials", "Content-Type" -> "application/xml" ) ) require(responseCode == 201, s"Create staging repo response code. Expected: 201, got: $responseCode") val optRepositoryId = (XML.loadString(response) \ "data" \ "stagedRepositoryId").headOption.map(e => StagingRepositoryId(e.text)) optRepositoryId.getOrElse(throw new Exception(s"Malformed response. Failed to get id of created staging repo")) } def finishRelease(repo: StagingRepository, profile: StagingProfile): Unit = { val repoId = StagingRepositoryId(repo.repositoryId) repo.state match { case Open => closeStagingRepo(profile, repoId) promoteStagingRepo(profile, repoId) dropStagingRepo(profile, repoId) case Closed => promoteStagingRepo(profile, repoId) dropStagingRepo(profile, repoId) case Released => dropStagingRepo(profile, repoId) case Unknown(status) => throw new Exception(s"Got repo in status: ${status}, can't finish release.") } } // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_finish.html private def closeStagingRepo(profile: StagingProfile, repoId: StagingRepositoryId): Unit = { log(s"Closing staging repo: ${repoId.repositoryId}") val (responseCode, _) = POST( uri = profile.resourceURI + "/finish", body = promoteRequestBody( repoId.repositoryId, "Close staging repository [CBT]", profile.repositoryTargetId ).getBytes, headers = Map( "Authorization" -> s"Basic $base64Credentials", "Content-Type" -> "application/xml" ) ) require(responseCode == 201, s"Close staging repo response code. Expected: 201, got: $responseCode") } // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_promote.html // You can promote repository only when it is in "closed" state. private def promoteStagingRepo(profile: StagingProfile, repoId: StagingRepositoryId): Unit = { log(s"Promoting staging repo: ${repoId.repositoryId}") val responseCode = withRetry { // need to get fresh info about this repo val repoState = try getStagingRepoById(repoId) catch { case e: Exception => throw new Exception(s"Repository with id ${repoId.repositoryId} not found. Maybe it was dropped already", e) } if(repoState.state == Closed) { val (code, _) = POST( uri = profile.resourceURI + "/promote", body = promoteRequestBody( repoId.repositoryId, "Promote staging repository [CBT]", profile.repositoryTargetId ).getBytes, headers = Map( "Authorization" -> s"Basic $base64Credentials", "Content-Type" -> "application/xml" ) ) code } else { throw new Exception(s"Can't promote, repository ${repoId.repositoryId} is not in closed state yet!") } } require(responseCode == 201, s"Promote staging repo response code. Expected: 201, got: $responseCode") } // https://oss.sonatype.org/nexus-staging-plugin/default/docs/path__staging_profiles_-profileIdKey-_drop.html // It's safe to drop repository in "released" state. private def dropStagingRepo(profile: StagingProfile, repoId: StagingRepositoryId): Unit = { log(s"Dropping staging repo: ${repoId.repositoryId}") val responseCode = withRetry { // need to get fresh info about this repo val repoState = try getStagingRepoById(repoId) catch { case e: Exception => throw new Exception(s"Repository with id ${repoId.repositoryId} not found. Maybe it was dropped already", e) } if (repoState.state == Released) { val (code, _) = POST( uri = profile.resourceURI + "/drop", body = promoteRequestBody( repoId.repositoryId, "Drop staging repository [CBT]", profile.repositoryTargetId ).getBytes, headers = Map( "Authorization" -> s"Basic $base64Credentials", "Content-Type" -> "application/xml" ) ) code } else { throw new Exception(s"Can't drop, repository ${repoId.repositoryId} is not in released state yet!") } } require(responseCode == 201, s"Drop staging repo response code. Expected: 201, got: $responseCode") } private def promoteRequestBody(repoId: String, description: String, targetRepoId: String) = s""" | | | $repoId | $description | $targetRepoId | | """.stripMargin private def createRequestBody(description: String) = s""" | | | $description | | """.stripMargin private def extractStagingRepository(repo: xml.Node): StagingRepository = StagingRepository( (repo \ "profileId").head.text, (repo \ "profileName").head.text, (repo \ "repositoryId").head.text, RepositoryState.fromString((repo \ "type").head.text) ) }