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:
Daniel Weiße 2022-09-26 15:52:31 +02:00 committed by GitHub
parent 2b32b79026
commit 804c173d52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1066 additions and 182 deletions

View 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
}

View 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
}

View 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)
}
}

View 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)
}

View 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
}
}

View file

@ -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>

View file

@ -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"
}

View file

@ -0,0 +1,3 @@
output "instance_ips" {
value = flatten(libvirt_domain.instance_group[*].network_interface[*].addresses[*])
}

View file

@ -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"
}

View file

@ -0,0 +1,3 @@
output "ip" {
value = module.control_plane.instance_ips[0]
}

View 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"
}

View 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
}