pkg/config/expand.go (401 lines of code) (raw):
// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"errors"
"fmt"
"hpc-toolkit/pkg/modulereader"
"hpc-toolkit/pkg/sourcereader"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
const (
blueprintLabel string = "ghpc_blueprint"
deploymentLabel string = "ghpc_deployment"
)
func validateModuleInputs(mp ModulePath, m Module, bp Blueprint) error {
mi := m.InfoOrDie()
errs := Errors{}
for _, input := range mi.Inputs {
ip := mp.Settings.Dot(input.Name)
if !m.Settings.Has(input.Name) {
if input.Required {
errs.At(ip,
HintError{
Err: fmt.Errorf("a required setting %q is missing from a module %q", input.Name, m.ID),
Hint: fmt.Sprintf("%q description: %s", input.Name, input.Description)})
}
continue
}
errs.At(ip, checkInputValueMatchesType(m.Settings.Get(input.Name), input, bp))
}
return errs.OrNil()
}
func attemptEvalModuleInput(val cty.Value, bp Blueprint) (cty.Value, bool) {
v, err := bp.Eval(val)
// there could be a legitimate reasons for it.
// e.g. use of modules output or unsupported (by gcluster) functions
// TODO:
// * substitute module outputs with an UnknownValue
// * skip if uses functions with side-effects, e.g. `file`
// * add implementation of all pure terraform functions
// * add positive selection for eval-errors to bubble up
return v, err == nil
}
func checkInputValueMatchesType(val cty.Value, input modulereader.VarInfo, bp Blueprint) error {
v, ok := attemptEvalModuleInput(val, bp)
if !ok || input.Type == cty.NilType {
return nil // skip, can do nothing
}
// cty does panic on some edge cases, e.g. (cty.NilVal)
// we don't anticipate any of those, but just in case, catch panic and swallow it
defer func() { recover() }()
// TODO: consider returning error (not panic) or logging warning
if _, err := convert.Convert(v, input.Type); err != nil {
return fmt.Errorf("unsuitable value for %q: %w", input.Name, err)
}
return nil
}
func validateModulesAreUsed(bp Blueprint) error {
used := map[ModuleID]bool{}
bp.WalkModulesSafe(func(_ ModulePath, m *Module) {
for ref := range valueReferences(m.Settings.AsObject()) {
used[ref.Module] = true
}
})
errs := Errors{}
bp.WalkModulesSafe(func(p ModulePath, m *Module) {
if m.InfoOrDie().Metadata.Ghpc.HasToBeUsed && !used[m.ID] {
errs.At(p.ID, HintError{
"you need to add it to the `use`-block of downstream modules",
fmt.Errorf("module %q was not used", m.ID)})
}
})
return errs.OrNil()
}
func (bp *Blueprint) expandVars() error {
if err := validateVars(*bp); err != nil {
return err
}
bp.expandGlobalLabels()
return nil
}
func (bp *Blueprint) substituteModuleSources() {
bp.WalkModulesSafe(func(_ ModulePath, m *Module) {
m.Source = bp.transformSource(m.Source)
})
}
func (bp Blueprint) transformSource(s string) string {
if sourcereader.IsEmbeddedPath(s) && bp.ToolkitModulesURL != "" && bp.ToolkitModulesVersion != "" {
return fmt.Sprintf("%s//%s?ref=%s&depth=1", bp.ToolkitModulesURL, s, bp.ToolkitModulesVersion)
}
return s
}
func (bp *Blueprint) expandGroups() error {
bp.addKindToModules()
bp.substituteModuleSources()
if err := checkModulesAndGroups(*bp); err != nil {
return err
}
var errs Errors
for ig := range bp.Groups {
errs.Add(bp.expandGroup(Root.Groups.At(ig), &bp.Groups[ig]))
}
if errs.Any() {
return errs
}
// Following actions depend on whole blueprint being expanded
// run it after all groups are expanded
if err := validateModulesAreUsed(*bp); err != nil {
return err
}
bp.populateOutputs()
return nil
}
func (bp Blueprint) expandGroup(gp groupPath, g *Group) error {
var errs Errors
bp.expandBackend(g)
if g.Kind() == TerraformKind {
bp.expandProviders(g)
}
for im := range g.Modules {
errs.Add(bp.expandModule(gp.Modules.At(im), &g.Modules[im]))
}
return errs.OrNil()
}
func (bp Blueprint) expandModule(mp ModulePath, m *Module) error {
bp.applyUseModules(m)
bp.applyGlobalVarsInModule(m)
return validateModuleInputs(mp, *m, bp)
}
func (bp Blueprint) expandBackend(grp *Group) {
// 1. DEFAULT: use TerraformBackend configuration (if supplied)
// 2. If top-level TerraformBackendDefaults is defined, insert that
// backend into resource groups which have no explicit
// TerraformBackend
// 3. In all cases, add a prefix for GCS backends if one is not defined
defaults := bp.TerraformBackendDefaults
if defaults.Type == "" {
return
}
be := &grp.TerraformBackend
if be.Type == "" {
(*be) = defaults
}
if be.Type == "gcs" && !be.Configuration.Has("prefix") {
prefix := MustParseExpression(
fmt.Sprintf(`"%s/${var.deployment_name}/%s"`, bp.BlueprintName, grp.Name))
be.Configuration = be.Configuration.With("prefix", prefix.AsValue())
}
}
func getDefaultGoogleProviders(bp Blueprint) map[string]TerraformProvider {
gglConf := Dict{}
for s, v := range map[string]string{
"project": "project_id",
"region": "region",
"zone": "zone"} {
if bp.Vars.Has(v) {
gglConf = gglConf.With(s, GlobalRef(v).AsValue())
}
}
return map[string]TerraformProvider{
"google": {
Source: "hashicorp/google",
Version: "~> 6.27.0",
Configuration: gglConf},
"google-beta": {
Source: "hashicorp/google-beta",
Version: "~> 6.27.0",
Configuration: gglConf}}
}
func (bp Blueprint) expandProviders(grp *Group) {
// 1. DEFAULT: use TerraformProviders provider dictionary (if supplied)
// 2. If top-level TerraformProviders is defined, insert that
// provider dictionary into resource groups which have no explicit
// TerraformProviders
defaults := bp.TerraformProviders
pv := &grp.TerraformProviders
if defaults == nil {
defaults = getDefaultGoogleProviders(bp)
}
if (*pv) == nil {
(*pv) = maps.Clone(defaults)
}
}
func getModuleInputMap(inputs []modulereader.VarInfo) map[string]cty.Type {
modInputs := make(map[string]cty.Type)
for _, input := range inputs {
modInputs[input.Name] = input.Type
}
return modInputs
}
// initialize a Toolkit setting that corresponds to a module input of type list
// create new list if unset, append if already set, error if value not a list
func (mod *Module) addListValue(setting string, value cty.Value) {
args := []cty.Value{value}
mods := map[ModuleID]bool{}
for _, mod := range IsProductOfModuleUse(value) {
mods[mod] = true
}
if mod.Settings.Has(setting) {
cur := mod.Settings.Get(setting)
for _, mod := range IsProductOfModuleUse(cur) {
mods[mod] = true
}
args = append(args, cur)
}
exp := FunctionCallExpression("flatten", cty.TupleVal(args))
val := AsProductOfModuleUse(exp.AsValue(), maps.Keys(mods)...)
mod.Settings = mod.Settings.With(setting, val)
}
// useModule matches input variables in a "using" module to output values
// from a "used" module. It may be used iteratively to successively apply used
// modules in order of precedence. New input variables are added to the using
// module as Toolkit variable references (in same format as a blueprint). If
// the input variable already has a setting, it is ignored, unless the value is
// a list, in which case output values are appended and flattened using HCL.
//
// mod: "using" module as defined above
// use: "used" module as defined above
func useModule(mod *Module, use Module) {
modInputsMap := getModuleInputMap(mod.InfoOrDie().Inputs)
for _, useOutput := range use.InfoOrDie().Outputs {
setting := useOutput.Name
// Skip settings that do not have matching module inputs
inputType, ok := modInputsMap[setting]
if !ok || setting == "labels" { // also do not "use" module labels
continue
}
alreadySet := mod.Settings.Has(setting)
if alreadySet && len(IsProductOfModuleUse(mod.Settings.Get(setting))) == 0 {
continue // set explicitly, skip
}
// skip settings that are not of list type, but already have a value
// these were probably added by a previous call to this function
isList := inputType.IsListType()
if alreadySet && !isList {
continue
}
v := AsProductOfModuleUse(ModuleRef(use.ID, setting).AsValue(), use.ID)
if !isList {
mod.Settings = mod.Settings.With(setting, v)
} else {
mod.addListValue(setting, v)
}
}
}
// applyUseModules applies variables from modules listed in the "use" field
// when/if applicable
func (bp Blueprint) applyUseModules(m *Module) error {
for _, u := range m.Use {
used, err := bp.Module(u)
if err != nil { // should never happen
panic(err)
}
useModule(m, *used)
}
return nil
}
// expandGlobalLabels sets defaults for labels based on other variables.
func (bp *Blueprint) expandGlobalLabels() {
defaults := cty.ObjectVal(map[string]cty.Value{
blueprintLabel: cty.StringVal(bp.BlueprintName),
deploymentLabel: GlobalRef("deployment_name").AsValue()})
labels := "labels"
var gl cty.Value
if !bp.Vars.Has(labels) {
gl = defaults
} else {
gl = FunctionCallExpression("merge", defaults, bp.Vars.Get(labels)).AsValue()
}
bp.Vars = bp.Vars.With(labels, gl)
}
func combineModuleLabels(mod Module) cty.Value {
ref := GlobalRef("labels").AsValue()
set := mod.Settings.Get("labels")
if !set.IsNull() {
// = merge(vars.labels, {...labels_from_settings...})
return FunctionCallExpression("merge", ref, set).AsValue()
}
return ref // = vars.labels
}
func (bp Blueprint) applyGlobalVarsInModule(mod *Module) {
mi := mod.InfoOrDie()
for _, input := range mi.Inputs {
if input.Name == "labels" && bp.Vars.Has("labels") {
// labels are special case, always make use of global labels
mod.Settings = mod.Settings.With("labels", combineModuleLabels(*mod))
}
// Module setting exists? Nothing more needs to be done.
if mod.Settings.Has(input.Name) {
continue
}
// If it's not set, is there a global we can use?
if bp.Vars.Has(input.Name) {
mod.Settings = mod.Settings.With(input.Name, GlobalRef(input.Name).AsValue())
continue
}
if input.Name == mi.Metadata.Ghpc.InjectModuleId {
mod.Settings = mod.Settings.With(input.Name, cty.StringVal(string(mod.ID)))
}
}
}
// AutomaticOutputName generates unique deployment-group-level output names
func AutomaticOutputName(outputName string, moduleID ModuleID) string {
return outputName + "_" + string(moduleID)
}
// Checks validity of reference to a module:
// * module exists;
// * module is not a Packer module;
// * module is not in a later deployment group.
func validateModuleReference(bp Blueprint, from Module, toID ModuleID) error {
to, err := bp.Module(toID)
if err != nil {
mods := []string{}
bp.WalkModulesSafe(func(_ ModulePath, m *Module) {
mods = append(mods, string(m.ID))
})
return HintSpelling(string(toID), mods, err)
}
if to.Kind == PackerKind {
return fmt.Errorf("packer modules cannot be used by other modules: %s", to.ID)
}
fg := bp.ModuleGroupOrDie(from.ID)
tg := bp.ModuleGroupOrDie(to.ID)
fgi := slices.IndexFunc(bp.Groups, func(g Group) bool { return g.Name == fg.Name })
tgi := slices.IndexFunc(bp.Groups, func(g Group) bool { return g.Name == tg.Name })
if tgi > fgi {
return fmt.Errorf("%s: %s is in a later group", errMsgIntergroupOrder, to.ID)
}
return nil
}
// Checks validity of reference to a module output:
// * reference to an existing global variable;
// * reference to a module is valid;
// * referenced module output exists.
func validateModuleSettingReference(bp Blueprint, mod Module, r Reference) error {
// simplest case to evaluate is a deployment variable's existence
if r.GlobalVar {
if !bp.Vars.Has(r.Name) {
err := fmt.Errorf("module %q references unknown global variable %q", mod.ID, r.Name)
return HintSpelling(r.Name, bp.Vars.Keys(), err)
}
return nil
}
if err := validateModuleReference(bp, mod, r.Module); err != nil {
var unkModErr UnknownModuleError
if errors.As(err, &unkModErr) {
hints := []string{"vars"}
bp.WalkModulesSafe(func(_ ModulePath, m *Module) {
hints = append(hints, string(m.ID))
})
return HintSpelling(string(unkModErr.ID), hints, unkModErr)
}
return err
}
tm, _ := bp.Module(r.Module) // Shouldn't error if validateModuleReference didn't
mi, err := modulereader.GetModuleInfo(tm.Source, tm.Kind.String())
if err != nil {
return err
}
outputs := []string{}
for _, o := range mi.Outputs {
outputs = append(outputs, o.Name)
}
if !slices.Contains(outputs, r.Name) {
err := fmt.Errorf("module %q does not have output %q", tm.ID, r.Name)
return HintSpelling(r.Name, outputs, err)
}
return nil
}
// FindAllIntergroupReferences finds all intergroup references within the group
func (g Group) FindAllIntergroupReferences(bp Blueprint) []Reference {
igcRefs := map[Reference]bool{}
for _, mod := range g.Modules {
for _, ref := range FindIntergroupReferences(mod.Settings.AsObject(), mod, bp) {
igcRefs[ref] = true
}
}
return maps.Keys(igcRefs)
}
// FindIntergroupReferences finds all references to other groups used in the given value
func FindIntergroupReferences(v cty.Value, mod Module, bp Blueprint) []Reference {
g := bp.ModuleGroupOrDie(mod.ID)
res := []Reference{}
for r := range valueReferences(v) {
if !r.GlobalVar && bp.ModuleGroupOrDie(r.Module).Name != g.Name {
res = append(res, r)
}
}
return res
}
// find all intergroup references and add them to source Module.Outputs
func (bp *Blueprint) populateOutputs() {
refs := map[Reference]bool{}
bp.WalkModulesSafe(func(_ ModulePath, m *Module) {
rs := FindIntergroupReferences(m.Settings.AsObject(), *m, *bp)
for _, r := range rs {
refs[r] = true
}
})
bp.WalkModulesSafe(func(_ ModulePath, m *Module) {
for r := range refs {
if r.Module != m.ID {
continue // find IGC references pointing to this module
}
if slices.ContainsFunc(m.Outputs, func(o modulereader.OutputInfo) bool { return o.Name == r.Name }) {
continue // output is already registered
}
m.Outputs = append(m.Outputs, modulereader.OutputInfo{
Name: r.Name,
Description: "Automatically-generated output exported for use by later deployment groups",
Sensitive: true,
})
}
})
}
// OutputNames returns the group-level output names constructed from module ID
// and module-level output name; by construction, all elements are unique
func (dg Group) OutputNames() []string {
outputs := []string{}
for _, mod := range dg.Modules {
for _, output := range mod.Outputs {
outputs = append(outputs, AutomaticOutputName(output.Name, mod.ID))
}
}
return outputs
}
// OutputNamesByGroup returns the outputs from prior groups that match input
// names for this group as a map
func OutputNamesByGroup(g Group, bp Blueprint) (map[GroupName][]string, error) {
refs := g.FindAllIntergroupReferences(bp)
inputs := make([]string, len(refs))
for i, ref := range refs {
inputs[i] = AutomaticOutputName(ref.Name, ref.Module)
}
i := bp.GroupIndex(g.Name)
if i == -1 {
return nil, fmt.Errorf("group %s not found in blueprint", g.Name)
}
res := make(map[GroupName][]string)
for _, pg := range bp.Groups[:i] {
res[pg.Name] = intersection(inputs, pg.OutputNames())
}
return res, nil
}
// return sorted list of elements common to s1 and s2
func intersection(s1 []string, s2 []string) []string {
first := make(map[string]bool)
for _, v := range s1 {
first[v] = true
}
both := map[string]bool{}
for _, v := range s2 {
if first[v] {
both[v] = true
}
}
is := maps.Keys(both)
slices.Sort(is)
return is
}