All Downloads are FREE. Search and download functionalities are using the official Maven repository.

scala.cli.commands.github.SecretCreate.scala Maven / Gradle / Ivy

There is a newer version: 1.5.0
Show newest version
package scala.cli.commands.github

import caseapp.core.RemainingArgs
import caseapp.core.help.HelpFormat
import com.github.plokhotnyuk.jsoniter_scala.core.*
import coursier.cache.ArchiveCache
import sttp.client3.*

import java.nio.charset.StandardCharsets
import java.util.Base64

import scala.build.EitherCps.{either, value}
import scala.build.Logger
import scala.cli.commands.publish.ConfigUtil.*
import scala.cli.commands.shared.HelpGroup
import scala.cli.commands.util.ScalaCliSttpBackend
import scala.cli.commands.{ScalaCommand, SpecificationLevel}
import scala.cli.config.{PasswordOption, Secret}
import scala.cli.errors.GitHubApiError
import scala.cli.util.ArgHelpers.*

object SecretCreate extends ScalaCommand[SecretCreateOptions] {

  override def scalaSpecificationLevel: SpecificationLevel = SpecificationLevel.EXPERIMENTAL
  override def helpFormat: HelpFormat = super.helpFormat.withPrimaryGroup(HelpGroup.Secret)
  override def names = List(
    List("github", "secret", "create"),
    List("gh", "secret", "create")
  )

  private def parseSecretKv(input: String): (String, Secret[String]) =
    input.split("=", 2) match {
      case Array(name, value) =>
        PasswordOption.parse(value) match {
          case Left(err)  => sys.error(s"Error parsing secret: $err")
          case Right(opt) => name -> opt.get()
        }
      case _ =>
        sys.error(
          s"Malformed secret '$input' (expected name=password, with password either file:path, command:command, or value:value)"
        )
    }

  def publicKey(
    repoOrg: String,
    repoName: String,
    token: Secret[String],
    backend: SttpBackend[Identity, Any],
    logger: Logger
  ): Either[GitHubApiError, GitHubApi.PublicKey] = either {
    // https://docs.github.com/en/rest/reference/actions#get-a-repository-public-key
    val publicKeyResp = basicRequest
      .get(uri"https://api.github.com/repos/$repoOrg/$repoName/actions/secrets/public-key")
      .header("Authorization", s"token ${token.value}")
      .header("Accept", "application/vnd.github.v3+json")
      .send(backend)

    if (publicKeyResp.code.code != 200)
      value(Left(new GitHubApiError(
        s"Error getting public key (code ${publicKeyResp.code}) for $repoOrg/$repoName"
      )))

    val publicKeyRespBody = publicKeyResp.body match {
      case Left(_) =>
        // should not happen if response code is 200?
        value(Left(new GitHubApiError(
          s"Unexpected missing body in response when listing secrets of $repoOrg/$repoName"
        )))
      case Right(value) => value
    }

    logger.debug(s"Public key: $publicKeyRespBody")

    readFromString(publicKeyRespBody)(GitHubApi.publicKeyCodec)
  }

  def createOrUpdate(
    repoOrg: String,
    repoName: String,
    token: Secret[String],
    secretName: String,
    secretValue: Secret[String],
    pubKey: GitHubApi.PublicKey,
    dummy: Boolean,
    printRequest: Boolean,
    backend: SttpBackend[Identity, Any],
    logger: Logger
  ): Either[GitHubApiError, Boolean] = either {

    val secretBytes = secretValue.value.getBytes(StandardCharsets.UTF_8)

    val encryptedValue = libsodiumjni.Sodium.seal(secretBytes, pubKey.decodedKey)

    val content = GitHubApi.EncryptedSecret(
      encrypted_value = Base64.getEncoder().encodeToString(encryptedValue),
      key_id = pubKey.key_id
    )

    // https://docs.github.com/en/rest/reference/actions#create-or-update-a-repository-secret
    val uri =
      uri"https://api.github.com/repos/$repoOrg/$repoName/actions/secrets/$secretName"
    val requestBody = writeToArray(content)(GitHubApi.encryptedSecretCodec)

    if (printRequest)
      System.out.write(requestBody)

    if (dummy) {
      logger.debug(s"Dummy mode - would have sent a request to $uri")
      logger.message(
        s"Dummy mode - NOT uploading secret $secretName to $repoOrg/$repoName"
      )
      false
    }
    else {
      val r = basicRequest
        .put(uri)
        .header("Authorization", s"token ${token.value}")
        .header("Accept", "application/vnd.github.v3+json")
        .body(requestBody)
        .send(backend)

      r.code.code match {
        case 201 =>
          logger.message(s"  created $secretName")
          true
        case 204 =>
          logger.message(s"  updated $secretName")
          false
        case code =>
          value(Left(new GitHubApiError(
            s"Unexpected status code $code in response when creating secret $secretName in $repoOrg/$repoName"
          )))
      }
    }
  }

  override def runCommand(
    options: SecretCreateOptions,
    args: RemainingArgs,
    logger: Logger
  ): Unit = {
    val secrets = args.all.map(parseSecretKv)

    val backend = ScalaCliSttpBackend.httpURLConnection(logger)

    val pubKey = options.publicKey.filter(_.trim.nonEmpty) match {
      case Some(path) =>
        val content = os.read.bytes(os.Path(path, os.pwd))
        readFromArray(content)(GitHubApi.publicKeyCodec)
      case None =>
        publicKey(
          options.shared.repoOrg,
          options.shared.repoName,
          options.shared.token.get().toConfig,
          backend,
          logger
        ).orExit(logger)
    }

    val cache        = options.coursier.coursierCache(logger.coursierLogger(""))
    val archiveCache = ArchiveCache().withCache(cache)

    LibSodiumJni.init(cache, archiveCache, logger)

    for ((name, secretValue) <- secrets) {

      logger.debug(s"Secret name: $name")

      createOrUpdate(
        options.shared.repoOrg,
        options.shared.repoName,
        options.shared.token.get().toConfig,
        name,
        secretValue,
        pubKey,
        options.dummy,
        options.printRequest,
        backend,
        logger
      ).orExit(logger)
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy