consul/testing/deployer/sprawl/internal/tfgen/gen.go

479 lines
11 KiB
Go
Raw Normal View History

[COMPLIANCE] License changes (#18443) * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Updating the license from MPL to Business Source License Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at <Blog URL>, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl. * add missing license headers * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2023-08-11 13:12:13 +00:00
// 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"`
}