mirror of
https://github.com/edgelesssys/constellation.git
synced 2025-09-27 03:50:56 -04:00
Use terraform in CLI to create QEMU cluster (#172)
* Use terraform in CLI to create QEMU cluster * Dont allow qemu creation on os/arch other than linux/amd64 * Allow usage of --name flag for QEMU resources Signed-off-by: Daniel Weiße <dw@edgeless.systems>
This commit is contained in:
parent
2b32b79026
commit
804c173d52
41 changed files with 1066 additions and 182 deletions
35
cli/internal/terraform/input.go
Normal file
35
cli/internal/terraform/input.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
Copyright (c) Edgeless Systems GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package terraform
|
||||
|
||||
// CreateClusterInput is user configuration for creating a cluster with Terraform.
|
||||
type CreateClusterInput struct {
|
||||
// CountControlPlanes is the number of control-plane nodes to create.
|
||||
CountControlPlanes int
|
||||
// CountWorkers is the number of worker nodes to create.
|
||||
CountWorkers int
|
||||
// QEMU is the configuration for QEMU clusters.
|
||||
QEMU QEMUInput
|
||||
}
|
||||
|
||||
// QEMUInput is user configuration for creating a QEMU cluster with Terraform.
|
||||
type QEMUInput struct {
|
||||
// CPUCount is the number of CPUs to allocate to each node.
|
||||
CPUCount int
|
||||
// MemorySizeMiB is the amount of memory to allocate to each node, in MiB.
|
||||
MemorySizeMiB int
|
||||
// StateDiskSizeGB is the size of the state disk to allocate to each node, in GB.
|
||||
StateDiskSizeGB int
|
||||
// IPRangeStart is the first IP address in the IP range to allocate to the cluster.
|
||||
IPRangeStart int
|
||||
// ImagePath is the path to the image to use for the nodes.
|
||||
ImagePath string
|
||||
// ImageFormat is the format of the image from ImagePath.
|
||||
ImageFormat string
|
||||
// MetadataAPIImage is the container image to use for the metadata API.
|
||||
MetadataAPIImage string
|
||||
}
|
64
cli/internal/terraform/loader.go
Normal file
64
cli/internal/terraform/loader.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
Copyright (c) Edgeless Systems GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
|
||||
"github.com/edgelesssys/constellation/v2/internal/file"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
//go:embed terraform/*
|
||||
var terraformFS embed.FS
|
||||
|
||||
// prepareWorkspace loads the embedded Terraform files,
|
||||
// and writes them into the workspace.
|
||||
func prepareWorkspace(fileHandler file.Handler, provider cloudprovider.Provider) error {
|
||||
// use path.Join to ensure no forward slashes are used to read the embedded FS
|
||||
rootDir := path.Join("terraform", strings.ToLower(provider.String()))
|
||||
return fs.WalkDir(terraformFS, rootDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := terraformFS.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := strings.TrimPrefix(path, rootDir+"/")
|
||||
return fileHandler.Write(fileName, content, file.OptMkdirAll)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanUpWorkspace removes files that were loaded into the workspace.
|
||||
func cleanUpWorkspace(fileHandler file.Handler, provider cloudprovider.Provider) error {
|
||||
rootDir := path.Join("terraform", strings.ToLower(provider.String()))
|
||||
return fs.WalkDir(terraformFS, rootDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := strings.TrimPrefix(path, rootDir+"/")
|
||||
return ignoreFileNotFoundErr(fileHandler.RemoveAll(fileName))
|
||||
})
|
||||
}
|
||||
|
||||
// ignoreFileNotFoundErr ignores the error if it is a file not found error.
|
||||
func ignoreFileNotFoundErr(err error) error {
|
||||
if errors.Is(err, afero.ErrFileNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
62
cli/internal/terraform/loader_test.go
Normal file
62
cli/internal/terraform/loader_test.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
Copyright (c) Edgeless Systems GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
|
||||
"github.com/edgelesssys/constellation/v2/internal/file"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoader(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
provider cloudprovider.Provider
|
||||
fileList []string
|
||||
}{
|
||||
"qemu": {
|
||||
provider: cloudprovider.QEMU,
|
||||
fileList: []string{
|
||||
"main.tf",
|
||||
"variables.tf",
|
||||
"outputs.tf",
|
||||
"modules",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
file := file.NewHandler(afero.NewMemMapFs())
|
||||
|
||||
err := prepareWorkspace(file, tc.provider)
|
||||
require.NoError(err)
|
||||
|
||||
checkFiles(t, file, func(err error) { assert.NoError(err) }, tc.fileList)
|
||||
|
||||
err = cleanUpWorkspace(file, tc.provider)
|
||||
require.NoError(err)
|
||||
|
||||
checkFiles(t, file, func(err error) { assert.ErrorIs(err, fs.ErrNotExist) }, tc.fileList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkFiles(t *testing.T, file file.Handler, assertion func(error), files []string) {
|
||||
t.Helper()
|
||||
for _, f := range files {
|
||||
_, err := file.Stat(f)
|
||||
assertion(err)
|
||||
}
|
||||
}
|
203
cli/internal/terraform/terraform.go
Normal file
203
cli/internal/terraform/terraform.go
Normal file
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
Copyright (c) Edgeless Systems GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
|
||||
"github.com/edgelesssys/constellation/v2/internal/file"
|
||||
"github.com/edgelesssys/constellation/v2/internal/state"
|
||||
"github.com/hashicorp/go-version"
|
||||
install "github.com/hashicorp/hc-install"
|
||||
"github.com/hashicorp/hc-install/fs"
|
||||
"github.com/hashicorp/hc-install/product"
|
||||
"github.com/hashicorp/hc-install/releases"
|
||||
"github.com/hashicorp/hc-install/src"
|
||||
"github.com/hashicorp/terraform-exec/tfexec"
|
||||
tfjson "github.com/hashicorp/terraform-json"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
tfVersion = ">= 1.2.0"
|
||||
terraformVarsFile = "terraform.tfvars"
|
||||
)
|
||||
|
||||
// Client manages interaction with Terraform.
|
||||
type Client struct {
|
||||
tf tfInterface
|
||||
|
||||
provider cloudprovider.Provider
|
||||
|
||||
file file.Handler
|
||||
state state.ConstellationState
|
||||
remove func()
|
||||
}
|
||||
|
||||
// New sets up a new Client for Terraform.
|
||||
func New(ctx context.Context, provider cloudprovider.Provider) (*Client, error) {
|
||||
tf, remove, err := GetExecutable(ctx, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file := file.NewHandler(afero.NewOsFs())
|
||||
|
||||
return &Client{
|
||||
tf: tf,
|
||||
provider: provider,
|
||||
remove: remove,
|
||||
file: file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateCluster creates a Constellation cluster using Terraform.
|
||||
func (c *Client) CreateCluster(ctx context.Context, name string, input CreateClusterInput) error {
|
||||
if err := prepareWorkspace(c.file, c.provider); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.tf.Init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeUserConfig(c.file, c.provider, name, input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.tf.Apply(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tfState, err := c.tf.Show(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ipOutput, ok := tfState.Values.Outputs["ip"]
|
||||
if !ok {
|
||||
return errors.New("no IP output found")
|
||||
}
|
||||
ip, ok := ipOutput.Value.(string)
|
||||
if !ok {
|
||||
return errors.New("invalid type in IP output: not a string")
|
||||
}
|
||||
c.state = state.ConstellationState{
|
||||
CloudProvider: c.provider.String(),
|
||||
LoadBalancerIP: ip,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DestroyInstances destroys a Constellation cluster using Terraform.
|
||||
func (c *Client) DestroyCluster(ctx context.Context) error {
|
||||
return c.tf.Destroy(ctx)
|
||||
}
|
||||
|
||||
// RemoveInstaller removes the Terraform installer, if it was downloaded for this command.
|
||||
func (c *Client) RemoveInstaller() {
|
||||
c.remove()
|
||||
}
|
||||
|
||||
// CleanUpWorkspace removes terraform files from the current directory.
|
||||
func (c *Client) CleanUpWorkspace() error {
|
||||
if err := cleanUpWorkspace(c.file, c.provider); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ignoreFileNotFoundErr(c.file.Remove("terraform.tfvars")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ignoreFileNotFoundErr(c.file.Remove("terraform.tfstate")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ignoreFileNotFoundErr(c.file.Remove("terraform.tfstate.backup")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ignoreFileNotFoundErr(c.file.Remove(".terraform.lock.hcl")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ignoreFileNotFoundErr(c.file.RemoveAll(".terraform")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetState returns the state of the cluster.
|
||||
func (c *Client) GetState() state.ConstellationState {
|
||||
return c.state
|
||||
}
|
||||
|
||||
// writeUserConfig writes the user config file for Terraform.
|
||||
func writeUserConfig(file file.Handler, provider cloudprovider.Provider, name string, input CreateClusterInput) error {
|
||||
var userConfig string
|
||||
switch provider {
|
||||
case cloudprovider.QEMU:
|
||||
userConfig = fmt.Sprintf(`
|
||||
constellation_coreos_image = "%s"
|
||||
image_format = "%s"
|
||||
control_plane_count = %d
|
||||
worker_count = %d
|
||||
vcpus = %d
|
||||
memory = %d
|
||||
state_disk_size = %d
|
||||
ip_range_start = %d
|
||||
metadata_api_image = "%s"
|
||||
name = "%s"
|
||||
`,
|
||||
input.QEMU.ImagePath, input.QEMU.ImageFormat,
|
||||
input.CountControlPlanes, input.CountWorkers,
|
||||
input.QEMU.CPUCount, input.QEMU.MemorySizeMiB, input.QEMU.StateDiskSizeGB,
|
||||
input.QEMU.IPRangeStart,
|
||||
input.QEMU.MetadataAPIImage,
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
return file.Write(terraformVarsFile, []byte(userConfig))
|
||||
}
|
||||
|
||||
// GetExecutable returns a Terraform executable either from the local filesystem,
|
||||
// or downloads the latest version fulfilling the version constraint.
|
||||
func GetExecutable(ctx context.Context, workingDir string) (terraform *tfexec.Terraform, remove func(), err error) {
|
||||
inst := install.NewInstaller()
|
||||
|
||||
version, err := version.NewConstraint(tfVersion)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
downloadVersion := &releases.LatestVersion{
|
||||
Product: product.Terraform,
|
||||
Constraints: version,
|
||||
}
|
||||
localVersion := &fs.Version{
|
||||
Product: product.Terraform,
|
||||
Constraints: version,
|
||||
}
|
||||
|
||||
execPath, err := inst.Ensure(ctx, []src.Source{localVersion, downloadVersion})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
tf, err := tfexec.NewTerraform(workingDir, execPath)
|
||||
|
||||
return tf, func() { _ = inst.Remove(context.Background()) }, err
|
||||
}
|
||||
|
||||
type tfInterface interface {
|
||||
Apply(context.Context, ...tfexec.ApplyOption) error
|
||||
Destroy(context.Context, ...tfexec.DestroyOption) error
|
||||
Init(context.Context, ...tfexec.InitOption) error
|
||||
Show(context.Context, ...tfexec.ShowOption) (*tfjson.State, error)
|
||||
}
|
103
cli/internal/terraform/terraform/qemu/main.tf
Normal file
103
cli/internal/terraform/terraform/qemu/main.tf
Normal file
|
@ -0,0 +1,103 @@
|
|||
terraform {
|
||||
required_providers {
|
||||
libvirt = {
|
||||
source = "dmacvicar/libvirt"
|
||||
version = "0.6.14"
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
version = "2.17.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "libvirt" {
|
||||
uri = "qemu:///session"
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
host = "unix:///var/run/docker.sock"
|
||||
|
||||
registry_auth {
|
||||
address = "ghcr.io"
|
||||
config_file = pathexpand("~/.docker/config.json")
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_image" "qemu-metadata" {
|
||||
name = "${var.metadata_api_image}"
|
||||
keep_locally = true
|
||||
}
|
||||
|
||||
resource "docker_container" "qemu-metadata" {
|
||||
name = "${var.name}-qemu-metadata"
|
||||
image = docker_image.qemu-metadata.latest
|
||||
network_mode = "host"
|
||||
rm = true
|
||||
command = [
|
||||
"--network",
|
||||
"${var.name}-network",
|
||||
]
|
||||
mounts {
|
||||
source = "/var/run/libvirt/libvirt-sock"
|
||||
target = "/var/run/libvirt/libvirt-sock"
|
||||
type = "bind"
|
||||
}
|
||||
}
|
||||
|
||||
module "control_plane" {
|
||||
source = "./modules/instance_group"
|
||||
role = "control-plane"
|
||||
amount = var.control_plane_count
|
||||
vcpus = var.vcpus
|
||||
memory = var.memory
|
||||
state_disk_size = var.state_disk_size
|
||||
ip_range_start = var.ip_range_start
|
||||
cidr = "10.42.1.0/24"
|
||||
network_id = libvirt_network.constellation.id
|
||||
pool = libvirt_pool.cluster.name
|
||||
boot_volume_id = libvirt_volume.constellation_coreos_image.id
|
||||
machine = var.machine
|
||||
name = var.name
|
||||
}
|
||||
|
||||
module "worker" {
|
||||
source = "./modules/instance_group"
|
||||
role = "worker"
|
||||
amount = var.worker_count
|
||||
vcpus = var.vcpus
|
||||
memory = var.memory
|
||||
state_disk_size = var.state_disk_size
|
||||
ip_range_start = var.ip_range_start
|
||||
cidr = "10.42.2.0/24"
|
||||
network_id = libvirt_network.constellation.id
|
||||
pool = libvirt_pool.cluster.name
|
||||
boot_volume_id = libvirt_volume.constellation_coreos_image.id
|
||||
machine = var.machine
|
||||
name = var.name
|
||||
}
|
||||
|
||||
resource "libvirt_pool" "cluster" {
|
||||
name = "${var.name}-storage-pool"
|
||||
type = "dir"
|
||||
path = "/var/lib/libvirt/images"
|
||||
}
|
||||
|
||||
resource "libvirt_volume" "constellation_coreos_image" {
|
||||
name = "${var.name}-node-image"
|
||||
pool = libvirt_pool.cluster.name
|
||||
source = var.constellation_coreos_image
|
||||
format = var.image_format
|
||||
}
|
||||
|
||||
resource "libvirt_network" "constellation" {
|
||||
name = "${var.name}-network"
|
||||
mode = "nat"
|
||||
addresses = ["10.42.0.0/16"]
|
||||
dhcp {
|
||||
enabled = true
|
||||
}
|
||||
dns {
|
||||
enabled = true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||
<xsl:output omit-xml-declaration="yes" indent="yes"/>
|
||||
<xsl:template match="node()|@*">
|
||||
<xsl:copy>
|
||||
<xsl:apply-templates select="node()|@*"/>
|
||||
</xsl:copy>
|
||||
</xsl:template>
|
||||
<xsl:template match="os">
|
||||
<os firmware="efi">
|
||||
<xsl:apply-templates select="@*|node()"/>
|
||||
</os>
|
||||
</xsl:template>
|
||||
<xsl:template match="/domain/devices/tpm/backend">
|
||||
<xsl:copy>
|
||||
<xsl:apply-templates select="node()|@*"/>
|
||||
<xsl:element name ="active_pcr_banks">
|
||||
<xsl:element name="sha1"></xsl:element>
|
||||
<xsl:element name="sha256"></xsl:element>
|
||||
<xsl:element name="sha384"></xsl:element>
|
||||
<xsl:element name="sha512"></xsl:element>
|
||||
</xsl:element>
|
||||
</xsl:copy>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
|
@ -0,0 +1,72 @@
|
|||
terraform {
|
||||
required_providers {
|
||||
libvirt = {
|
||||
source = "dmacvicar/libvirt"
|
||||
version = "0.6.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
state_disk_size_byte = 1073741824 * var.state_disk_size
|
||||
}
|
||||
|
||||
resource "libvirt_domain" "instance_group" {
|
||||
name = "${var.name}-${var.role}-${count.index}"
|
||||
count = var.amount
|
||||
memory = var.memory
|
||||
vcpu = var.vcpus
|
||||
machine = var.machine
|
||||
tpm {
|
||||
backend_type = "emulator"
|
||||
backend_version = "2.0"
|
||||
}
|
||||
disk = [
|
||||
{
|
||||
volume_id = element(libvirt_volume.boot_volume.*.id, count.index)
|
||||
scsi : true,
|
||||
// fix for https://github.com/dmacvicar/terraform-provider-libvirt/issues/728
|
||||
block_device : null,
|
||||
file : null,
|
||||
url : null,
|
||||
wwn : null
|
||||
},
|
||||
{
|
||||
volume_id = element(libvirt_volume.state_volume.*.id, count.index)
|
||||
// fix for https://github.com/dmacvicar/terraform-provider-libvirt/issues/728
|
||||
block_device : null,
|
||||
file : null,
|
||||
scsi : null,
|
||||
url : null,
|
||||
wwn : null
|
||||
},
|
||||
]
|
||||
network_interface {
|
||||
network_id = var.network_id
|
||||
hostname = "${var.role}-${count.index}"
|
||||
addresses = [cidrhost(var.cidr, var.ip_range_start + count.index)]
|
||||
wait_for_lease = true
|
||||
}
|
||||
console {
|
||||
type = "pty"
|
||||
target_port = "0"
|
||||
}
|
||||
xml {
|
||||
xslt = file("modules/instance_group/domain.xsl")
|
||||
}
|
||||
}
|
||||
|
||||
resource "libvirt_volume" "boot_volume" {
|
||||
name = "constellation-${var.role}-${count.index}-boot"
|
||||
count = var.amount
|
||||
pool = var.pool
|
||||
base_volume_id = var.boot_volume_id
|
||||
}
|
||||
|
||||
resource "libvirt_volume" "state_volume" {
|
||||
name = "constellation-${var.role}-${count.index}-state"
|
||||
count = var.amount
|
||||
pool = var.pool
|
||||
size = local.state_disk_size_byte
|
||||
format = "qcow2"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
output "instance_ips" {
|
||||
value = flatten(libvirt_domain.instance_group[*].network_interface[*].addresses[*])
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
variable "amount" {
|
||||
type = number
|
||||
description = "amount of nodes"
|
||||
}
|
||||
|
||||
variable "vcpus" {
|
||||
type = number
|
||||
description = "amount of vcpus per instance"
|
||||
}
|
||||
|
||||
variable "memory" {
|
||||
type = number
|
||||
description = "amount of memory per instance (MiB)"
|
||||
}
|
||||
|
||||
variable "state_disk_size" {
|
||||
type = number
|
||||
description = "size of state disk (GiB)"
|
||||
}
|
||||
|
||||
variable "ip_range_start" {
|
||||
type = number
|
||||
description = "first ip address to use within subnet"
|
||||
}
|
||||
|
||||
variable "cidr" {
|
||||
type = string
|
||||
description = "subnet to use for dhcp"
|
||||
}
|
||||
|
||||
variable "network_id" {
|
||||
type = string
|
||||
description = "id of the network to use"
|
||||
}
|
||||
|
||||
variable "pool" {
|
||||
type = string
|
||||
description = "name of the storage pool to use"
|
||||
}
|
||||
|
||||
variable "boot_volume_id" {
|
||||
type = string
|
||||
description = "id of the constellation boot disk"
|
||||
}
|
||||
|
||||
variable "role" {
|
||||
type = string
|
||||
description = "role of the node in the constellation. either 'control-plane' or 'worker'"
|
||||
}
|
||||
|
||||
variable "machine" {
|
||||
type = string
|
||||
description = "machine type. use 'q35' for secure boot and 'pc' for non secure boot. See 'qemu-system-x86_64 -machine help'"
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "name prefix of the cluster VMs"
|
||||
}
|
3
cli/internal/terraform/terraform/qemu/outputs.tf
Normal file
3
cli/internal/terraform/terraform/qemu/outputs.tf
Normal file
|
@ -0,0 +1,3 @@
|
|||
output "ip" {
|
||||
value = module.control_plane.instance_ips[0]
|
||||
}
|
57
cli/internal/terraform/terraform/qemu/variables.tf
Normal file
57
cli/internal/terraform/terraform/qemu/variables.tf
Normal file
|
@ -0,0 +1,57 @@
|
|||
variable "constellation_coreos_image" {
|
||||
type = string
|
||||
description = "constellation OS file path"
|
||||
}
|
||||
|
||||
variable "image_format" {
|
||||
type = string
|
||||
default = "qcow2"
|
||||
description = "image format"
|
||||
}
|
||||
|
||||
variable "control_plane_count" {
|
||||
type = number
|
||||
description = "amount of control plane nodes"
|
||||
}
|
||||
|
||||
variable "worker_count" {
|
||||
type = number
|
||||
description = "amount of worker nodes"
|
||||
}
|
||||
|
||||
variable "vcpus" {
|
||||
type = number
|
||||
description = "amount of vcpus per instance"
|
||||
}
|
||||
|
||||
variable "memory" {
|
||||
type = number
|
||||
description = "amount of memory per instance (MiB)"
|
||||
}
|
||||
|
||||
variable "state_disk_size" {
|
||||
type = number
|
||||
description = "size of state disk (GiB)"
|
||||
}
|
||||
|
||||
variable "ip_range_start" {
|
||||
type = number
|
||||
description = "first ip address to use within subnet"
|
||||
}
|
||||
|
||||
variable "machine" {
|
||||
type = string
|
||||
default = "q35"
|
||||
description = "machine type. use 'q35' for secure boot and 'pc' for non secure boot. See 'qemu-system-x86_64 -machine help'"
|
||||
}
|
||||
|
||||
variable "metadata_api_image" {
|
||||
type = string
|
||||
description = "container image of the QEMU metadata api server"
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
default = "constellation"
|
||||
description = "name prefix of the cluster VMs"
|
||||
}
|
235
cli/internal/terraform/terraform_test.go
Normal file
235
cli/internal/terraform/terraform_test.go
Normal file
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
Copyright (c) Edgeless Systems GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
|
||||
"github.com/edgelesssys/constellation/v2/internal/file"
|
||||
"github.com/hashicorp/terraform-exec/tfexec"
|
||||
tfjson "github.com/hashicorp/terraform-json"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/multierr"
|
||||
)
|
||||
|
||||
func TestCreateInstances(t *testing.T) {
|
||||
someErr := errors.New("error")
|
||||
getState := func() *tfjson.State {
|
||||
workingState := tfjson.State{
|
||||
Values: &tfjson.StateValues{
|
||||
Outputs: map[string]*tfjson.StateOutput{
|
||||
"ip": {
|
||||
Value: "192.0.2.100",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &workingState
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
provider cloudprovider.Provider
|
||||
input CreateClusterInput
|
||||
tf *stubTerraform
|
||||
fs afero.Fs
|
||||
wantErr bool
|
||||
}{
|
||||
"works": {
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: &stubTerraform{
|
||||
showState: getState(),
|
||||
},
|
||||
fs: afero.NewMemMapFs(),
|
||||
},
|
||||
"init fails": {
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: &stubTerraform{
|
||||
initErr: someErr,
|
||||
showState: getState(),
|
||||
},
|
||||
fs: afero.NewMemMapFs(),
|
||||
wantErr: true,
|
||||
},
|
||||
"apply fails": {
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: &stubTerraform{
|
||||
applyErr: someErr,
|
||||
showState: getState(),
|
||||
},
|
||||
fs: afero.NewMemMapFs(),
|
||||
wantErr: true,
|
||||
},
|
||||
"show fails": {
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: &stubTerraform{
|
||||
showErr: someErr,
|
||||
},
|
||||
fs: afero.NewMemMapFs(),
|
||||
wantErr: true,
|
||||
},
|
||||
"no ip": {
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: &stubTerraform{
|
||||
showState: &tfjson.State{
|
||||
Values: &tfjson.StateValues{
|
||||
Outputs: map[string]*tfjson.StateOutput{},
|
||||
},
|
||||
},
|
||||
},
|
||||
fs: afero.NewMemMapFs(),
|
||||
wantErr: true,
|
||||
},
|
||||
"prepare workspace fails": {
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: &stubTerraform{
|
||||
showState: getState(),
|
||||
},
|
||||
fs: afero.NewReadOnlyFs(afero.NewMemMapFs()),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
c := &Client{
|
||||
provider: tc.provider,
|
||||
tf: tc.tf,
|
||||
file: file.NewHandler(tc.fs),
|
||||
}
|
||||
|
||||
err := c.CreateCluster(context.Background(), "test", tc.input)
|
||||
if tc.wantErr {
|
||||
assert.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestroyInstances(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
tf *stubTerraform
|
||||
wantErr bool
|
||||
}{
|
||||
"works": {
|
||||
tf: &stubTerraform{},
|
||||
},
|
||||
"destroy fails": {
|
||||
tf: &stubTerraform{
|
||||
destroyErr: errors.New("error"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
c := &Client{
|
||||
provider: cloudprovider.QEMU,
|
||||
tf: tc.tf,
|
||||
}
|
||||
|
||||
err := c.DestroyCluster(context.Background())
|
||||
if tc.wantErr {
|
||||
assert.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupWorkspace(t *testing.T) {
|
||||
someContent := []byte("some content")
|
||||
|
||||
testCases := map[string]struct {
|
||||
provider cloudprovider.Provider
|
||||
prepareFS func(file.Handler) error
|
||||
wantErr bool
|
||||
}{
|
||||
"files are cleaned up": {
|
||||
provider: cloudprovider.QEMU,
|
||||
prepareFS: func(f file.Handler) error {
|
||||
var err error
|
||||
err = multierr.Append(err, f.Write("terraform.tfvars", someContent))
|
||||
err = multierr.Append(err, f.Write("terraform.tfstate", someContent))
|
||||
return multierr.Append(err, f.Write("terraform.tfstate.backup", someContent))
|
||||
},
|
||||
},
|
||||
"no error if files do not exist": {
|
||||
provider: cloudprovider.QEMU,
|
||||
prepareFS: func(f file.Handler) error { return nil },
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
file := file.NewHandler(afero.NewMemMapFs())
|
||||
require.NoError(tc.prepareFS(file))
|
||||
|
||||
c := &Client{
|
||||
provider: tc.provider,
|
||||
file: file,
|
||||
tf: &stubTerraform{},
|
||||
}
|
||||
|
||||
err := c.CleanUpWorkspace()
|
||||
if tc.wantErr {
|
||||
assert.Error(err)
|
||||
return
|
||||
}
|
||||
assert.NoError(err)
|
||||
_, err = file.Stat("terraform.tfvars")
|
||||
assert.ErrorIs(err, fs.ErrNotExist)
|
||||
_, err = file.Stat("terraform.tfstate")
|
||||
assert.ErrorIs(err, fs.ErrNotExist)
|
||||
_, err = file.Stat("terraform.tfstate.backup")
|
||||
assert.ErrorIs(err, fs.ErrNotExist)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type stubTerraform struct {
|
||||
applyErr error
|
||||
destroyErr error
|
||||
initErr error
|
||||
showErr error
|
||||
showState *tfjson.State
|
||||
}
|
||||
|
||||
func (s *stubTerraform) Apply(context.Context, ...tfexec.ApplyOption) error {
|
||||
return s.applyErr
|
||||
}
|
||||
|
||||
func (s *stubTerraform) Destroy(context.Context, ...tfexec.DestroyOption) error {
|
||||
return s.destroyErr
|
||||
}
|
||||
|
||||
func (s *stubTerraform) Init(context.Context, ...tfexec.InitOption) error {
|
||||
return s.initErr
|
||||
}
|
||||
|
||||
func (s *stubTerraform) Show(context.Context, ...tfexec.ShowOption) (*tfjson.State, error) {
|
||||
return s.showState, s.showErr
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue