mirror of https://github.com/hashicorp/consul
479 lines
11 KiB
Go
479 lines
11 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package tfgen
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/go-hclog"
|
|
|
|
"github.com/hashicorp/consul/testing/deployer/sprawl/internal/runner"
|
|
"github.com/hashicorp/consul/testing/deployer/sprawl/internal/secrets"
|
|
"github.com/hashicorp/consul/testing/deployer/topology"
|
|
"github.com/hashicorp/consul/testing/deployer/util"
|
|
)
|
|
|
|
type Generator struct {
|
|
logger hclog.Logger
|
|
runner *runner.Runner
|
|
topology *topology.Topology
|
|
sec *secrets.Store
|
|
workdir string
|
|
license string
|
|
|
|
tfLogger io.Writer
|
|
|
|
// set during network phase
|
|
remainingSubnets map[string]struct{}
|
|
|
|
launched bool
|
|
}
|
|
|
|
func NewGenerator(
|
|
logger hclog.Logger,
|
|
runner *runner.Runner,
|
|
topo *topology.Topology,
|
|
sec *secrets.Store,
|
|
workdir string,
|
|
license string,
|
|
) (*Generator, error) {
|
|
if logger == nil {
|
|
panic("logger is required")
|
|
}
|
|
if runner == nil {
|
|
panic("runner is required")
|
|
}
|
|
if topo == nil {
|
|
panic("topology is required")
|
|
}
|
|
if sec == nil {
|
|
panic("secrets store is required")
|
|
}
|
|
if workdir == "" {
|
|
panic("workdir is required")
|
|
}
|
|
|
|
g := &Generator{
|
|
logger: logger,
|
|
runner: runner,
|
|
sec: sec,
|
|
workdir: workdir,
|
|
license: license,
|
|
|
|
tfLogger: logger.Named("terraform").StandardWriter(&hclog.StandardLoggerOptions{ForceLevel: hclog.Debug}),
|
|
}
|
|
g.SetTopology(topo)
|
|
|
|
_ = g.terraformDestroy(context.Background(), true) // cleanup prior run
|
|
|
|
return g, nil
|
|
}
|
|
|
|
func (g *Generator) MarkLaunched() {
|
|
g.launched = true
|
|
}
|
|
|
|
func (g *Generator) SetTopology(topo *topology.Topology) {
|
|
if topo == nil {
|
|
panic("topology is required")
|
|
}
|
|
g.topology = topo
|
|
}
|
|
|
|
type Step int
|
|
|
|
const (
|
|
StepAll Step = 0
|
|
StepNetworks Step = 1
|
|
StepServers Step = 2
|
|
StepAgents Step = 3
|
|
StepServices Step = 4
|
|
// StepPeering Step = XXX5
|
|
StepRelaunch Step = 5
|
|
)
|
|
|
|
func (s Step) String() string {
|
|
switch s {
|
|
case StepAll:
|
|
return "all"
|
|
case StepNetworks:
|
|
return "networks"
|
|
case StepServers:
|
|
return "servers"
|
|
case StepAgents:
|
|
return "agents"
|
|
case StepServices:
|
|
return "services"
|
|
case StepRelaunch:
|
|
return "relaunch"
|
|
// case StepPeering:
|
|
// return "peering"
|
|
default:
|
|
return "UNKNOWN--" + strconv.Itoa(int(s))
|
|
}
|
|
}
|
|
|
|
func (s Step) StartServers() bool { return s >= StepServers }
|
|
|
|
func (s Step) StartAgents() bool { return s >= StepAgents }
|
|
|
|
func (s Step) StartServices() bool { return s >= StepServices }
|
|
|
|
// func (s Step) InitiatePeering() bool { return s >= StepPeering }
|
|
|
|
func (g *Generator) Regenerate() error {
|
|
return g.Generate(StepRelaunch)
|
|
}
|
|
|
|
func (g *Generator) Generate(step Step) error {
|
|
if g.launched && step != StepRelaunch {
|
|
return fmt.Errorf("cannot use step %q after successful launch; see Regenerate()", step)
|
|
}
|
|
|
|
g.logger.Info("generating and creating resources", "step", step.String())
|
|
var (
|
|
networks []Resource
|
|
volumes []Resource
|
|
images []Resource
|
|
containers []Resource
|
|
|
|
imageNames = make(map[string]string)
|
|
)
|
|
|
|
addVolume := func(name string) {
|
|
volumes = append(volumes, DockerVolume(name))
|
|
}
|
|
addImage := func(name, image string) {
|
|
if image == "" {
|
|
return
|
|
}
|
|
if _, ok := imageNames[image]; ok {
|
|
return
|
|
}
|
|
|
|
if name == "" {
|
|
name = DockerImageResourceName(image)
|
|
}
|
|
|
|
imageNames[image] = name
|
|
|
|
g.logger.Debug("registering image", "resource", name, "image", image)
|
|
|
|
images = append(images, DockerImage(name, image))
|
|
}
|
|
|
|
if g.remainingSubnets == nil {
|
|
g.remainingSubnets = util.GetPossibleDockerNetworkSubnets()
|
|
}
|
|
if len(g.remainingSubnets) == 0 {
|
|
return fmt.Errorf("exhausted all docker networks")
|
|
}
|
|
|
|
addImage("nginx", "nginx:latest")
|
|
addImage("coredns", "coredns/coredns:latest")
|
|
for _, net := range g.topology.SortedNetworks() {
|
|
if net.Subnet == "" {
|
|
// Because this harness runs on a linux or macos host, we can't
|
|
// directly invoke the moby libnetwork calls to check for free
|
|
// subnets as it would have to cross into the docker desktop vm on
|
|
// mac.
|
|
//
|
|
// Instead rely on map iteration order being random to avoid
|
|
// collisions, but detect the terraform failure and retry until
|
|
// success.
|
|
|
|
var ipnet string
|
|
for ipnet = range g.remainingSubnets {
|
|
}
|
|
if ipnet == "" {
|
|
return fmt.Errorf("could not get a free docker network")
|
|
}
|
|
delete(g.remainingSubnets, ipnet)
|
|
|
|
if _, err := net.SetSubnet(ipnet); err != nil {
|
|
return fmt.Errorf("assigned subnet is invalid %q: %w", ipnet, err)
|
|
}
|
|
}
|
|
networks = append(networks, DockerNetwork(net.DockerName, net.Subnet))
|
|
|
|
var (
|
|
// We always ask for a /24, so just blindly pick x.x.x.252 as our
|
|
// proxy address. There's an offset of 2 in the list of available
|
|
// addresses here because we removed x.x.x.0 and x.x.x.1 from the
|
|
// pool.
|
|
proxyIPAddress = net.IPByIndex(250)
|
|
// Grab x.x.x.253 for the dns server
|
|
dnsIPAddress = net.IPByIndex(251)
|
|
)
|
|
|
|
{
|
|
// wrote, hashes, err := g.write
|
|
}
|
|
|
|
{ // nginx forward proxy
|
|
_, hash, err := g.writeNginxConfig(net)
|
|
if err != nil {
|
|
return fmt.Errorf("writeNginxConfig[%s]: %w", net.Name, err)
|
|
}
|
|
|
|
containers = append(containers, g.getForwardProxyContainer(net, proxyIPAddress, hash))
|
|
|
|
}
|
|
|
|
net.ProxyAddress = proxyIPAddress
|
|
net.DNSAddress = ""
|
|
|
|
if net.IsLocal() {
|
|
wrote, hashes, err := g.writeCoreDNSFiles(net, dnsIPAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("writeCoreDNSFiles[%s]: %w", net.Name, err)
|
|
}
|
|
if wrote {
|
|
net.DNSAddress = dnsIPAddress
|
|
containers = append(containers, g.getCoreDNSContainer(net, dnsIPAddress, hashes))
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, c := range g.topology.SortedClusters() {
|
|
if c.TLSVolumeName == "" {
|
|
c.TLSVolumeName = c.Name + "-tls-material-" + g.topology.ID
|
|
}
|
|
addVolume(c.TLSVolumeName)
|
|
}
|
|
|
|
addImage("pause", "docker.mirror.hashicorp.services/hashiderek/pause")
|
|
|
|
if step.StartServers() {
|
|
for _, c := range g.topology.SortedClusters() {
|
|
for _, node := range c.SortedNodes() {
|
|
if node.Disabled {
|
|
continue
|
|
}
|
|
addImage("", node.Images.Consul)
|
|
addImage("", node.Images.EnvoyConsulImage())
|
|
addImage("", node.Images.LocalDataplaneImage())
|
|
|
|
if node.IsAgent() {
|
|
addVolume(node.DockerName())
|
|
}
|
|
|
|
for _, wrk := range node.Workloads {
|
|
addImage("", wrk.Image)
|
|
}
|
|
|
|
myContainers, err := g.generateNodeContainers(step, c, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
containers = append(containers, myContainers...)
|
|
}
|
|
}
|
|
}
|
|
|
|
tfpath := func(p string) string {
|
|
return filepath.Join(g.workdir, "terraform", p)
|
|
}
|
|
|
|
if _, err := WriteHCLResourceFile(g.logger, []Resource{Text(terraformPrelude)}, tfpath("init.tf"), 0644); err != nil {
|
|
return err
|
|
}
|
|
if netResult, err := WriteHCLResourceFile(g.logger, networks, tfpath("networks.tf"), 0644); err != nil {
|
|
return err
|
|
} else if netResult == UpdateResultModified {
|
|
if step != StepNetworks {
|
|
return fmt.Errorf("cannot change networking details after they are established")
|
|
}
|
|
}
|
|
if _, err := WriteHCLResourceFile(g.logger, volumes, tfpath("volumes.tf"), 0644); err != nil {
|
|
return err
|
|
}
|
|
if _, err := WriteHCLResourceFile(g.logger, images, tfpath("images.tf"), 0644); err != nil {
|
|
return err
|
|
}
|
|
if _, err := WriteHCLResourceFile(g.logger, containers, tfpath("containers.tf"), 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := g.terraformApply(context.TODO()); err != nil {
|
|
return err
|
|
}
|
|
|
|
out, err := g.terraformOutputs(context.TODO())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return g.digestOutputs(out)
|
|
}
|
|
|
|
func (g *Generator) DestroyAll() error {
|
|
return g.terraformDestroy(context.TODO(), false)
|
|
}
|
|
|
|
func (g *Generator) DestroyAllQuietly() error {
|
|
return g.terraformDestroy(context.TODO(), true)
|
|
}
|
|
|
|
func (g *Generator) terraformApply(ctx context.Context) error {
|
|
tfdir := filepath.Join(g.workdir, "terraform")
|
|
|
|
if _, err := os.Stat(filepath.Join(tfdir, ".terraform")); err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
// On the fly init
|
|
g.logger.Info("Running 'terraform init'...")
|
|
if err := g.runner.TerraformExec(ctx, []string{"init", "-input=false"}, g.tfLogger, tfdir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
g.logger.Info("Running 'terraform apply'...")
|
|
return g.runner.TerraformExec(ctx, []string{"apply", "-input=false", "-auto-approve"}, g.tfLogger, tfdir)
|
|
}
|
|
|
|
func (g *Generator) terraformDestroy(ctx context.Context, quiet bool) error {
|
|
g.logger.Info("Running 'terraform destroy'...")
|
|
|
|
var out io.Writer
|
|
if quiet {
|
|
out = io.Discard
|
|
} else {
|
|
out = g.tfLogger
|
|
}
|
|
|
|
tfdir := filepath.Join(g.workdir, "terraform")
|
|
return g.runner.TerraformExec(ctx, []string{
|
|
"destroy", "-input=false", "-auto-approve", "-refresh=false",
|
|
}, out, tfdir)
|
|
}
|
|
|
|
func (g *Generator) terraformOutputs(ctx context.Context) (*Outputs, error) {
|
|
tfdir := filepath.Join(g.workdir, "terraform")
|
|
|
|
var buf bytes.Buffer
|
|
err := g.runner.TerraformExec(ctx, []string{
|
|
"output", "-json",
|
|
}, &buf, tfdir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type outputVar struct {
|
|
// may be map[string]any
|
|
Value any `json:"value"`
|
|
}
|
|
|
|
raw := make(map[string]*outputVar)
|
|
dec := json.NewDecoder(&buf)
|
|
if err := dec.Decode(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &Outputs{}
|
|
|
|
for key, rv := range raw {
|
|
switch {
|
|
case strings.HasPrefix(key, "ports_"):
|
|
cluster, nid, ok := extractNodeOutputKey("ports_", key)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unexpected output var: %s", key)
|
|
}
|
|
|
|
ports := make(map[int]int)
|
|
for k, v := range rv.Value.(map[string]any) {
|
|
ki, err := strconv.Atoi(k)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unexpected port value %q: %w", k, err)
|
|
}
|
|
ports[ki] = int(v.(float64))
|
|
}
|
|
out.SetNodePorts(cluster, nid, ports)
|
|
case strings.HasPrefix(key, "forwardproxyport_"):
|
|
netname := strings.TrimPrefix(key, "forwardproxyport_")
|
|
|
|
found := rv.Value.(map[string]any)
|
|
if len(found) != 1 {
|
|
return nil, fmt.Errorf("found unexpected ports: %v", found)
|
|
}
|
|
got, ok := found[strconv.Itoa(proxyInternalPort)]
|
|
if !ok {
|
|
return nil, fmt.Errorf("found unexpected ports: %v", found)
|
|
}
|
|
|
|
out.SetProxyPort(netname, int(got.(float64)))
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func extractNodeOutputKey(prefix, key string) (string, topology.NodeID, bool) {
|
|
clusterNode := strings.TrimPrefix(key, prefix)
|
|
|
|
cluster, nodeid, ok := strings.Cut(clusterNode, "_")
|
|
if !ok {
|
|
return "", topology.NodeID{}, false
|
|
}
|
|
|
|
partition, node, ok := strings.Cut(nodeid, "_")
|
|
if !ok {
|
|
return "", topology.NodeID{}, false
|
|
}
|
|
|
|
nid := topology.NewNodeID(node, partition)
|
|
return cluster, nid, true
|
|
}
|
|
|
|
type Outputs struct {
|
|
ProxyPorts map[string]int // net -> exposed port
|
|
Nodes map[string]map[topology.NodeID]*NodeOutput // clusterID -> node -> stuff
|
|
}
|
|
|
|
func (o *Outputs) SetNodePorts(cluster string, nid topology.NodeID, ports map[int]int) {
|
|
nodeOut := o.getNode(cluster, nid)
|
|
nodeOut.Ports = ports
|
|
}
|
|
|
|
func (o *Outputs) SetProxyPort(net string, port int) {
|
|
if o.ProxyPorts == nil {
|
|
o.ProxyPorts = make(map[string]int)
|
|
}
|
|
o.ProxyPorts[net] = port
|
|
}
|
|
|
|
func (o *Outputs) getNode(cluster string, nid topology.NodeID) *NodeOutput {
|
|
if o.Nodes == nil {
|
|
o.Nodes = make(map[string]map[topology.NodeID]*NodeOutput)
|
|
}
|
|
cnodes, ok := o.Nodes[cluster]
|
|
if !ok {
|
|
cnodes = make(map[topology.NodeID]*NodeOutput)
|
|
o.Nodes[cluster] = cnodes
|
|
}
|
|
|
|
nodeOut, ok := cnodes[nid]
|
|
if !ok {
|
|
nodeOut = &NodeOutput{}
|
|
cnodes[nid] = nodeOut
|
|
}
|
|
|
|
return nodeOut
|
|
}
|
|
|
|
type NodeOutput struct {
|
|
Ports map[int]int `json:",omitempty"`
|
|
}
|