Manage controllers

Bootstrap a controller

To bootstrap a new Juju controller use the juju_controller resource.

Bootstrap to LXD (localhost)

This example bootstraps a controller onto the local LXD cloud using certificate authentication.

1. Configure the provider for controller mode.

Bootstrapping is a unique situation as there is no controller to connect to yet so our provider block will be mostly empty.

Set controller_mode = true in the provider to enable bootstrapping.
No resources besides controllers can be created when this flag is set.

terraform {
  required_providers {
    juju = {
      source  = "juju/juju"
      version = "~> 1.0.0"
    }
  }
}

provider "juju" {
  controller_mode = true
}

2. Obtain your LXD credential values (including secrets):

juju show-credentials --client localhost localhost --show-secrets

From the output, you will need the values client-cert, client-key, and server-cert. Keep them out of version control (for example, pass them via TF_VAR_... environment variables, a secrets manager, or a .tfvars file you do not commit).

3. Declare the controller:

Here we use the localhost cloud which is already known to the Juju CLI. Private clouds can be specified by filling the remainder of the fields in the cloud object.

resource "juju_controller" "this" {
  name = "test-controller"

  juju_binary = "/snap/juju/current/bin/juju"
  
  cloud = {
    name       = "localhost"
    type       = "lxd"
    auth_types = ["certificate"]
  }

  cloud_credential = {
    name      = "localhost"
    auth_type = "certificate"
    attributes = {
      "client-cert" = var.lxd_client_cert
      "client-key"  = var.lxd_client_key
      "server-cert" = var.lxd_server_cert
    }
  }

  # Settings here map to flags/config used by `juju controller-config`.
  controller_config = {
    "audit-log-max-backups"     = "10"
    "query-tracing-enabled"     = "true"
  }

  # Settings here map to flags/config used by `juju model-config`.
  controller_model_config = {
    "juju-http-proxy" = "http://my-proxy.internal"
  }

}

Important

If you have installed Juju as a snap, use the path /snap/juju/current/bin/juju to avoid snap confinement issues.

After terraform apply, the resource exposes useful read-only attributes such as the controller api_addresses, ca_cert, username, and password.

4. Change config post-bootstrap:

After bootstrap, the controller config and controller-model config can be changed.

Note the following behaviors:

  1. If you remove a key from controller_config, it will not be unset on the controller; it is left unchanged.

  2. Attempting to change a config value that Juju doesn’t support changing after bootstrap will result in an error. You must destroy and recreate the controller to change these values.

  3. Boolean values must be specified as either “true” or “false”.

Tip

Many juju_controller fields correspond to the same flags used by the juju bootstrap CLI. When in doubt, juju bootstrap --help and the Juju docs are a good way to discover valid keys and values.

resource "juju_controller" "this" {
  # additional fields ommitted

  controller_config = {
    "audit-log-max-backups"     = "10"
  }

  controller_model_config = {
    "juju-http-proxy"              = "http://my-proxy.internal"
    "update-status-hook-interval"  = "1m"
  }
}

Import an existing controller

If you have a controller that was created outside of Terraform (for example, via juju bootstrap), you can import it into your Terraform state.

1: Getting controller connection information:

To import a controller, you need its connection details. You can obtain these by running:

juju show-controller --show-password

From the output, you will need:

  • Controller name

  • API addresses

  • CA certificate

  • Admin username (typically admin)

  • Admin password

  • Controller UUID

  • Credential name

2: Import block structure:

Create an import block with the identity schema containing the controller’s connection information:

import {
  to = juju_controller.imported
  identity = {
    name            = "my-existing-controller"
    api_addresses   = ["<ip>:17070"]
    username        = "admin"
    password        = "<password>"
    ca_cert         = <<-EOT
      -----BEGIN CERTIFICATE-----
      -----END CERTIFICATE-----
    EOT
    controller_uuid = "<controller-uudi>"
    credential_name = "<credential-name>"
  }
}

3: Define a juju_controller resource:

You also need to define the corresponding juju_controller resource with the cloud and credential information:

resource "juju_controller" "imported" {
  name = "my-existing-controller"

  cloud = {
    name       = "localhost"
    type       = "lxd"
    auth_types = ["certificate"]
  }

  cloud_credential = {
    name      = "localhost"
    auth_type = "certificate"
    attributes = {
      "client-cert" = var.lxd_client_cert
      "client-key"  = var.lxd_client_key
      "server-cert" = var.lxd_server_cert
    }
  }
}

Then run:

terraform plan

Terraform will detect the import block and import the controller during the next terraform apply.

Import example: LXD controller

provider "juju" {
  controller_mode = true
}

