diff --git a/README.md b/README.md index 8cde215df0..3eec26ec7e 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,12 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) |------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ./modules/ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ./modules/termination-watcher | n/a | +| [rotating\_random](#module\_rotating\_random) | ./modules/rotating-random | n/a | | [runner\_binaries](#module\_runner\_binaries) | ./modules/runner-binaries-syncer | n/a | | [runners](#module\_runners) | ./modules/runners | n/a | | [ssm](#module\_ssm) | ./modules/ssm | n/a | | [webhook](#module\_webhook) | ./modules/webhook | n/a | +| [webhook\_github\_app](#module\_webhook\_github\_app) | ./modules/webhook-github-app | n/a | ## Resources @@ -140,8 +142,8 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling.

`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), null)
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | -| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB - github.com. However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). |
object({
key_base64 = string
id = string
webhook_secret = string
})
| n/a | yes | +| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com | `string` | `null` | no | +| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`)."

If `webhook_secret` is not set, a random secret will be generated and stored in SSM. The secret is used to validate the webhook events. If you want to use your own secret, set the `webhook_secret` parameter.
When the secret is managed by the module, it will be rotated every `webhook_secret_rotation_days` days. |
object({
key_base64 = string
id = string
webhook_secret = optional(string)
webhook_secret_rotation_days = optional(number, 30)
})
| n/a | yes | | [idle\_config](#input\_idle\_config) | List of time periods, defined as a cron expression, to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for spot instances. AWS recommends using `price-capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | | [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | diff --git a/docs/configuration.md b/docs/configuration.md index c7f53121ed..2c66340003 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,7 @@ To be able to support a number of use-cases, the module has quite a lot of confi - Spot vs on-demand. The runners use either the EC2 spot or on-demand life cycle. Runners will be created via the AWS [CreateFleet API](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateFleet.html). The module (scale up lambda) will request via the CreateFleet API to create instances in one of the subnets and of the specified instance types. - ARM64 support via Graviton/Graviton2 instance-types. When using the default example or top-level module, specifying `instance_types` that match a Graviton/Graviton 2 (ARM64) architecture (e.g. a1, t4g or any 6th-gen `g` or `gd` type), you must also specify `runner_architecture = "arm64"` and the sub-modules will be automatically configured to provision with ARM64 AMIs and leverage GitHub's ARM64 action runner. See below for more details. - Disable default labels for the runners (os, architecture and `self-hosted`) can achieve by setting `runner_disable_default_labels` = true. If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM. +- Managed vs self-managed webhook secret. The module can manage the webhook secret for you. In that case simply do not provide a value for `github_app.webhook_secret`. If you want to manage the secret yourself, provide a value for `github_app.webhook_secret`. The secret will be managed and a rotation is triggered once running terraform apply again after `github_app.webhook_secret_rotation_days` days. **Important note**: THe managed webhook secret depends on a local-exec (bash) to update the secret in GitNub. It will also update the webhook url. ## AWS SSM Parameters diff --git a/docs/getting-started.md b/docs/getting-started.md index cfd8f7b88b..37872d7eb7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -85,7 +85,7 @@ module "github-runner" { github_app = { key_base64 = "base64string" id = "1" - webhook_secret = "webhook_secret" + webhook_secret = "webhook_secret" # optional, if not set the module will manage the secret. } webhook_lambda_zip = "lambdas-download/webhook.zip" @@ -109,7 +109,7 @@ The lambda for syncing the GitHub distribution to S3 is triggered via CloudWatch ### Setup the webhook / GitHub App (part 2) At this point you have two options. Either create a separate webhook (enterprise, -org, or repo), or create a webhook in the App. +org, or repo), or create a webhook in the App. In case you have not provided a Webhook secret the module will create one and update the GitHub app with both the secret and the webhook url. #### Option 1: Webhook diff --git a/examples/default/.terraform.lock.hcl b/examples/default/.terraform.lock.hcl index 045fb7350a..c236658c15 100644 --- a/examples/default/.terraform.lock.hcl +++ b/examples/default/.terraform.lock.hcl @@ -83,3 +83,23 @@ provider "registry.terraform.io/hashicorp/random" { "zh:eff58323099f1bd9a0bec7cb04f717e7f1b2774c7d612bf7581797e1622613a0", ] } + +provider "registry.terraform.io/hashicorp/time" { + version = "0.12.1" + constraints = "~> 0.7" + hashes = [ + "h1:JzYsPugN8Fb7C4NlfLoFu7BBPuRVT2/fCOdCaxshveI=", + "zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2", + "zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea", + "zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511", + "zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38", + "zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869", + "zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e", + "zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625", + "zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136", + "zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b", + "zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44", + ] +} diff --git a/examples/default/README.md b/examples/default/README.md index 771b2c7bab..463d54482a 100644 --- a/examples/default/README.md +++ b/examples/default/README.md @@ -40,9 +40,7 @@ terraform output -raw webhook_secret ## Providers -| Name | Version | -|------|---------| -| [random](#provider\_random) | 3.6.3 | +No providers. ## Modules @@ -50,13 +48,10 @@ terraform output -raw webhook_secret |------|--------|---------| | [base](#module\_base) | ../base | n/a | | [runners](#module\_runners) | ../../ | n/a | -| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../modules/webhook-github-app | n/a | ## Resources -| Name | Type | -|------|------| -| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +No resources. ## Inputs @@ -72,5 +67,4 @@ terraform output -raw webhook_secret |------|-------------| | [runners](#output\_runners) | n/a | | [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | -| [webhook\_secret](#output\_webhook\_secret) | n/a | diff --git a/examples/default/main.tf b/examples/default/main.tf index 42608fae40..7ff2a97ac9 100644 --- a/examples/default/main.tf +++ b/examples/default/main.tf @@ -3,10 +3,6 @@ locals { aws_region = var.aws_region } -resource "random_id" "random" { - byte_length = 20 -} - module "base" { source = "../base" @@ -27,9 +23,9 @@ module "runners" { } github_app = { - key_base64 = var.github_app.key_base64 - id = var.github_app.id - webhook_secret = random_id.random.hex + key_base64 = var.github_app.key_base64 + id = var.github_app.id + # webhook_secret = random_id.random.hex } # configure the block device mappings, default for Amazon Linux2 @@ -143,18 +139,6 @@ module "runners" { # kms_key_arn = aws_kms_key.github.arn } -module "webhook_github_app" { - source = "../../modules/webhook-github-app" - depends_on = [module.runners] - - github_app = { - key_base64 = var.github_app.key_base64 - id = var.github_app.id - webhook_secret = random_id.random.hex - } - webhook_endpoint = module.runners.webhook.endpoint -} - # enable CMK instead of aws managed key for encryptions # resource "aws_kms_key" "github" { # is_enabled = true diff --git a/examples/default/outputs.tf b/examples/default/outputs.tf index c50214f566..2709fc69b3 100644 --- a/examples/default/outputs.tf +++ b/examples/default/outputs.tf @@ -7,9 +7,3 @@ output "runners" { output "webhook_endpoint" { value = module.runners.webhook.endpoint } - -output "webhook_secret" { - sensitive = true - value = random_id.random.hex -} - diff --git a/main.tf b/main.tf index 3f3c9808b4..19e5c61036 100644 --- a/main.tf +++ b/main.tf @@ -12,6 +12,17 @@ locals { runner_labels = (var.runner_disable_default_labels == false) ? sort(concat(local.default_runner_labels, var.runner_extra_labels)) : var.runner_extra_labels ssm_root_path = var.ssm_paths.use_prefix ? "/${var.ssm_paths.root}/${var.prefix}" : "/${var.ssm_paths.root}" + + github_app = merge(var.github_app, { + webhook_secret = var.github_app.webhook_secret != null ? var.github_app.webhook_secret : module.rotating_random[0].random.hex + }) +} + +module "rotating_random" { + count = var.github_app.webhook_secret == null ? 1 : 0 + source = "./modules/rotating-random" + + rotation_days = var.github_app.webhook_secret_rotation_days } resource "random_string" "random" { @@ -91,10 +102,18 @@ module "ssm" { kms_key_arn = var.kms_key_arn path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" - github_app = var.github_app + github_app = local.github_app tags = local.tags } +module "webhook_github_app" { + count = var.github_app.webhook_secret == null ? 1 : 0 + source = "./modules/webhook-github-app" + + github_app = local.github_app + webhook_endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}" +} + module "webhook" { source = "./modules/webhook" diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index de8811bc49..88cd5de7f4 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -95,10 +95,12 @@ module "multi-runner" { |------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | +| [rotating\_random](#module\_rotating\_random) | ./../rotating-random | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | | [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | | [webhook](#module\_webhook) | ../webhook | n/a | +| [webhook\_github\_app](#module\_webhook\_github\_app) | ./../webhook-github-app | n/a | ## Resources @@ -130,8 +132,8 @@ module "multi-runner" { | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | -| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB.However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com| `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). |
object({
key_base64 = string
id = string
webhook_secret = string
})
| n/a | yes | +| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | +| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`)."

If `webhook_secret` is not set, a random secret will be generated and stored in SSM. The secret is used to validate the webhook events. If you want to use your own secret, set the `webhook_secret` parameter.
When the secret is managed by the module, it will be rotated every `webhook_secret_rotation_days` days. |
object({
key_base64 = string
id = string
webhook_secret = optional(string)
webhook_secret_rotation_days = optional(number, 30)
})
| n/a | yes | | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
`memory_size`: Memory size linit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | | [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 22ec0df3ba..ecdf79eb9a 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -16,6 +16,17 @@ locals { unique_os_and_arch = { for i, v in local.tmp_distinct_list_unique_os_and_arch : "${v.os_type}_${v.architecture}" => v } ssm_root_path = "/${var.ssm_paths.root}/${var.prefix}" + + github_app = merge(var.github_app, { + webhook_secret = var.github_app.webhook_secret != null ? var.github_app.webhook_secret : module.rotating_random[0].random.hex + }) +} + +module "rotating_random" { + count = var.github_app.webhook_secret == null ? 1 : 0 + source = "./../rotating-random" + + rotation_days = var.github_app.webhook_secret_rotation_days } resource "random_string" "random" { diff --git a/modules/multi-runner/ssm.tf b/modules/multi-runner/ssm.tf index 6b2591f465..ae914e7471 100644 --- a/modules/multi-runner/ssm.tf +++ b/modules/multi-runner/ssm.tf @@ -3,6 +3,6 @@ module "ssm" { kms_key_arn = var.kms_key_arn path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" - github_app = var.github_app + github_app = local.github_app tags = local.tags } diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 8e4897985b..5b636b5b72 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -1,9 +1,15 @@ variable "github_app" { - description = "GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`)." + description = < This module is treated as internal module, breaking changes will not trigger a major release bump. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [random](#requirement\_random) | ~> 3 | +| [time](#requirement\_time) | ~> 0.12 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | ~> 3 | +| [time](#provider\_time) | ~> 0.12 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [time_rotating.rotation_days](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/rotating) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [rotation\_days](#input\_rotation\_days) | Number of days before rotating the random. | `number` | `30` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [random](#output\_random) | n/a | + diff --git a/modules/rotating-random/main.tf b/modules/rotating-random/main.tf new file mode 100644 index 0000000000..c6235ac79a --- /dev/null +++ b/modules/rotating-random/main.tf @@ -0,0 +1,10 @@ +resource "time_rotating" "rotation_days" { + rotation_days = var.rotation_days +} + +resource "random_id" "random" { + byte_length = 20 + keepers = { + rotation = time_rotating.rotation_days.id + } +} diff --git a/modules/rotating-random/outputs.tf b/modules/rotating-random/outputs.tf new file mode 100644 index 0000000000..5d4e64e6d9 --- /dev/null +++ b/modules/rotating-random/outputs.tf @@ -0,0 +1,3 @@ +output "random" { + value = random_id.random +} diff --git a/modules/rotating-random/variables.tf b/modules/rotating-random/variables.tf new file mode 100644 index 0000000000..f68bab0c04 --- /dev/null +++ b/modules/rotating-random/variables.tf @@ -0,0 +1,5 @@ +variable "rotation_days" { + description = "Number of days before rotating the random." + type = number + default = 30 +} diff --git a/modules/rotating-random/versions.tf b/modules/rotating-random/versions.tf new file mode 100644 index 0000000000..db2e796d6f --- /dev/null +++ b/modules/rotating-random/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + random = { + source = "hashicorp/random" + version = "~> 3" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/modules/runners/README.md b/modules/runners/README.md index a67ba5355b..46acdbf5d3 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -159,7 +159,7 @@ yarn run dist | [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | -| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB. However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com | `string` | `null` | no | +| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | | [idle\_config](#input\_idle\_config) | List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for spot instances. AWS recommends to use `capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | diff --git a/outputs.tf b/outputs.tf index 699867ec2e..403402e495 100644 --- a/outputs.tf +++ b/outputs.tf @@ -47,7 +47,6 @@ output "ssm_parameters" { value = module.ssm.parameters } - output "queues" { description = "SQS queues." value = { diff --git a/variables.tf b/variables.tf index 85c675d58a..68dff756f9 100644 --- a/variables.tf +++ b/variables.tf @@ -32,11 +32,17 @@ variable "enable_organization_runners" { } variable "github_app" { - description = "GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`)." + description = <