diff --git a/.github/workflows/server-terraform-tags.yml b/.github/workflows/server-terraform-tags.yml new file mode 100644 index 000000000..7d16b75ad --- /dev/null +++ b/.github/workflows/server-terraform-tags.yml @@ -0,0 +1,51 @@ +name: Create version tag for testflinger server terraform module +permissions: + contents: read +on: + push: + branches: + - main + paths: + - server/terraform/** + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + auto-tag-terraform-module: + name: Create version tag for the terraform module + runs-on: ubuntu-latest + permissions: + contents: write # necessary for writing tag + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 # necessary for semantic versioning + filter: blob:none # exclude file contents for faster checkout + persist-credentials: true # necessary for pushing tags + + - name: Generate semantic version + id: version + uses: PaulHatch/semantic-version@f29500c9d60a99ed5168e39ee367e0976884c46e # v6.0.1 + with: + tag_prefix: "testflinger-k8s-" + major_pattern: "breaking(terraform-server):" + minor_pattern: "feat(terraform-server):" + change_path: "server/terraform/" + search_commit_body: true + enable_prerelease_mode: false + + - name: Create and push tag + env: + TAG: ${{ steps.version.outputs.version_tag }} + run: | + echo ::group::Create tag + git tag "$TAG" + echo ::endgroup:: + echo ::notice::"$TAG" + echo ::group::Push tag + git push origin $TAG + echo ::endgroup:: diff --git a/server/terraform/CONTRIBUTING.md b/server/terraform/CONTRIBUTING.md new file mode 100644 index 000000000..7cdfe4a21 --- /dev/null +++ b/server/terraform/CONTRIBUTING.md @@ -0,0 +1,169 @@ +# Contributing to Testflinger Terraform modules + +This document outlines contribution guidelines specific to the Testflinger Server +Terraform module. + +To learn more about the general contribution guidelines for the Testflinger project, +refer to the [Testflinger contribution guide](../../CONTRIBUTING.md). + +## Pull Requests + +Any change to the terraform modules should be semantically versioned, for automation +to automatically tag your commits, you should consider the following patterns: +- `breaking(terraform-server):` - Creates a major release +- `feat(terraform-server):` - Creates a minor release + +By default, any commit without the above patterns will create a patch version bump. +Refer to [Terraform versioning guide][versioning-guide] to understand more about +semantic versioning Terraform modules. + +## Testflinger Server Terraform Deployment + +The following instructions are meant to provide developers a guide on how to +deploy a Testflinger Server by using [Terraform]. + +### Set up a Juju environment + +It is recommended to install the pre-requisites on a VM rather than your host +machine. To do so, first install [Multipass]: + +```shell +sudo snap install multipass +``` + +Then launch a new VM instance (this may take a while): + +```shell +multipass launch noble --disk 50G --memory 4G --cpus 2 --name testflinger-juju --mount /path/to/testflinger:/home/ubuntu/testflinger --cloud-init /path/to/testflinger/server/terraform/dev/cloud-init.yaml --timeout 1200 +``` + +Feel free to increase the storage, memory, CPU, or VM name. + +> [!NOTE] +> The initialization may time out. That's fine as long as the setup actually completes. +> You can tell that the setup completed by checking if the Juju models were created. + +Check that the models were created: + +```shell +multipass exec testflinger-juju -- juju models +``` + +### Initialize project's terraform + +Now that everything has been set up, you can initialize the project's terraform. +The following guide setups a higher-level module to configure the Testflinger +Server Charm deployment. + +Change your directory on your host machine to the terraform dev directory: + +```shell +cd /path/to/testflinger/server/terraform/dev +``` + +Then run: + +```shell +multipass exec testflinger-juju -- terraform init +``` + +### Set up variables + +Refer to the [README](README.md#api) for the full list of required variables. + +The `terraform/dev` directory has sensitive values to be provided before +deployment. Create a `terraform.tfvars` file inside `terraform/dev` with the +following secrets: + +```hcl +jwt_signing_key = "" +testflinger_secrets_master_key = "" +``` + + +> [!NOTE] +> Only `jwt_signing_key` is required. In case `terraform.tfvars` is not provided +> terraform will prompt for the value. + +> [!WARNING] +> Never commit `terraform.tfvars` to version control. The file is excluded via +> `.gitignore` for this reason. + +### Deploy + +In the terraform directory on your host machine, run: + +```shell +multipass exec testflinger-juju -- terraform apply -auto-approve +``` + +Then wait for the deployment to settle and all the statuses to become active. +You can watch the statuses via: + +```shell +multipass exec testflinger-juju -- juju status --storage --relations --watch 5s +``` + +### Connect to your deployment + +Look at the IPv4 addresses of your testflinger-juju VM through: + +```shell +multipass info testflinger-juju +``` + +One of these connects to the ingress enabled inside the VM. To figure out which one, +try the following command on each IP address until you get a response: + +```shell +curl --connect-to :: http://testflinger.local/v1 +``` + +Once you find the IP address, add the following entry to your host machine's +`/etc/hosts` file: + +```text + testflinger.local +``` + +After that you should be able to reach the Testflinger frontend on your host +machine's browser through `http://testflinger.local`. You should also be able +to access the API through `http://testflinger.local/v1/`. + +### Teardown + +To take everything down, you can start with terraform: + +```shell +multipass exec testflinger-juju -- terraform destroy -auto-approve +``` + +The above step can take a while and may even get stuck with some applications +in error state. You can watch it through: + +```shell +multipass exec testflinger-juju -- juju status --storage --relations --watch 5s +``` + +To forcefully remove applications stuck in error state: + +```shell +multipass exec testflinger-juju -- juju remove-application --destroy-storage --force +``` + +Once everything is down and the juju model has been deleted you can stop the +multipass VM: + +```shell +multipass stop testflinger-juju +``` + +Optionally, delete the VM: + +```shell +multipass delete --purge testflinger-juju +``` + +[Multipass]: https://canonical.com/multipass +[Terraform]: https://developer.hashicorp.com/terraform +[versioning-guide]: https://developer.hashicorp.com/terraform/plugin/best-practices/versioning diff --git a/server/terraform/README.md b/server/terraform/README.md index 0c95ec87c..d00ba7ef0 100644 --- a/server/terraform/README.md +++ b/server/terraform/README.md @@ -1,103 +1,79 @@ -# Juju deployment +# Terraform module for Testflinger Server -Local Juju and charm deployment via microk8s and terraform. +This is a Terraform module facilitating the deployment of the Testflinger Server +charm, using the [Terraform Juju provider][juju-provider]. For more information, +refer to the provider [documentation][juju-provider-docs]. -## Setup a juju environment +## Requirements -It is recommended to install the pre-requisites on a VM rather than your host machine. To do so, first install multipass: +This module requires a Juju model UUID to be available. Refer to the [usage section](#usage) +below for more details. -```bash -sudo snap install multipass -``` +## API -Then launch a new VM instance using (this will take a while): +### Inputs -```bash -multipass launch noble --disk 50G --memory 4G --cpus 2 --name testflinger-juju --mount /path/to/testflinger:/home/ubuntu/testflinger --cloud-init /path/to/testflinger/server/terraform/cloud-init.yaml --timeout 1200 -``` +The module offers the following configurable inputs: -Feel free to increase the storage, memory, cpu limits or change the VM name. +| Name | Type | Description | Required | +| - | - | - | - | +| `app_name` | string | Name of the Testflinger server application | False | +| `base` | string | Operating system base to use for the Testflinger server charm | False | +| `channel` | string | Channel to use for the charm | False | +| `config` | map(string) | Map of charm config options | False | +| `constraints` | string | Constraints to use for the Testflinger server application | False | +| `model_uuid` | string | UUID of the Juju model to deploy into | True | +| `revision` | number | Revision of the charm to use | False | +| `units` | number | Number of units for the Testflinger server application | False | -Note that the initialization may timeout. That's fine as long as the setup actually completed. You can tell that the setup completed by checking if there are juju models created already: -```bash -multipass exec testflinger-juju -- juju models -``` +### Outputs -## Initialize project's terraform +| Name | Type | Description | +| - | - | - | +| `application` | object | The deployed application object | +| `provides` | map(string) | Map of provides integration endpoints | +| `requires` | map(string) | Map of requires integration endpoints | -Now that everything has been set up, you can initialize the project's terraform. +## Usage +This module is intended to be used as part of a higher-level module. +When defining one, users should ensure that Terraform is aware of the `model_uuid` +dependency of the charm module. -In the terraform directory on your host machine, run: - -```bash -multipass exec testflinger-juju -- terraform init -``` - -## Deploy everything - -In the terraform directory on your host machine, run: - -```bash -multipass exec testflinger-juju -- terraform apply -auto-approve -``` +### Define a `data` source -Then wait for the deployment to settle and all the statuses to become active. You can watch the statuses via: +Define a `data.juju_model` source that looks up the target Juju model by name, +and pass the resulting UUID to the `model_uuid` input using `data.juju_model..uuid`. +This ensures Terraform resolves the model data source before applying the module. +If the named model cannot be found, Terraform will fail during planning or apply +before creating any resources. -```bash -multipass exec testflinger-juju -- juju status --storage --relations --watch 5s +```hcl +data "juju_model" "testflinger_dev" { + name = "" +} ``` -## Connect to your deployment - -Look at the IPv4 addresses of your testflinger-juju vm through: - -```bash -multipass info testflinger-juju -``` - -One of these connect to the ingress enabled inside the VM. To figure out which one try the following command on each IP address until you get response: - -```bash -curl --connect-to :: http://testflinger.local -``` - -Once you find the IP address add the following entry to your host machine's `/etc/hosts` file: - -```text - testflinger.local +### Create module + +Then call the module: + +```hcl +module "testflinger" { + source = "git::https://github.com/canonical/testflinger.git//server/terraform?ref=" + model_uuid = data.juju_model.testflinger_dev.uuid + app_name = "" + config = { + external_hostname = "testflinger.local" + http_proxy = "" + https_proxy = "" + no_proxy = "localhost,127.0.0.1,::1" + max_pool_size = "100" + jwt_signing_key = var.jwt_signing_key + testflinger_secrets_master_key = var.testflinger_secrets_master_key + } +} ``` -After that you should be able to get to Testflinger frontend on your host machine's browser through the url `http://testflinger.local`. You should also be able to access the API through `http://testflinger.local/v1/`. - -## Teardown - -To take everything down you can start with terraform: - -```bash -multipass exec testflinger-juju -- terraform destroy -auto-approve -``` - -The above step can take a while and may even get stuck with some applications in error state. You can watch it through: - -```bash -multipass exec testflinger-juju -- juju status --storage --relations --watch 5s -``` - -To forcefully remove applications stuck in error state: - -```bash -multipass exec testflinger-juju -- juju remove-application --destroy-storage --force -``` - -Once everything is down and the juju model has been deleted you can stop the multipass VM: - -```bash -multipass stop testflinger-juju -``` - -Optionally, delete the VM: - -```bash -multipass delete --purge testflinger-juju -``` +[juju-provider]: https://github.com/juju/terraform-provider-juju/ +[juju-provider-docs]: https://registry.terraform.io/providers/juju/juju/latest/docs \ No newline at end of file diff --git a/server/terraform/.gitignore b/server/terraform/dev/.gitignore similarity index 90% rename from server/terraform/.gitignore rename to server/terraform/dev/.gitignore index f2b3b69bd..b468dd82c 100644 --- a/server/terraform/.gitignore +++ b/server/terraform/dev/.gitignore @@ -1,3 +1,5 @@ +.terraform.lock.hcl + .terraform/* *.tfstate diff --git a/server/terraform/cloud-init.yaml b/server/terraform/dev/cloud-init.yaml similarity index 95% rename from server/terraform/cloud-init.yaml rename to server/terraform/dev/cloud-init.yaml index 57f96ecd0..4103df7e6 100644 --- a/server/terraform/cloud-init.yaml +++ b/server/terraform/dev/cloud-init.yaml @@ -25,6 +25,6 @@ runcmd: sed -i "s|server: .*|server: $server_url|g" /var/snap/microk8s/current/credentials/client.config - sudo -i -u ubuntu snap run juju add-cloud microk8s --controller localhost-controller - sudo -i -u ubuntu snap run juju add-model testflinger-dev-db localhost - - sudo -i -u ubuntu snap run juju deploy mongodb --channel 5/edge + - sudo -i -u ubuntu snap run juju deploy mongodb --channel 6/edge - sudo -i -u ubuntu snap run juju offer testflinger-dev-db.mongodb:database mongodb - sudo -i -u ubuntu snap run juju add-model testflinger-dev microk8s diff --git a/server/terraform/dev/main.tf b/server/terraform/dev/main.tf new file mode 100644 index 000000000..caf377142 --- /dev/null +++ b/server/terraform/dev/main.tf @@ -0,0 +1,70 @@ +# Testflinger Terraform module +module "testflinger" { + source = "../" + app_name = "testflinger" + model_uuid = data.juju_model.testflinger_dev_model.uuid + units = 2 + base = "ubuntu@22.04" + channel = "latest/beta" + config = { + external_hostname = "testflinger.local" + http_proxy = "" + https_proxy = "" + no_proxy = "localhost,127.0.0.1,::1" + max_pool_size = "100" + jwt_signing_key = var.jwt_signing_key + testflinger_secrets_master_key = var.testflinger_secrets_master_key + } +} + +# Data Source for juju model +data "juju_model" "testflinger_dev_model" { + name = "testflinger-dev" + owner = "admin" +} + +# Nginx Ingress Integrator Terraform resource +resource "juju_application" "ingress" { + name = "ingress" + model_uuid = data.juju_model.testflinger_dev_model.uuid + trust = true + + charm { + name = "nginx-ingress-integrator" + channel = "latest/stable" + } + + config = { + tls-secret-name = "" + whitelist-source-range = "" + max-body-size = "20" + } +} + +# Juju integration between MongoDB and Testflinger application +resource "juju_integration" "testflinger_database_relation" { + model_uuid = data.juju_model.testflinger_dev_model.uuid + + application { + name = module.testflinger.application.name + endpoint = "mongodb_client" + } + + application { + offer_url = "admin/testflinger-dev-db.mongodb" + endpoint = "database" + } +} + +# Juju integration between Ingress and Testflinger application +resource "juju_integration" "testflinger_ingress_relation" { + model_uuid = data.juju_model.testflinger_dev_model.uuid + + application { + name = module.testflinger.application.name + } + + application { + name = juju_application.ingress.name + } +} diff --git a/server/terraform/versions.tf b/server/terraform/dev/terraform.tf similarity index 75% rename from server/terraform/versions.tf rename to server/terraform/dev/terraform.tf index c4e2edc16..9d78d4bf3 100644 --- a/server/terraform/versions.tf +++ b/server/terraform/dev/terraform.tf @@ -1,7 +1,7 @@ terraform { required_providers { juju = { - version = "~> 0.17.0" + version = "~> 1.0" source = "juju/juju" } } diff --git a/server/terraform/dev/variables.tf b/server/terraform/dev/variables.tf new file mode 100644 index 000000000..fabda929f --- /dev/null +++ b/server/terraform/dev/variables.tf @@ -0,0 +1,12 @@ +variable "jwt_signing_key" { + description = "The signing key for JWT tokens" + sensitive = true + type = string +} + +variable "testflinger_secrets_master_key" { + description = "Master key for Testflinger secrets encryption" + type = string + sensitive = true + default = "" +} diff --git a/server/terraform/locals.tf b/server/terraform/locals.tf new file mode 100644 index 000000000..e69de29bb diff --git a/server/terraform/main.tf b/server/terraform/main.tf index a6bdda94b..3933561b2 100644 --- a/server/terraform/main.tf +++ b/server/terraform/main.tf @@ -1,80 +1,14 @@ resource "juju_application" "testflinger" { - name = "testflinger" - model = local.app_model - - units = var.application_units + name = var.app_name + model_uuid = var.model_uuid + constraints = var.constraints + units = var.units + config = var.config charm { name = "testflinger-k8s" - base = "ubuntu@22.04" - channel = local.channel + base = var.base + channel = var.channel revision = var.revision } - - config = { - external_hostname = var.external_ingress_hostname - max_pool_size = var.max_pool_size - jwt_signing_key = var.jwt_signing_key - testflinger_secrets_master_key = var.testflinger_secrets_master_key - http_proxy = var.http_proxy - https_proxy = var.https_proxy - no_proxy = var.no_proxy - } -} - -resource "juju_application" "ingress" { - name = "ingress" - model = local.app_model - trust = true - - charm { - name = "nginx-ingress-integrator" - channel = "latest/stable" - } - - config = { - tls-secret-name = var.tls_secret_name - whitelist-source-range = var.nginx_ingress_integrator_charm_whitelist_source_range - max-body-size = var.nginx_ingress_integrator_charm_max_body_size - } -} - -resource "juju_integration" "testflinger-database-relation" { - model = local.app_model - - application { - name = juju_application.testflinger.name - endpoint = "mongodb_client" - } - - application { - offer_url = var.db_offer - } -} - -resource "juju_integration" "testflinger_encryption_relation" { - model = local.app_model - - application { - name = juju_application.testflinger.name - endpoint = "mongodb_keyvault" - } - - application { - offer_url = var.encryption_offer - } } - -resource "juju_integration" "testflinger-ingress-relation" { - model = local.app_model - - application { - name = juju_application.testflinger.name - } - - application { - name = juju_application.ingress.name - } -} - - diff --git a/server/terraform/outputs.tf b/server/terraform/outputs.tf new file mode 100644 index 000000000..014ed26e5 --- /dev/null +++ b/server/terraform/outputs.tf @@ -0,0 +1,22 @@ +output "application" { + description = "The deployed application" + value = juju_application.testflinger +} + +output "provides" { + description = "Map of provided integration endpoints" + value = { + grafana_dashboard = "grafana-dashboard" + metrics_endpoint = "metrics-endpoint" + } +} + +output "requires" { + description = "Map of requires integration endpoints" + value = { + mongodb_client = "mongodb_client" + mongodb_keyvault = "mongodb_keyvault" + nginx_route = "nginx-route" + traefik_route = "traefik-route" + } +} diff --git a/server/terraform/providers.tf b/server/terraform/providers.tf new file mode 100644 index 000000000..e69de29bb diff --git a/server/terraform/terraform.tf b/server/terraform/terraform.tf new file mode 100644 index 000000000..9d78d4bf3 --- /dev/null +++ b/server/terraform/terraform.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + juju = { + version = "~> 1.0" + source = "juju/juju" + } + } +} diff --git a/server/terraform/variables.tf b/server/terraform/variables.tf index 51218fa2b..8e7f0820c 100644 --- a/server/terraform/variables.tf +++ b/server/terraform/variables.tf @@ -1,102 +1,49 @@ -variable "environment" { - description = "The environment to deploy to. When the \"revision\" variable is not set, the value of \"environment\" determines the channel to deploy from, either \"latest/stable\" (for production) or \"latest/edge\" channel otherwise." +variable "app_name" { + description = "Name of the Testflinger application to deploy" type = string - default = "dev" - validation { - condition = contains(["dev", "staging", "prod", "production"], var.environment) - error_message = "The environment must be one of 'dev', 'staging', or 'prod'." - } + default = "testflinger" } -variable "revision" { - description = "Revision of the charm to use" - type = number +variable "base" { + description = "Operating system base to use for the Testflinger Server charm" + type = string nullable = true default = null } -variable "external_ingress_hostname" { - description = "External hostname for the ingress" +variable "channel" { + description = "Channel to use for the Testflinger charm." type = string - default = "testflinger.local" + default = "latest/stable" } -variable "tls_secret_name" { - description = "Secret where the TLS certificate for ingress is stored" - type = string - default = "" +variable "config" { + description = "Map of charm config options" + type = map(string) + default = {} } -variable "db_offer" { - description = "Name of the juju offer for mongodb to use for the cross-model relation" +variable "constraints" { + description = "Constraints to apply to the Testflinger application" type = string - default = "admin/testflinger-dev-db.mongodb" -} - -variable "encryption_offer" { - description = "Name of the juju offer for encryption database to use for the cross-model relation" - type = string - default = "admin/testflinger-dev-db.mongodb" + nullable = true + default = null } -variable "nginx_ingress_integrator_charm_whitelist_source_range" { - description = "Allowed client IP source ranges. The value is a comma separated list of CIDRs." +variable "model_uuid" { + description = "UUID of the Juju model to deploy into" type = string - default = "" } -variable "nginx_ingress_integrator_charm_max_body_size" { - description = "Max allowed body-size (for file uploads) in megabytes, set to 0 to disable limits." - type = number - default = 20 -} - - -variable "application_units" { - description = "Number of units (pods) to start" +variable "revision" { + description = "Revision of the charm to use" type = number - default = 2 + nullable = true + default = null } -variable "max_pool_size" { - description = "Maximum number of concurrent connections to the database" +variable "units" { + description = "Number of units for the server application" type = number - default = 100 -} - -variable "jwt_signing_key" { - description = "The signing key for JWT tokens" - sensitive = true - type = string - default = "secret" -} - -variable "testflinger_secrets_master_key" { - description = "Master key for Testflinger secrets encryption" - type = string - sensitive = true - default = "" -} - -variable "http_proxy" { - description = "HTTP proxy for accessing external HTTP resources" - type = string - default = "" -} - -variable "https_proxy" { - description = "HTTPS proxy for accessing external HTTPS resources" - type = string - default = "" -} - -variable "no_proxy" { - description = "Resources that we should abe able to access bypassing proxy" - type = string - default = "localhost,127.0.0.1,::1" -} - -locals { - app_model = "testflinger-${var.environment}" - channel = var.revision == null ? (startswith(var.environment, "prod") ? "latest/stable" : "latest/edge") : null + default = 1 }