resource "juju_controller" "imported" {
  name = "my-lxd-controller"

  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    name       = "localhost"
    type       = "lxd"
    auth_types = ["certificate"]
  }

  cloud_credential = {
    name      = "localhost"
    auth_type = "certificate"
    attributes = {
      <attrs>
    }
  }

  lifecycle {
    ignore_changes = [
      cloud.endpoint,
      cloud.region,
      cloud_credential.attributes["client-cert"],
      cloud_credential.attributes["client-key"]
    ]
  }
}

import {
  to = juju_controller.imported
  identity = {
    name            = "my-lxd-controller"
    api_addresses   = ["<ip>:17070"]
    username        = "admin"
    password        = "<password>"
    ca_cert         = <<-EOT
      -----BEGIN CERTIFICATE-----
      -----END CERTIFICATE-----
    EOT
    controller_uuid = "<controller-uudi>"
    credential_name = "<credential-name>"
  }
}

Note

The cloud_credential.attributes["client-cert"] and cloud_credential.attributes["client-key"] are not required to bootstrap an LXD controller, but they are populated in the state during import because they are fetched from the controller. The same applies to cloud.endpoint and cloud.region, which may be set by Juju during bootstrap even if not explicitly specified.

Import example: MicroK8s controller

provider "juju" {
  controller_mode = true
}

resource "juju_controller" "imported" {
  name = "my-k8s-controller"

  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    name                = "test-k8s"
    type                = "kubernetes"
    auth_types          = ["clientcertificate"]
    endpoint            = var.k8s_endpoint
    ca_certificates     = [var.k8s_ca_cert]
    host_cloud_region   = "localhost"
  }

  cloud_credential = {
    name      = "test-credential"
    auth_type = "clientcertificate"
    attributes = {
      "ClientCertificateData" = var.k8s_client_cert
      "ClientKeyData"         = var.k8s_client_key
    }
  }

  lifecycle {
    ignore_changes = [
      cloud.region,
      cloud.host_cloud_region
    ]
  }
}

import {
  to = juju_controller.imported
  identity = {
    name            = "my-k8s-controller"
    api_addresses   = ["<ip>:17070"]
    username        = "admin"
    password        = "<password>"
    ca_cert         = <<-EOT
      -----BEGIN CERTIFICATE-----
      -----END CERTIFICATE-----
    EOT
    controller_uuid = "<controller-uudi>"
    credential_name = "<credential-name>"
  }
}

Note

The cloud.region is not required during bootstrap but may be set by Juju and needs to be ignored. The cloud.host_cloud_region cannot be fetched from the controller after bootstrap, so it must be ignored to prevent Terraform from attempting to replace the controller.

Post-import workflow

After importing a controller:

1. Review the plan:

Run terraform plan to see which attributes Terraform cannot determine or that differ from your configuration. These differences are expected after an import.

terraform plan

2. Add necessary ignore_changes:

Based on the plan output, add any fields showing unexpected changes to the lifecycle.ignore_changes block that would require a replace of the controller resource.
Common fields to ignore include:

  • Credential attributes that may differ between your plan and the ones fetched from the controller.

  • Cloud region and endpoint fields, which can be default when a controller is bootstrap, but it’s returned when it’s set in the state when it’s fetched from the controller.

  • Bootstrap-time configuration that cannot be changed, and can’t be fetched from the controller.

3. Verify the configuration:

After adding the appropriate lifecycle.ignore_changes directives, run terraform plan again. You should see either no changes or only expected configuration updates.

Tip

If you see controller_config or controller_model_config showing changes to set default values, you can either apply them (they will update the controller configuration which is idempotent) or add these blocks to ignore_changes to prevent the updates.

Add a cloud to a controller

Add a credential to a controller

By virtue of being bootstrapped into a cloud, your controller already has a credential for that cloud. However, if you want to use a different credential, or if you’re adding a further cloud to the controller and would like to also add a credential for that cloud, you will need to add those credentials to the controller too. You can do that in the usual way by creating a resource of the juju_credential type.

Manage access to a controller

Note

At present the Terraform Provider for Juju supports controller access management only for Juju controllers added to JAAS.

When using Juju with JAAS, to grant access to a Juju controller added to JAAS, in your Terraform plan add a resource type juju_jaas_access_controller. Access can be granted to one or more users, service accounts, roles, and/or groups. You must specify the model UUID, the JAAS controller access level, and the desired list of users, service accounts, roles, and/or groups. For example:

resource "juju_jaas_access_controller" "development" {
  access           = "administrator"
  users            = ["foo@domain.com"]
  service_accounts = ["Client-ID-1", "Client-ID-2"]
  roles            = [juju_jaas_role.development.uuid]
  groups           = [juju_jaas_group.development.uuid]
}