package evaluator
import (
"fmt"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/types/known/structpb"
)
// analyzer provides facilities for HCL analysis.
type analyzer struct {
finder sourceFinder
resourceNames map[string]bool
collectionNames map[string]bool
}
func newAnalyzer(finder sourceFinder) *analyzer {
return &analyzer{
finder: finder,
resourceNames: make(map[string]bool),
collectionNames: make(map[string]bool),
}
}
func (a *analyzer) addResource(name string, r hcl.Range) hcl.Diagnostics {
if a.resourceNames[name] {
return toErrorDiag("resource defined more than once", name, r)
}
a.resourceNames[name] = true
return nil
}
func (a *analyzer) addCollection(name string, r hcl.Range) hcl.Diagnostics {
if a.collectionNames[name] {
return toErrorDiag("resource collection defined more than once", name, r)
}
a.collectionNames[name] = true
return nil
}
func (a *analyzer) checkReferences(ctx *hcl.EvalContext, tables map[string]DynamicObject, expr hcl.Traversal) hcl.Diagnostics {
var ret hcl.Diagnostics
sr := expr.SourceRange()
expr = normalizeTraversal(expr)
getText := func() string {
return a.finder.sourceCode(sr)
}
switch expr.RootName() {
case reservedReq, reservedSelf:
if len(expr) < 2 {
return nil
}
root := tables[expr.RootName()]
second, ok := expr[1].(hcl.TraverseAttr)
if !ok {
ret = ret.Extend(toErrorDiag("invalid index expression", getText(), sr))
break
}
if _, ok := root[second.Name]; !ok {
ret = ret.Extend(toErrorDiag(fmt.Sprintf("no such attribute %q", second.Name), getText(), sr))
break
}
// get the third step in the traversal if one exists
thirdStep := ""
if len(expr) > 2 {
third, ok := expr[2].(hcl.TraverseAttr)
if ok {
thirdStep = third.Name
}
}
if thirdStep == "" {
break
}
switch {
case expr.RootName() == reservedReq && second.Name == "resource":
if !a.resourceNames[thirdStep] {
ret = ret.Extend(toErrorDiag("invalid resource name reference", thirdStep, sr))
}
case expr.RootName() == reservedReq && second.Name == "resources":
if !a.collectionNames[thirdStep] {
ret = ret.Extend(toErrorDiag("invalid resource collection name reference", thirdStep, sr))
}
case expr.RootName() == reservedSelf && second.Name == "each":
if thirdStep != "key" && thirdStep != "value" {
ret = ret.Extend(toErrorDiag("invalid each reference, must be one of 'key' or 'value'", thirdStep, sr))
}
}
case iteratorName:
if len(expr) < 2 {
return nil
}
second, ok := expr[1].(hcl.TraverseAttr)
if !ok {
ret = ret.Extend(toErrorDiag("invalid index expression", getText(), sr))
break
}
if second.Name != "key" && second.Name != "value" {
ret = ret.Extend(toErrorDiag("invalid each reference, must be one of 'key' or 'value'", second.Name, sr))
break
}
fallthrough // since each is a local variable added on demand, add the local variable ref checks as well
default: // local variable reference
reference := expr.RootName()
if !hasVariable(ctx, reference) {
r := expr[0].SourceRange()
ret = ret.Extend(toErrorDiag("invalid local variable reference", reference, r))
}
}
return ret
}
func (a *analyzer) processLocals(ctx *hcl.EvalContext, content *hcl.BodyContent) (*hcl.EvalContext, map[string]hcl.Expression, hcl.Diagnostics) {
lp := newLocalsProcessor(a.finder)
childCtx, diags := lp.process(ctx, content)
if diags.HasErrors() {
return nil, nil, diags
}
exprs, diags := lp.getLocalExpressions(content)
if diags.HasErrors() {
return nil, nil, diags
}
return childCtx, exprs, diags
}
func (a *analyzer) analyzeContent(ctx *hcl.EvalContext, parent *hcl.Block, content *hcl.BodyContent) hcl.Diagnostics {
// if in a resources block add the expected self vars
if parent.Type == blockResources {
ctx = createSelfChildContext(ctx, DynamicObject{
selfBaseName: cty.StringVal("dummy"),
selfObservedResources: cty.DynamicVal,
selfObservedConnections: cty.DynamicVal,
})
}
if parent.Type == blockResource || parent.Type == blockTemplate {
ctx = createSelfChildContext(ctx, map[string]cty.Value{
selfName: cty.StringVal("dummy"),
selfObservedResource: cty.DynamicVal,
selfObservedConnection: cty.DynamicVal,
})
}
// evaluate locals, checking for bad refs
ctx, localExpressions, diags := a.processLocals(ctx, content)
if diags.HasErrors() {
return diags
}
// now ensure that all expressions including ones in local and attributes refer to
// locals, resources, and collections that exist.
tables := makeTables(ctx)
var ret hcl.Diagnostics
// first locals
for _, expr := range localExpressions {
vars := expr.Variables()
for _, v := range vars {
ret = ret.Extend(a.checkReferences(ctx, tables, v))
}
}
// then attributes
for _, attr := range content.Attributes {
// unlike any other attribute, the name attribute for the `resources` block is special because
// it has access to the iterator.
if attr.Name == "name" && parent.Type == blockResources {
continue
}
vars := attr.Expr.Variables()
for _, v := range vars {
ret = ret.Extend(a.checkReferences(ctx, tables, v))
}
}
// if it is a resources block add the iterator context at this point
if parent.Type == blockResources {
ctx = ctx.NewChild()
ctx.Variables = DynamicObject{
iteratorName: cty.ObjectVal(DynamicObject{
attrKey: cty.DynamicVal,
attrValue: cty.DynamicVal,
}),
}
// check the name attribute if one exists
if nameAttr, ok := content.Attributes[attrName]; ok {
vars := nameAttr.Expr.Variables()
for _, v := range vars {
ret = ret.Extend(a.checkReferences(ctx, tables, v))
}
}
}
// process child blocks
for _, block := range content.Blocks {
if block.Type == blockLocals {
continue
}
childContent, d := block.Body.Content(schemasByBlockType[block.Type])
if d.HasErrors() { // should never happen if structure has already been checked
return d
}
ret = ret.Extend(a.analyzeContent(ctx, block, childContent))
}
return ret
}
func (a *analyzer) analyze(ctx *hcl.EvalContext, content *hcl.BodyContent) hcl.Diagnostics {
return a.analyzeContent(ctx, &hcl.Block{}, content)
}
func (a *analyzer) checkStructure(body hcl.Body, s *hcl.BodySchema) hcl.Diagnostics {
if s == nil {
_, diags := body.JustAttributes()
if diags.HasErrors() {
return diags
}
return nil
}
content, diags := body.Content(s)
if diags.HasErrors() {
return diags
}
for _, block := range content.Blocks {
switch block.Type {
case blockResource:
diags = diags.Extend(a.addResource(block.Labels[0], block.LabelRanges[0]))
case blockResources:
diags = diags.Extend(a.addCollection(block.Labels[0], block.LabelRanges[0]))
}
diags = diags.Extend(a.checkStructure(block.Body, schemasByBlockType[block.Type]))
}
return diags
}
func (e *Evaluator) doAnalyze(files ...File) hcl.Diagnostics {
// parse all files
bodies, diags := e.toBodies(files)
if diags.HasErrors() {
return diags
}
req := &fnv1.RunFunctionRequest{
Observed: &fnv1.State{
Composite: &fnv1.Resource{
Resource: &structpb.Struct{},
ConnectionDetails: map[string][]byte{},
},
Resources: map[string]*fnv1.Resource{},
},
Context: &structpb.Struct{},
ExtraResources: map[string]*fnv1.Resources{},
Credentials: map[string]*fnv1.Credentials{},
}
ctx, err := e.makeVars(req)
if err != nil {
return []*hcl.Diagnostic{{Severity: hcl.DiagError, Summary: "internal error: setup dummy vars", Detail: err.Error()}}
}
a := newAnalyzer(e)
for _, body := range bodies {
diags = diags.Extend(a.checkStructure(body, topLevelSchema()))
}
if diags.HasErrors() {
return diags
}
content, diags := e.makeContent(bodies)
if diags.HasErrors() {
return diags
}
return a.analyze(ctx, content)
}
// Package evaluator implements the HCL processing need to create resource definitions.
package evaluator
import (
"fmt"
"strings"
"github.com/crossplane/crossplane-runtime/pkg/logging"
fn "github.com/crossplane/function-sdk-go"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/types/known/structpb"
)
// set up some type aliases for nicer looking code.
type (
Object = map[string]any
DynamicObject = map[string]cty.Value
)
// keys under req, gotten from the run function request.
const (
reqContext = "context"
reqComposite = "composite"
reqCompositeConnection = "composite_connection"
reqObservedResource = "resource"
reqObservedConnection = "connection"
reqObservedResources = "resources"
reqObservedConnections = "connections"
reqExtraResources = "extra_resources"
)
// supported blocks and attributes.
const (
blockGroup = "group"
blockResource = "resource"
blockResources = "resources"
blockComposite = "composite"
blockContext = "context"
blockLocals = "locals"
blockTemplate = "template"
blockReady = "ready"
attrBody = "body"
attrCondition = "condition"
attrForEach = "for_each"
attrName = "name"
attrKey = "key"
attrValue = "value"
blockLabelStatus = "status"
blockLabelConnection = "connection"
)
const (
reservedReq = "req"
reservedSelf = "self"
reservedArg = "arg"
)
// automatic annotations we will add to resources that are created in a for_each loop.
const (
annotationBaseName = "hcl.fn.crossplane.io/collection-base-name"
annotationIndex = "hcl.fn.crossplane.io/collection-index"
)
// dynamic names set by the evaluator.
const (
selfName = "name"
selfBaseName = "basename"
selfObservedResource = "resource"
selfObservedConnection = "connection"
selfObservedResources = "resources"
selfObservedConnections = "connections"
iteratorName = "each"
)
var reservedWords = map[string]bool{
reservedSelf: true,
reservedReq: true,
reservedArg: true,
iteratorName: true,
}
// DiscardType describes what was discarded by the function.
type DiscardType string
const (
discardTypeResource DiscardType = "resource"
discardTypeResourceList DiscardType = "resources"
discardTypeGroup DiscardType = "group"
discardTypeStatus DiscardType = "composite-status"
discardTypeConnection DiscardType = "composite-connection"
discardTypeReady DiscardType = "resource-ready"
discardTypeContext DiscardType = "context"
)
// DiscardReason describes the reason for the elision.
type DiscardReason string
// discard reasons.
const (
discardReasonUserCondition DiscardReason = "user-condition"
discardReasonIncomplete DiscardReason = "incomplete"
discardReasonBadSecret DiscardReason = "bad-secret"
)
// File is an HCL file to evaluate.
type File struct {
Name string // the name is informational and only used in diagnostic messages
Content string // the content is the HCL content as a byte-array
}
// Options are evaluation options.
type Options struct {
Logger logging.Logger
Debug bool
}
// DiscardItem is an instance of a resource, resource list, group, connection detail or a composite status
// being discarded from the output either based on user conditions or an incomplete definition of the
// object in question.
type DiscardItem struct {
Type DiscardType `json:"type"` // the kind of thing that is discarded
Reason DiscardReason `json:"reason"` // the reason for the discard
Name string `json:"name,omitempty"` // used only for things that are named
SourceRange string `json:"sourceRange,omitempty"` // source range where the discard happened
Context []string `json:"context,omitempty"` // relevant messages with more details
}
func (di DiscardItem) MessageString() string {
base := []string{fmt.Sprintf("%s:discarded %s %s", di.SourceRange, di.Type, di.Name)}
base = append(base, di.Context...)
return strings.Join(base, "\n")
}
// Evaluator evaluates the HCL DSL created for the purposes of producing crossplane resources.
// Evaluators have mutable state and must not be re-used, nor are they safe for concurrent use.
type Evaluator struct {
log logging.Logger // the logger to use
debug bool // whether we are in debug mode
files map[string]*hcl.File // map of HCL files keyed by source filename
existingResourceMap DynamicObject // tracks resource names present in observed resources
existingConnectionMap DynamicObject // tracks observed resource connection details.
collectionResourcesMap DynamicObject // tracks resource names present in observed resource collections
collectionConnectionsMap DynamicObject // tracks observed collection resource connection details.
desiredResources map[string]*structpb.Struct // desired resource bodies
compositeStatuses []Object // status attributes of the composite
compositeConnections []map[string][]byte // composite connection details
contexts []Object // desired context values
ready map[string]int32 // readiness indicator for resource
discards []DiscardItem // list of things discarded from output
}
// New creates an evaluator.
func New(opts Options) (*Evaluator, error) {
if opts.Logger == nil {
var err error
opts.Logger, err = fn.NewLogger(opts.Debug)
if err != nil {
return nil, err
}
}
return &Evaluator{
log: opts.Logger,
debug: opts.Debug,
files: map[string]*hcl.File{},
desiredResources: map[string]*structpb.Struct{},
ready: map[string]int32{},
}, nil
}
// Eval evaluates the supplied HCL files. Ordering of these files are not important for evaluation.
// Internally they are just processed as though all the files were concatenated into a single file.
func (e *Evaluator) Eval(in *fnv1.RunFunctionRequest, files ...File) (*fnv1.RunFunctionResponse, error) {
return e.doEval(in, files...)
}
// Analyze runs static checks on the supplied HCL files that implement a composition.
// It returns errors and warnings in the process.
func (e *Evaluator) Analyze(files ...File) hcl.Diagnostics {
return e.doAnalyze(files...)
}
package evaluator
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/hashicorp/hcl/v2"
ctyjson "github.com/zclconf/go-cty/cty/json"
)
func (e *Evaluator) processComposite(ctx *hcl.EvalContext, block *hcl.Block) hcl.Diagnostics {
content, diags := block.Body.Content(compositeSchema())
if diags.HasErrors() {
return diags
}
ctx, ds := e.processLocals(ctx, content)
diags = diags.Extend(ds)
if ds.HasErrors() {
return ds
}
values := content.Attributes[attrBody].Expr
what := block.Labels[0]
switch what {
case blockLabelStatus:
diags = diags.Extend(e.addStatus(ctx, values))
case blockLabelConnection:
diags = diags.Extend(e.addConnectionDetails(ctx, values))
default:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("invalid composite label: %s", what),
})
}
return diags
}
func (e *Evaluator) addStatus(ctx *hcl.EvalContext, attrs hcl.Expression) hcl.Diagnostics {
values, diags := e.attributesToValueMap(ctx, attrs, discardTypeStatus)
if values == nil {
return diags
}
e.compositeStatuses = append(e.compositeStatuses, values)
return diags
}
func (e *Evaluator) addConnectionDetails(ctx *hcl.EvalContext, attrs hcl.Expression) hcl.Diagnostics {
out, diags := e.attributesToValueMap(ctx, attrs, discardTypeConnection)
if out == nil {
return diags
}
values := map[string][]byte{}
hasDiscards := false
for name, v := range out {
val, ok := v.(string)
if !ok {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("connection key %q was not a string, got %T", name, v),
})
// continue processing to collect additional warnings and errors
continue
}
// make sure that the value can be decoded to bytes
b, err := base64.StdEncoding.DecodeString(val)
if err != nil { // do not print the value, it could be a secret in plain text
e.discard(DiscardItem{
Type: discardTypeConnection,
Reason: discardReasonBadSecret,
Name: name,
SourceRange: attrs.Range().String(),
Context: []string{fmt.Sprintf("connection secret key %q not in base64 format", name)},
})
// do not error out for this.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: fmt.Sprintf("connection secret key %q not in base64 format", name),
})
// mark that we have discards but continue processing to collect additional warnings and errors
hasDiscards = true
} else {
values[name] = b
}
}
if hasDiscards || diags.HasErrors() {
return diags
}
e.compositeConnections = append(e.compositeConnections, values)
return diags
}
func (e *Evaluator) attributesToValueMap(ctx *hcl.EvalContext, expr hcl.Expression, eType DiscardType) (Object, hcl.Diagnostics) {
value, diags := expr.Value(ctx)
if diags.HasErrors() || !value.IsWhollyKnown() {
// discard the object
e.discard(DiscardItem{
Type: eType,
Reason: discardReasonIncomplete,
SourceRange: expr.Range().String(),
Context: e.messagesFromDiags(diags),
})
// remap errors to warnings as we'll handle discarded objects later
return nil, mapDiagnosticSeverity(diags, hcl.DiagError, hcl.DiagWarning)
}
b, err := ctyjson.Marshal(value, value.Type())
if err != nil {
return nil, diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("error marshaling cty value: %s", err.Error()),
Subject: ptr(expr.Range()),
})
}
var ret Object
err = json.Unmarshal(b, &ret)
if err != nil {
return nil, diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("error unmarshaling cty value: %s", err.Error()),
Subject: ptr(expr.Range()),
})
}
return ret, nil
}
package evaluator
import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
)
func (e *Evaluator) processContext(ctx *hcl.EvalContext, block *hcl.Block) hcl.Diagnostics {
content, diags := block.Body.Content(contextSchema())
if diags.HasErrors() {
return diags
}
ctx, ds := e.processLocals(ctx, content)
diags = diags.Extend(ds)
if ds.HasErrors() {
return ds
}
ex := content.Attributes[attrKey].Expr
key, ds := ex.Value(ctx)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "unable to evaluate context key",
Detail: ds.Error(),
Subject: ptr(ex.Range()),
})
}
if !key.IsWhollyKnown() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "context key is unknown",
Subject: ptr(ex.Range()),
})
}
if key.Type() != cty.String {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("context key was not a string, got %s", key.Type().FriendlyName()),
Subject: ptr(ex.Range()),
})
}
keyString := key.AsString()
ex = content.Attributes[attrValue].Expr
val, ds := ex.Value(ctx)
if diags.HasErrors() || !val.IsWhollyKnown() {
e.discard(DiscardItem{
Type: discardTypeContext,
Reason: discardReasonIncomplete,
SourceRange: ex.Range().String(),
Context: e.messagesFromDiags(diags),
})
// map unknown context value errors to warnings as we'll handle them later
return diags.Extend(mapDiagnosticSeverity(ds, hcl.DiagError, hcl.DiagWarning))
}
diags = diags.Extend(ds)
goVal, err := valueToInterface(val)
if err != nil {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "cannot convert value to interface",
Detail: err.Error(),
Subject: ptr(ex.Range()),
})
}
e.contexts = append(e.contexts, Object{keyString: goVal})
return diags
}
package evaluator
import (
"fmt"
"strings"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/pkg/errors"
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/types/known/structpb"
)
const (
maxDiscardsToDisplay = 3
)
func (e *Evaluator) doEval(in *fnv1.RunFunctionRequest, files ...File) (*fnv1.RunFunctionResponse, error) {
// note: when returning something using diags from this function, we sort by severity first
// this is in order to have at least one error show up in formatted errors.
// parse all files
mergedBody, diags := e.toContent(files)
if diags.HasErrors() {
return nil, sortDiagsBySeverity(diags)
}
// make vars in cty format and set up the initial eval context
ctx, err := e.makeVars(in)
if err != nil {
return nil, sortDiagsBySeverity(diags.Append(err2Diag(err)))
}
// process top-level blocks as a group
ds := e.processGroup(ctx, mergedBody)
diags = diags.Extend(ds)
if ds.HasErrors() {
return nil, sortDiagsBySeverity(diags)
}
// create the response from internal state.
res, err := e.toResponse(diags)
if err != nil {
return nil, err
}
return res, nil
}
func (e *Evaluator) toBodies(files []File) ([]hcl.Body, hcl.Diagnostics) {
parser := hclparse.NewParser()
var bodies []hcl.Body
for _, file := range files {
hclFile, diags := parser.ParseHCL([]byte(file.Content), file.Name)
if diags.HasErrors() {
return nil, diags
}
e.files[file.Name] = hclFile
b, ok := hclFile.Body.(*hclsyntax.Body)
if !ok {
panic(fmt.Errorf("internal error: unable to convert HCL body to desired type"))
}
bodies = append(bodies, b)
}
return bodies, nil
}
func (e *Evaluator) makeContent(bodies []hcl.Body) (*hcl.BodyContent, hcl.Diagnostics) {
var d hcl.Diagnostics
ret := &hcl.BodyContent{}
for _, body := range bodies {
content, diags := body.Content(topLevelSchema())
d = d.Extend(diags)
if content != nil {
ret.Blocks = append(ret.Blocks, content.Blocks...)
}
}
if d.HasErrors() {
return nil, d
}
return ret, nil
}
func (e *Evaluator) toContent(files []File) (*hcl.BodyContent, hcl.Diagnostics) {
bodies, diags := e.toBodies(files)
if diags.HasErrors() {
return nil, diags
}
return e.makeContent(bodies)
}
// evaluateCondition looks for an optional condition attribute in the supplied content and return false if the content
// is to be skipped.
func (e *Evaluator) evaluateCondition(ctx *hcl.EvalContext, content *hcl.BodyContent, et DiscardType, name string) (bool, hcl.Diagnostics) {
if condAttr, exists := content.Attributes[attrCondition]; exists {
val, diags := condAttr.Expr.Value(ctx)
if diags.HasErrors() {
return false, diags
}
if val.Type() != cty.Bool {
return false, diags.Append(err2Diag(fmt.Errorf("got type %s, expected %s", val.Type(), cty.Bool)))
}
e.discard(DiscardItem{
Type: et,
Reason: discardReasonUserCondition,
Name: name,
SourceRange: condAttr.Range.String(),
})
return val.True(), diags
}
return true, nil
}
// toResponse creates a RunFunctionResponse from internal state.
func (e *Evaluator) toResponse(diags hcl.Diagnostics) (*fnv1.RunFunctionResponse, error) {
ret := fnv1.RunFunctionResponse{}
if ret.Desired == nil {
ret.Desired = &fnv1.State{}
}
if ret.Desired.Resources == nil {
ret.Desired.Resources = map[string]*fnv1.Resource{}
}
for name, res := range e.desiredResources {
ret.Desired.Resources[name] = &fnv1.Resource{Resource: res}
}
ensureDesiredComposite := func() {
if ret.Desired.Composite == nil {
ret.Desired.Composite = &fnv1.Resource{}
}
}
if len(e.compositeStatuses) > 0 {
st, err := unify(e.compositeStatuses...)
if err != nil {
return nil, errors.Wrap(err, "unify composite status")
}
obj := Object{
"status": st,
}
s, err := structpb.NewStruct(obj)
if err != nil {
return nil, fmt.Errorf("unexpected error converting composite status: %v", err)
}
ensureDesiredComposite()
ret.Desired.Composite.Resource = s
}
if len(e.compositeConnections) > 0 {
ensureDesiredComposite()
u, err := unifyBytes(e.compositeConnections...)
if err != nil {
return nil, errors.Wrap(err, "unify composite connection")
}
ret.Desired.Composite.ConnectionDetails = u
}
if len(e.contexts) > 0 {
ctx, err := unify(e.contexts...)
if err != nil {
return nil, errors.Wrap(err, "unify context")
}
s, err := structpb.NewStruct(ctx)
if err != nil {
return nil, fmt.Errorf("unexpected error converting context: %v", err)
}
ret.Context = s
}
for name, val := range e.ready {
desired := ret.Desired.Resources[name]
if desired == nil {
panic(fmt.Sprintf("internal error: no desired resource found for %s when readiness set", name))
}
desired.Ready = fnv1.Ready(val)
}
tg := fnv1.Target_TARGET_COMPOSITE
var discarded []string
msg := ""
for _, di := range e.discards {
if di.Reason == discardReasonUserCondition {
continue
}
resultReason := string(di.Reason)
r := &fnv1.Result{
Severity: fnv1.Severity_SEVERITY_WARNING,
Message: di.MessageString(),
Target: &tg,
Reason: &resultReason,
}
ret.Results = append(ret.Results, r)
if len(discarded) < maxDiscardsToDisplay {
discarded = append(discarded, fmt.Sprintf("%s %s", di.Type, di.Name))
}
}
if len(discarded) > 0 {
msg = strings.Join(discarded, ", ")
if len(ret.Results) > maxDiscardsToDisplay {
msg += fmt.Sprintf(" and %d more items incomplete", len(ret.Results)-maxDiscardsToDisplay)
} else {
msg += " incomplete"
}
} else {
msg = "all items complete"
}
c := fnv1.Status_STATUS_CONDITION_TRUE
resultReason := "AllItemsProcessed"
if len(ret.Results) > 0 {
resultReason = "IncompleteItemsPresent"
c = fnv1.Status_STATUS_CONDITION_FALSE
}
cond := fnv1.Condition{
Type: "FullyResolved",
Target: &tg,
Status: c,
Reason: resultReason,
Message: &msg,
}
ret.Conditions = append(ret.Conditions, &cond)
// Add diagnostics info
e.addDiagnosticsInfo(&ret, diags)
return &ret, nil
}
// addDiagnosticsInfo adds diagnostics information to the response.
func (e *Evaluator) addDiagnosticsInfo(ret *fnv1.RunFunctionResponse, diags hcl.Diagnostics) {
target := ptr(fnv1.Target_TARGET_COMPOSITE)
resultReason := ptr("HclDiagnostics")
condition := &fnv1.Condition{
Type: "HclDiagnostics",
Target: target,
Status: fnv1.Status_STATUS_CONDITION_TRUE,
Reason: "Eval",
}
summaries := make([]string, 0, len(diags))
for _, diag := range diags {
if diag.Severity == hcl.DiagWarning {
summaries = append(summaries, fmt.Sprintf("%s: %s", diag.Subject, diag.Summary))
condition.Status = fnv1.Status_STATUS_CONDITION_FALSE
}
}
if len(summaries) > 0 {
r := &fnv1.Result{
Severity: fnv1.Severity_SEVERITY_WARNING,
Message: fmt.Sprintf("warnings: [%s]", strings.Join(summaries, "; ")),
Target: target,
Reason: resultReason,
}
ret.Results = append(ret.Results, r)
condition.Message = ptr(fmt.Sprintf("hcl.Diagnostics contains %d warnings; %s", len(summaries), strings.Join(summaries, "; ")))
} else {
r := &fnv1.Result{
Severity: fnv1.Severity_SEVERITY_NORMAL,
Message: "no warnings",
Target: target,
Reason: resultReason,
}
ret.Results = append(ret.Results, r)
condition.Message = ptr("hcl.Diagnostics contains no warnings")
}
ret.Conditions = append(ret.Conditions, condition)
}
// discard adds a discard item to the evaluator's list.
func (e *Evaluator) discard(el DiscardItem) {
e.discards = append(e.discards, el)
}
// getObservedResource returns the resource body of the observed
// resource with the supplied name or any empty object.
func (e *Evaluator) getObservedResource(name string) cty.Value {
return e.existingResourceMap[name]
}
// getObservedConnection returns the connection details of the observed
// resource with the supplied name or any empty object.
func (e *Evaluator) getObservedConnection(name string) cty.Value {
return e.existingConnectionMap[name]
}
// getObservedCollectionResources returns a list of resources under the
// resource collection with the supplied name, or an empty list.
func (e *Evaluator) getObservedCollectionResources(baseName string) cty.Value {
return e.collectionResourcesMap[baseName]
}
// getObservedCollectionConnections returns a list of connection details under the
// resource collection with the supplied name, or an empty list.
func (e *Evaluator) getObservedCollectionConnections(baseName string) cty.Value {
return e.collectionConnectionsMap[baseName]
}
// sourceCode returns the source code associated with the supplied range
// with best-effort processing. Do not rely on this for anything other than
// error messages.
func (e *Evaluator) sourceCode(r hcl.Range) string {
ret := "[unknown source]"
f := e.files[r.Filename]
if f == nil {
return ret
}
if r.End.Byte > len(f.Bytes) {
return ret
}
return string(f.Bytes[r.Start.Byte:r.End.Byte])
}
// messagesFromDiags extracts useful messages from the supplied diagnostics object.
func (e *Evaluator) messagesFromDiags(d hcl.Diagnostics) []string {
var ret []string
for _, diag := range d {
var parts []string
var r *hcl.Range
if diag.Expression != nil {
r2 := diag.Expression.Range()
r = &r2
} else if diag.Context != nil {
r = diag.Context
}
if r != nil {
parts = append(parts, e.sourceCode(*r))
}
parts = append(parts, diag.Error())
ret = append(ret, strings.Join(parts, ", "))
}
return ret
}
package evaluator
import (
"fmt"
"strings"
"github.com/hashicorp/hcl/v2"
)
// contains code to process locals.
func (e *Evaluator) processLocals(ctx *hcl.EvalContext, content *hcl.BodyContent) (*hcl.EvalContext, hcl.Diagnostics) {
lp := newLocalsProcessor(e)
return lp.process(ctx, content)
}
// sourceFinder returns the source code for a given range.
type sourceFinder interface {
sourceCode(hcl.Range) string
}
// localsProcessor processes local declarations in a block. This is the workhorse of the evaluator.
// It computes dependencies across local variables and evaluates them in dependency order checking for circularity.
// Given an eval context, it checks whether there is any expression that relies on an unknown local or on
// other unknown values in the eval context and produces an error if that is the case.
// At the end of processing, it returns a child context with the locals having computed values.
// Note that "computed" does not mean "complete" - locals may have incomplete values if they refer to resource
// properties that are not yet known.
type localsProcessor struct {
finder sourceFinder
}
// newLocalsProcessor returns a locals processor.
func newLocalsProcessor(finder sourceFinder) *localsProcessor {
return &localsProcessor{
finder: finder,
}
}
// localInfo tracks the dependencies in an HCL expression.
type localInfo struct {
expr hcl.Expression
deps []string
}
// process processes all local blocks found in the supplied body contents as a single unit and returns a child
// context which has values for all locals.
func (l *localsProcessor) process(ctx *hcl.EvalContext, content *hcl.BodyContent) (*hcl.EvalContext, hcl.Diagnostics) {
var diags hcl.Diagnostics
var attrsList []hcl.Attributes
for _, block := range content.Blocks {
if block.Type == blockLocals {
attrs, ds := block.Body.JustAttributes()
diags = diags.Extend(ds)
if ds.HasErrors() {
return nil, diags
}
attrsList = append(attrsList, attrs)
}
}
childCtx, ds := l.processLocals(ctx, attrsList)
return childCtx, diags.Extend(ds)
}
func (l *localsProcessor) getLocalExpressions(content *hcl.BodyContent) (map[string]hcl.Expression, hcl.Diagnostics) {
var attrsList []hcl.Attributes
for _, block := range content.Blocks {
if block.Type == blockLocals {
attrs, diags := block.Body.JustAttributes()
if diags.HasErrors() {
return nil, diags
}
attrsList = append(attrsList, attrs)
}
}
ret := map[string]hcl.Expression{}
for _, attrs := range attrsList {
for _, attr := range attrs {
ret[attr.Name] = attr.Expr
}
}
return ret, nil
}
func toErrorDiag(summary string, details string, r hcl.Range) hcl.Diagnostics {
ret := &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: summary,
Detail: details,
Subject: &r,
Context: &r,
}
return []*hcl.Diagnostic{ret}
}
// processLocals returns a child context that has the values for all supplied locals evaluated in dependency order.
func (l *localsProcessor) processLocals(ctx *hcl.EvalContext, attrsList []hcl.Attributes) (*hcl.EvalContext, hcl.Diagnostics) {
locals := map[string]*localInfo{}
for _, attrs := range attrsList {
for name, attr := range attrs {
if _, ok := locals[name]; ok {
return nil, toErrorDiag(fmt.Sprintf("local %q: duplicate local declaration", name), "", attr.Range)
}
if reservedWords[name] {
return nil, toErrorDiag(fmt.Sprintf("local %q: name is reserved and cannot be used", name), "", attr.Range)
}
if hasVariable(ctx, name) {
return nil, toErrorDiag("attempt to shadow local", name, attr.Range)
}
locals[name] = &localInfo{expr: attr.Expr}
}
}
// if no locals defined, there is nothing to do.
if len(locals) == 0 {
return ctx, nil
}
diags := l.computeDeps(ctx, locals)
if diags.HasErrors() {
return nil, diags
}
childCtx := ctx.NewChild()
childCtx.Variables = DynamicObject{}
return childCtx, diags.Extend(l.eval(childCtx, locals))
}
// computeDeps computes the dependency maps between locals, also checking for typos in expressions in the process.
// Note that it does *not* check for circularity at this stage.
func (l *localsProcessor) computeDeps(ctx *hcl.EvalContext, locals map[string]*localInfo) hcl.Diagnostics {
for name, info := range locals {
deps := info.expr.Variables()
for _, dep := range deps {
dep = normalizeTraversal(dep)
getText := func() string {
return l.finder.sourceCode(dep.SourceRange())
}
// all references must be of the form `req.<something>`, `self.<something>` or a local variable
// that could have any name.
switch dep.RootName() {
case reservedReq, reservedSelf, reservedArg:
// no checks done here. analyzer will add some checks though.
default:
reference := dep.RootName()
if _, ok := locals[reference]; ok {
locals[name].deps = append(locals[name].deps, reference)
} else if !hasVariable(ctx, reference) {
return toErrorDiag("reference to non-existent local", getText(), dep.SourceRange())
}
}
}
}
return nil
}
type evalPath struct {
path []string
}
func (e *evalPath) push(name string) error {
for index, v := range e.path {
if v == name {
//nolint:gocritic
subPath := append(e.path[index:], name)
return fmt.Errorf("cycle found: %s", strings.Join(subPath, " \u2192 "))
}
}
e.path = append(e.path, name)
return nil
}
func (e *evalPath) pop() {
e.path = e.path[:len(e.path)-1]
}
type localContext struct {
ctx *hcl.EvalContext
locals map[string]*localInfo
seen *evalPath
remaining map[string]bool
}
// eval evaluates all locals in dependency order.
func (l *localsProcessor) eval(ctx *hcl.EvalContext, locals map[string]*localInfo) hcl.Diagnostics {
var diags hcl.Diagnostics
remaining := map[string]bool{}
for name := range locals {
remaining[name] = true
}
for name := range locals {
diags = diags.Extend(l.evalLocal(&localContext{
ctx: ctx,
locals: locals,
seen: &evalPath{},
remaining: remaining,
}, name))
}
return diags
}
// evalLocal evals a single local, ensuring that its dependencies are evaluated first.
func (l *localsProcessor) evalLocal(c *localContext, name string) hcl.Diagnostics {
var diags hcl.Diagnostics
if !c.remaining[name] { // already processed
return nil
}
// check cycles
if err := c.seen.push(name); err != nil {
return toErrorDiag(err.Error(), "", c.locals[name].expr.Range())
}
defer func() {
c.seen.pop()
delete(c.remaining, name)
}()
// ensure dependencies are evaluated first
info := c.locals[name]
for _, dep := range info.deps {
if c.remaining[dep] {
diags = diags.Extend(l.evalLocal(c, dep))
}
}
if diags.HasErrors() {
return diags
}
// evaluate local
// val will be an unknown value if it cannot be eval-ed
// we ignore errors due to incomplete values.
val, ds := info.expr.Value(c.ctx)
// rewrite the severity of errors due to incomplete values to warnings as we'll handle them later
diags = diags.Extend(mapDiagnosticSeverity(ds, hcl.DiagError, hcl.DiagWarning))
// having evaluated it, update the context with the new kv.
c.ctx.Variables[name] = val
return diags
}
package evaluator
import (
"fmt"
"sort"
"strings"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
)
// processGroup processes all blocks at the top-level or at the level of a single group.
func (e *Evaluator) processGroup(ctx *hcl.EvalContext, content *hcl.BodyContent) hcl.Diagnostics {
ctx, diags := e.processLocals(ctx, content)
if diags.HasErrors() {
return diags
}
cond, ds := e.evaluateCondition(ctx, content, discardTypeGroup, "")
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "unable to evaluate condition",
})
}
if !cond {
return nil
}
for _, b := range content.Blocks {
var curDiags hcl.Diagnostics
switch b.Type {
case blockGroup:
content, ds := b.Body.Content(groupSchema())
if ds.HasErrors() {
return diags.Extend(ds)
}
curDiags = ds.Extend(e.processGroup(ctx, content))
case blockResource:
curDiags = e.processResource(ctx, b)
case blockResources:
curDiags = e.processResources(ctx, b)
case blockContext:
curDiags = e.processContext(ctx, b)
case blockComposite:
curDiags = e.processComposite(ctx, b)
// will process in one shot after this
case blockLocals:
// already processed
default:
curDiags = curDiags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unsupported block type %s", b.Type),
Subject: ptr(b.DefRange),
})
}
diags = diags.Extend(curDiags)
if curDiags.HasErrors() {
return diags
}
}
return diags
}
func (e *Evaluator) processResource(ctx *hcl.EvalContext, block *hcl.Block) hcl.Diagnostics {
resourceName := block.Labels[0]
content, diags := block.Body.Content(resourceSchema())
if diags.HasErrors() {
return diags
}
// add the resource to our stash
ds := e.addResource(ctx, resourceName, content, nil)
return diags.Extend(ds)
}
func (e *Evaluator) processResources(ctx *hcl.EvalContext, block *hcl.Block) hcl.Diagnostics {
baseName := block.Labels[0]
// parse with strict schema
content, diags := block.Body.Content(resourcesSchema())
if diags.HasErrors() {
return diags
}
var templateBlock *hcl.Block
for _, b := range content.Blocks {
if b.Type == blockTemplate {
if templateBlock != nil {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("multiple template blocks for resource collection %s", baseName),
Subject: ptr(b.DefRange),
})
}
templateBlock = b
}
}
if templateBlock == nil {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("no template block for resource collection %s", baseName),
Subject: ptr(block.DefRange),
})
}
templateContent, ds := templateBlock.Body.Content(templateSchema())
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags
}
var err error
// create a context for the resources block to include the self.basename set to base name
ctx = createSelfChildContext(ctx, DynamicObject{
selfBaseName: cty.StringVal(baseName),
selfObservedResources: e.getObservedCollectionResources(baseName),
selfObservedConnections: e.getObservedCollectionConnections(baseName),
})
// add a locals child context
ctx, ds = e.processLocals(ctx, content)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags
}
cond, ds := e.evaluateCondition(ctx, content, discardTypeResourceList, baseName)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to evaluate condition for resource collection %s", baseName),
Subject: ptr(block.DefRange),
})
}
if !cond {
return diags
}
// get the iterations from the for_each expression
forEachExpr := content.Attributes[attrForEach].Expr
forEachVal, ds := forEachExpr.Value(ctx)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to evaluate for_each for resource collection %s", baseName),
Subject: ptr(forEachExpr.Range()),
})
}
iters, err := extractIterations(forEachVal)
if err != nil {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to extract iterations for resource collection %s", baseName),
Subject: ptr(forEachExpr.Range()),
})
}
// get the name as an expression.
var nameExpr hcl.Expression
if npAttr, ok := content.Attributes[attrName]; ok {
nameExpr = npAttr.Expr
} else {
nameExpr, ds = hclsyntax.ParseTemplate([]byte(`${self.basename}-${each.key}`), "default-name.hcl", hcl.Pos{Line: 1, Column: 1})
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to evaluate default name expression for resource collection %s", baseName),
Subject: ptr(nameExpr.Range()),
})
}
}
// actually process resources
for i, iter := range iters {
iterContext := ctx.NewChild()
iterContext.Variables = DynamicObject{
iteratorName: cty.ObjectVal(DynamicObject{
attrKey: iter.key,
attrValue: iter.value,
}),
}
resourceExpr, ds := nameExpr.Value(iterContext)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to evaluate name expression for resource collection %s", baseName),
Subject: ptr(nameExpr.Range()),
})
}
if resourceExpr.Type() != cty.String {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("name produced from evaluating the name expression for collection %s was not a string", baseName),
Subject: ptr(nameExpr.Range()),
})
}
name := resourceExpr.AsString()
annotations := map[string]string{
annotationBaseName: baseName,
annotationIndex: fmt.Sprintf("s%06d", i),
}
ds = e.addResource(iterContext, name, templateContent, annotations)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags
}
}
// process any composite and context blocks
for _, b := range content.Blocks {
var currentDiags hcl.Diagnostics
if b.Type == blockComposite {
currentDiags = e.processComposite(ctx, b)
}
if b.Type == blockContext {
currentDiags = e.processContext(ctx, b)
}
diags = diags.Extend(currentDiags)
if currentDiags.HasErrors() {
return diags
}
}
return diags
}
func (e *Evaluator) addResource(ctx *hcl.EvalContext, resourceName string, content *hcl.BodyContent, annotations map[string]string) hcl.Diagnostics {
// dup check
if e.desiredResources[resourceName] != nil {
return hcl.Diagnostics{&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("duplicate resource %q", resourceName),
}}
}
// create resource-specific context with magic variables
ctx = createSelfChildContext(ctx, DynamicObject{
selfName: cty.StringVal(resourceName),
selfObservedResource: e.getObservedResource(resourceName),
selfObservedConnection: e.getObservedConnection(resourceName),
})
ctx, diags := e.processLocals(ctx, content)
if diags.HasErrors() {
return diags
}
body, ok := content.Attributes[attrBody]
if !ok {
return hcl.Diagnostics{&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("internal error: no body in content block for %q", resourceName),
}}
}
cond, ds := e.evaluateCondition(ctx, content, discardTypeResource, resourceName)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to evaluate condition for resource %s", resourceName),
})
}
if !cond {
return nil
}
// process the body
out, ds := body.Expr.Value(ctx)
// if we have errors in processing or couldn't fully eval the body, make it a hard error if there is already an observed
// resource with this name. This implies that the user has made a bad change to one of the
// expressions in the body, and we should halt instead of silently removing the resource
// from the desired output, thereby having crossplane delete it.
if ds.HasErrors() || !out.IsWhollyKnown() {
context := e.messagesFromDiags(ds)
var incompleteVars []string
for _, t := range body.Expr.Variables() {
v, tdiag := t.TraverseAbs(ctx)
ds = append(ds, tdiag...)
sourceName := e.sourceCode(t.SourceRange())
// try to find the path to the actual unknown values to assist with debugging
unknownPaths, err := findUnknownPaths(v)
if err != nil {
// unexpected error while finding unknown paths, add to context instead of failing
ds = append(ds, &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Subject: ptr(t.SourceRange()),
Summary: fmt.Sprintf("unexpected error while finding unknown paths for %s: %s", resourceName, err),
})
}
for _, path := range unknownPaths {
incompleteVars = append(incompleteVars, sourceName+path)
}
// if we didn't find any unknown paths, add the source name only
if len(unknownPaths) == 0 && !v.IsWhollyKnown() {
incompleteVars = append(incompleteVars, sourceName)
}
}
unknown := strings.Join(incompleteVars, ", ")
if _, have := e.existingResourceMap[resourceName]; have {
return diags.Extend(ds).Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Subject: ptr(body.Expr.Range()),
Summary: fmt.Sprintf("existing resource %s could not be evaluated, abort (unknown values: %s)", resourceName, unknown),
})
}
e.discard(DiscardItem{
Type: discardTypeResource,
Reason: discardReasonIncomplete,
Name: resourceName,
SourceRange: body.Expr.Range().String(),
Context: append(context, fmt.Sprintf("unknown values: %s", unknown)),
})
// map unknown resource value errors to warnings as we'll handle them later
return diags.Extend(mapDiagnosticSeverity(ds, hcl.DiagError, hcl.DiagWarning))
}
diags = diags.Extend(ds)
// convert body to a protobuf struct and add to desired state
bodyStruct, err := valueToStructWithAnnotations(out, annotations)
if err != nil {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("unable to convert resource body to struct: %s", resourceName),
Subject: ptr(body.Expr.Range()),
})
}
e.desiredResources[resourceName] = bodyStruct
for _, b := range content.Blocks {
var currentDiags hcl.Diagnostics
if b.Type == blockComposite {
currentDiags = e.processComposite(ctx, b)
}
if b.Type == blockReady {
currentDiags = e.processReady(ctx, resourceName, b)
}
if b.Type == blockContext {
currentDiags = e.processContext(ctx, b)
}
diags = diags.Extend(currentDiags)
if currentDiags.HasErrors() {
return diags
}
}
return diags
}
var validReadyValues string
func init() {
var keys []string
for k := range fnv1.Ready_value {
keys = append(keys, k)
}
sort.Strings(keys)
validReadyValues = strings.Join(keys, ", ")
}
func (e *Evaluator) processReady(ctx *hcl.EvalContext, resourceName string, block *hcl.Block) hcl.Diagnostics {
content, diags := block.Body.Content(readySchema())
if diags.HasErrors() {
return diags
}
ctx, ds := e.processLocals(ctx, content)
diags = diags.Extend(ds)
if ds.HasErrors() {
return diags
}
attr, ok := content.Attributes[attrValue]
if !ok {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("attribute %q not found in ready block for %s", attrValue, resourceName),
Subject: ptr(block.DefRange),
})
}
value, ds := attr.Expr.Value(ctx)
if ds.HasErrors() || !value.IsWhollyKnown() {
e.discard(DiscardItem{
Type: discardTypeReady,
Reason: discardReasonIncomplete,
Name: resourceName,
SourceRange: attr.Expr.Range().String(),
Context: e.messagesFromDiags(diags),
})
// map unknown ready value errors to warnings as we'll handle them later
return diags.Extend(mapDiagnosticSeverity(ds, hcl.DiagError, hcl.DiagWarning))
}
diags = diags.Extend(ds)
if value.Type() != cty.String {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("attribute %q not a string in ready block for %s", attrValue, resourceName),
Subject: ptr(attr.Expr.Range()),
})
}
s := value.AsString()
v, ok := fnv1.Ready_value[s]
if !ok {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("attribute %q does not have a valid value in ready block for %s, must be one of %q", attrValue, resourceName, validReadyValues),
Subject: ptr(attr.Expr.Range()),
})
}
e.ready[resourceName] = v
return diags
}
package evaluator
import "github.com/hashicorp/hcl/v2"
// file that declares schemas for various blocks
var (
// base blocks applicable to top-level as well as groups.
baseGroupBlocks = []hcl.BlockHeaderSchema{
{Type: blockLocals},
{Type: blockGroup},
{Type: blockResource, LabelNames: []string{"name"}},
{Type: blockResources, LabelNames: []string{"baseName"}},
{Type: blockComposite, LabelNames: []string{"object"}},
{Type: blockContext},
}
// applicable to resource and template blocks.
resourceBlocks = []hcl.BlockHeaderSchema{
{Type: blockLocals},
{Type: blockReady},
{Type: blockComposite, LabelNames: []string{"object"}},
{Type: blockContext},
}
)
var schemasByBlockType = map[string]*hcl.BodySchema{
blockGroup: groupSchema(),
blockResource: resourceSchema(),
blockResources: resourcesSchema(),
blockComposite: compositeSchema(),
blockContext: contextSchema(),
blockTemplate: templateSchema(),
blockReady: readySchema(),
}
func topLevelSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Blocks: baseGroupBlocks,
}
}
func groupSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Blocks: baseGroupBlocks,
Attributes: []hcl.AttributeSchema{
{Name: attrCondition},
},
}
}
func resourcesSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: attrCondition},
{Name: attrForEach, Required: true},
{Name: attrName},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: blockLocals},
{Type: blockComposite, LabelNames: []string{"object"}},
{Type: blockTemplate},
{Type: blockContext},
},
}
}
func templateSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: attrBody, Required: true},
},
Blocks: resourceBlocks,
}
}
func resourceSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: attrBody, Required: true},
{Name: attrCondition},
},
Blocks: resourceBlocks,
}
}
func contextSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: blockLocals},
},
Attributes: []hcl.AttributeSchema{
{Name: attrKey, Required: true},
{Name: attrValue, Required: true},
},
}
}
func readySchema() *hcl.BodySchema {
return &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: blockLocals},
},
Attributes: []hcl.AttributeSchema{
{Name: attrValue, Required: true},
},
}
}
func compositeSchema() *hcl.BodySchema {
return &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: blockLocals},
},
Attributes: []hcl.AttributeSchema{
{Name: attrBody, Required: true},
},
}
}
package evaluator
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/pkg/errors"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
)
// reIdent is a regular expression that can test for HCL identifiers that are allowed to contain dashes.
var reIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`)
// isIdentifier returns true if the supplied string can be interpreted as an HCL identifier.
func isIdentifier(s string) bool {
return reIdent.MatchString(s)
}
// normalizeTraversal normalizes an index traversal to an attribute traversal for known cases.
// (i.e. x["foo"] is effectively turned to x.foo).
func normalizeTraversal(t hcl.Traversal) hcl.Traversal {
var ret hcl.Traversal
loop:
for _, item := range t {
switch item := item.(type) {
case hcl.TraverseRoot:
ret = append(ret, item)
case hcl.TraverseAttr:
ret = append(ret, item)
case hcl.TraverseIndex:
k := item.Key
if k.Type() == cty.String && isIdentifier(k.AsString()) {
ret = append(ret, hcl.TraverseAttr{
Name: k.AsString(),
SrcRange: item.SrcRange,
})
continue loop
}
ret = append(ret, item)
default:
panic(fmt.Errorf("unexpected traversal type: %T", item))
}
}
return ret
}
// hasVariable returns true if the supplied name is defined in the current or any ancestor context.
func hasVariable(ctx *hcl.EvalContext, name string) bool {
c := ctx
for c != nil {
if _, ok := c.Variables[name]; ok {
return true
}
c = c.Parent()
}
return false
}
// makeTables returns a map of top-level keys to their corresponding objects.
func makeTables(ctx *hcl.EvalContext) map[string]DynamicObject {
return map[string]DynamicObject{
reservedReq: extractSymbolTable(ctx, reservedReq),
reservedSelf: extractSymbolTable(ctx, reservedSelf),
reservedArg: extractSymbolTable(ctx, reservedArg),
}
}
// extractSymbolTable returns a map of values keyed by symbols under a specific namespace (e.g. `self` or `req`)
// It expects the top level entry to be an object. It will panic if this is not the case.
func extractSymbolTable(ctx *hcl.EvalContext, namespace string) DynamicObject {
for ctx != nil {
symbols, ok := ctx.Variables[namespace]
if ok {
return symbols.AsValueMap()
}
ctx = ctx.Parent()
}
return DynamicObject{}
}
// createSelfChildContext creates a `self` var in the supplied context using the `self` var defined
// in the nearest parent context and augmenting it with the additional values passed.
func createSelfChildContext(ctx *hcl.EvalContext, vars DynamicObject) *hcl.EvalContext {
table := extractSymbolTable(ctx, reservedSelf)
for k, v := range vars {
table[k] = v
}
child := ctx.NewChild()
child.Variables = DynamicObject{
reservedSelf: cty.ObjectVal(table),
}
return child
}
// valueToInterface returns the supplied dynamic value as a Go type.
func valueToInterface(val cty.Value) (any, error) {
jsonBytes, err := ctyjson.Marshal(val, val.Type())
if err != nil {
return nil, err
}
var result any
if err = json.Unmarshal(jsonBytes, &result); err != nil {
return nil, err
}
return result, nil
}
// valueToStruct returns the supplied value as a protobuf struct.
func valueToStruct(val cty.Value) (*structpb.Struct, error) {
jsonBytes, err := ctyjson.Marshal(val, val.Type())
if err != nil {
return nil, err
}
var result structpb.Struct
if err := protojson.Unmarshal(jsonBytes, &result); err != nil {
return nil, err
}
return &result, nil
}
// valueToStructWithAnnotations returns the supplied dynamic value as a protobuf struct after
// injecting the supplied annotations into it.
func valueToStructWithAnnotations(val cty.Value, a map[string]string) (*structpb.Struct, error) {
if len(a) == 0 {
return valueToStruct(val)
}
jsonBytes, err := ctyjson.Marshal(val, val.Type())
if err != nil {
return nil, errors.Wrap(err, "marshal cty to json")
}
var result map[string]any
if err = json.Unmarshal(jsonBytes, &result); err != nil {
return nil, errors.Wrap(err, "unmarshal cty to json")
}
meta, ok := result["metadata"]
if !ok {
meta = map[string]any{}
result["metadata"] = meta
}
metaObj, ok := meta.(map[string]any)
if !ok {
return nil, fmt.Errorf("expected metadata to be a map[string]any, got %T", meta)
}
annotations, ok := metaObj["annotations"]
if !ok {
annotations = map[string]any{}
metaObj["annotations"] = annotations
}
annotationsObj, ok := annotations.(map[string]any)
if !ok {
return nil, fmt.Errorf("expected annotations to be a map[string]any, got %T", meta)
}
for k, v := range a {
annotationsObj[k] = v
}
ret, err := structpb.NewStruct(result)
if err != nil {
return nil, errors.Wrapf(err, "convert result to struct")
}
return ret, nil
}
// iteration stores the key and value for a specific for_each iteration.
type iteration struct {
key cty.Value
value cty.Value
}
// extractIterations returns a list of iterations for the supplied value which must be a collection of some sort.
// For sets, both key and value are set to the set element.
func extractIterations(forEachValue cty.Value) ([]iteration, error) {
if forEachValue.IsNull() || !forEachValue.IsWhollyKnown() {
return nil, fmt.Errorf("for_each value is null or unknown")
}
var ret []iteration
switch {
case forEachValue.Type().IsListType() || forEachValue.Type().IsTupleType():
elements := forEachValue.AsValueSlice()
for i, element := range elements {
key := cty.NumberIntVal(int64(i))
ret = append(ret, iteration{key: key, value: element})
}
case forEachValue.Type().IsMapType() || forEachValue.Type().IsObjectType():
elements := forEachValue.AsValueMap()
for keyStr, value := range elements {
key := cty.StringVal(keyStr)
ret = append(ret, iteration{key: key, value: value})
}
case forEachValue.Type().IsSetType():
// convert set to list first, then iterate
// for sets, both key and value are set to the value similar to how Terraform does it.
elements := forEachValue.AsValueSlice()
for _, element := range elements {
ret = append(ret, iteration{key: element, value: element})
}
default:
return nil, fmt.Errorf("for_each value is not iterable, found type %v", forEachValue.Type().FriendlyName())
}
return ret, nil
}
// unify unifies the supplied objects by merging values ensuring that leaf-level values are identical in the event
// that multiple objects contain the same leaf key.
func unify(inputs ...Object) (Object, error) {
var unifyObjects func(path string, objects ...Object) (Object, error)
unifyObjects = func(path string, objects ...Object) (Object, error) {
ret := Object{}
for _, obj := range objects {
for k, v := range obj {
currentPath := k
if path != "" {
currentPath = fmt.Sprintf("%s.%s", path, k)
}
existing, ok := ret[k]
if !ok {
ret[k] = v
continue
}
existingType := reflect.TypeOf(existing)
inputType := reflect.TypeOf(v)
if existingType != inputType {
return nil, fmt.Errorf("type mismatch for key %s: %s v/s %s", currentPath, inputType, existingType)
}
if e, ok := existing.(Object); ok {
//nolint: forcetypeassert
unified, err := unifyObjects(currentPath, v.(Object), e)
if err != nil {
return nil, err
}
ret[k] = unified
continue
}
if !reflect.DeepEqual(v, existing) {
return nil, fmt.Errorf("values for key %s not equal", currentPath)
}
}
}
return ret, nil
}
return unifyObjects("", inputs...)
}
// unifyBytes unifies the supplied maps with the same semantics as unify.
func unifyBytes(inputs ...map[string][]byte) (map[string][]byte, error) {
ret := map[string][]byte{}
for _, input := range inputs {
for k, v := range input {
existing, ok := ret[k]
if !ok {
ret[k] = v
continue
}
if !bytes.Equal(v, existing) {
return nil, fmt.Errorf("values for key %s not equal", k)
}
}
}
return ret, nil
}
// findUnknownPaths walks the value and returns a list of paths to unknown values.
func findUnknownPaths(val cty.Value) ([]string, error) {
var unknownPaths []string
if err := cty.Walk(val, func(path cty.Path, v cty.Value) (bool, error) {
if !v.IsKnown() {
unknownPaths = append(unknownPaths, path2String(path))
return true, nil
}
return true, nil
}); err != nil {
return unknownPaths, err
}
return unknownPaths, nil
}
// unknownSegmentMarker is used to represent segments we don't support decoding.
const unknownSegmentMarker = "<?>"
// path2String converts a cty.Path to a human-readable string.
func path2String(path cty.Path) string {
segments := make([]string, 0, len(path))
for _, p := range path {
switch s := p.(type) {
case cty.GetAttrStep:
segments = append(segments, fmt.Sprintf(".%s", s.Name))
case cty.IndexStep:
switch s.Key.Type() {
case cty.String:
segments = append(segments, fmt.Sprintf("[%s]", s.Key.AsString()))
case cty.Number:
segments = append(segments, fmt.Sprintf("[%s]", s.Key.AsBigFloat().Text('f', 0)))
default:
segments = append(segments, unknownSegmentMarker)
}
default:
segments = append(segments, unknownSegmentMarker)
}
}
return strings.Join(segments, "")
}
// err2Diag converts an error to a hcl.Diagnostic.
func err2Diag(err error) *hcl.Diagnostic {
return &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: err.Error(),
}
}
// mapDiagnosticSeverity maps the severity of the diagnostics from src to dst.
// This is a destructive operation, clone the diags before calling this function if you need the original.
// nolint:unparam
func mapDiagnosticSeverity(diags hcl.Diagnostics, src, dst hcl.DiagnosticSeverity) hcl.Diagnostics {
for i := range diags {
if diags[i].Severity == src {
diags[i].Severity = dst
}
}
return diags
}
// ptr returns a pointer to the supplied value.
func ptr[T any](v T) *T {
return &v
}
// sortDiagsBySeverity sorts the supplied diagnostics by severity.
func sortDiagsBySeverity(diags hcl.Diagnostics) hcl.Diagnostics {
sort.SliceStable(diags, func(i, j int) bool {
return diags[i].Severity < diags[j].Severity
})
return diags
}
package evaluator
import (
"encoding/json"
"fmt"
"sort"
"github.com/crossplane-contrib/function-hcl/internal/funcs"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/hashicorp/hcl/v2"
"github.com/pkg/errors"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
type nameIndex struct {
name string
index string
}
func (e *Evaluator) trackBaseNames(observedResources map[string]any) (map[string][]string, error) {
out := map[string][]nameIndex{}
for name, res := range observedResources {
obj, ok := res.(map[string]any)
if !ok {
return nil, fmt.Errorf("observed resource %q is not a map[string]any", name)
}
annotations, found, err := unstructured.NestedStringMap(obj, "metadata", "annotations")
if err != nil {
return nil, errors.Wrap(err, "accessing observed resource annotations")
}
if !found || annotations == nil {
continue
}
baseName := annotations[annotationBaseName]
if baseName == "" {
continue
}
index := annotations[annotationIndex] // we assume it exists if base name does, only affects sorting
out[baseName] = append(out[baseName], nameIndex{name: name, index: index})
}
for _, v := range out {
sort.Slice(v, func(i, j int) bool {
return v[i].index < v[j].index
})
}
ret := map[string][]string{}
for k, v := range out {
var names []string
for _, ni := range v {
names = append(names, ni.name)
}
ret[k] = names
}
return ret, nil
}
func (e *Evaluator) makeVars(in *fnv1.RunFunctionRequest) (*hcl.EvalContext, error) {
// toObject converts a resource to an object after removing managed fields.
// This cuts the processing time needed to almost half,
// given that it is a lot of useless processing for getting the implied type of these fields.
toObject := func(r *fnv1.Resource) Object {
m := r.GetResource().AsMap()
unstructured.RemoveNestedField(m, "metadata", "managedFields")
return m
}
observedResourceMap := Object{}
observedConnectionMap := Object{}
for name, object := range in.GetObserved().GetResources() {
observedResourceMap[name] = toObject(object)
observedConnectionMap[name] = object.GetConnectionDetails()
}
extra := Object{}
for name, res := range in.GetExtraResources() {
resources := res.GetItems()
var coll []Object
for _, resource := range resources {
coll = append(coll, toObject(resource))
}
extra[name] = coll
}
baseNameMap, err := e.trackBaseNames(observedResourceMap)
if err != nil {
return nil, errors.Wrap(err, "get base collections")
}
out := Object{
reqContext: in.GetContext().AsMap(),
reqComposite: toObject(in.GetObserved().GetComposite()),
reqCompositeConnection: in.GetObserved().GetComposite().GetConnectionDetails(),
reqObservedResource: observedResourceMap,
reqObservedConnection: observedConnectionMap,
reqExtraResources: extra,
}
jsonBytes, err := json.Marshal(out)
if err != nil {
return nil, errors.Wrap(err, "marshal variables to json")
}
impliedType, err := ctyjson.ImpliedType(jsonBytes)
if err != nil {
return nil, errors.Wrap(err, "infer types from json")
}
varsValue, err := ctyjson.Unmarshal(jsonBytes, impliedType)
if err != nil {
return nil, errors.Wrap(err, "unmarshal json")
}
topMap := varsValue.AsValueMap()
e.existingResourceMap = topMap[reqObservedResource].AsValueMap()
e.existingConnectionMap = topMap[reqObservedConnection].AsValueMap()
collectionResources := DynamicObject{}
collectionConnections := DynamicObject{}
for baseName, resourceNames := range baseNameMap {
var ctyResources, ctyConnections []cty.Value
for _, resName := range resourceNames {
ctyResources = append(ctyResources, e.existingResourceMap[resName])
ctyConnections = append(ctyConnections, e.existingConnectionMap[resName])
// make collection resources only accessible from the collection so that
// we can perform better static analysis of resource name references.
// If this decision turns out to be a mistake it can be added back
// but going the other way and removing it later will be impossible.
delete(e.existingResourceMap, resName)
delete(e.existingConnectionMap, resName)
}
collectionResources[baseName] = cty.TupleVal(ctyResources)
collectionConnections[baseName] = cty.TupleVal(ctyConnections)
}
topMap[reqObservedResources] = cty.ObjectVal(collectionResources)
topMap[reqObservedConnections] = cty.ObjectVal(collectionConnections)
// create a basic context with vars
ctx := &hcl.EvalContext{
Variables: DynamicObject{
reservedReq: cty.ObjectVal(topMap),
},
Functions: funcs.All(),
}
return ctx, err
}
package fn
import (
"context"
"fmt"
input "github.com/crossplane-contrib/function-hcl/input/v1beta1"
"github.com/crossplane-contrib/function-hcl/internal/evaluator"
"github.com/crossplane/crossplane-runtime/pkg/logging"
"github.com/crossplane/function-sdk-go"
fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
"github.com/crossplane/function-sdk-go/request"
"github.com/crossplane/function-sdk-go/response"
"github.com/pkg/errors"
"golang.org/x/tools/txtar"
"google.golang.org/protobuf/types/known/structpb"
)
const debugAnnotation = "hcl.fn.crossplane.io/debug"
// Options are options for the cue runner.
type Options struct {
Logger logging.Logger
Debug bool
}
type Fn struct {
fnv1.UnimplementedFunctionRunnerServiceServer
log logging.Logger
debug bool
}
// New creates a hcl runner.
func New(opts Options) (*Fn, error) {
if opts.Logger == nil {
var err error
opts.Logger, err = function.NewLogger(opts.Debug)
if err != nil {
return nil, err
}
}
return &Fn{
log: opts.Logger,
debug: opts.Debug,
}, nil
}
// RunFunction runs the function.
func (f *Fn) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (outRes *fnv1.RunFunctionResponse, finalErr error) {
// setup response with desired state set up upstream functions
res := response.To(req, response.DefaultTTL)
logger := f.log
// automatically handle errors and response logging
defer func() {
if finalErr == nil {
logger.Info("hcl module executed successfully")
response.Normal(outRes, "hcl module executed successfully")
return
}
logger.Info(finalErr.Error())
response.Fatal(res, finalErr)
outRes = res
}()
// setup logging and debugging
oxr, err := request.GetObservedCompositeResource(req)
if err != nil {
return nil, errors.Wrap(err, "get observed composite")
}
tag := req.GetMeta().GetTag()
if tag != "" {
logger = f.log.WithValues("tag", tag)
}
logger = logger.WithValues(
"xr-version", oxr.Resource.GetAPIVersion(),
"xr-kind", oxr.Resource.GetKind(),
"xr-name", oxr.Resource.GetName(),
)
logger.Info("Running Function")
debugThis := false
annotations := oxr.Resource.GetAnnotations()
if annotations != nil && annotations[debugAnnotation] == "true" {
debugThis = true
}
// get inputs
in := &input.HclInput{}
if err := request.GetInput(req, in); err != nil {
return nil, errors.Wrap(err, "unable to get input")
}
if in.HCL == "" {
return nil, fmt.Errorf("input HCL was not specified")
}
if in.DebugNew {
if len(req.GetObserved().GetResources()) == 0 {
debugThis = true
}
}
var files []evaluator.File
archive := txtar.Parse([]byte(in.HCL))
for _, file := range archive.Files {
files = append(files, evaluator.File{Name: file.Name, Content: string(file.Data)})
}
if len(files) == 0 {
return nil, fmt.Errorf("no HCL input files found, are you using the txtar format?")
}
e, err := evaluator.New(evaluator.Options{
Logger: logger,
Debug: debugThis,
})
if err != nil {
return nil, errors.Wrap(err, "create evaluator")
}
evalRes, err := e.Eval(req, files...)
if err != nil {
return nil, errors.Wrap(err, "evaluate hcl")
}
r, err := f.mergeResponse(res, evalRes)
return r, err
}
func (f *Fn) mergeResponse(res *fnv1.RunFunctionResponse, hclResponse *fnv1.RunFunctionResponse) (*fnv1.RunFunctionResponse, error) {
if res.Desired == nil {
res.Desired = &fnv1.State{}
}
if res.Desired.Resources == nil {
res.Desired.Resources = map[string]*fnv1.Resource{}
}
// only set desired composite if the evaluator script actually returns it.
// we assume that the evaluator sets the `status` attribute only.
if hclResponse.Desired.GetComposite() != nil {
res.Desired.Composite = hclResponse.Desired.GetComposite()
}
// set desired resources from hcl output
for k, v := range hclResponse.Desired.GetResources() {
res.Desired.Resources[k] = v
}
// merge the context if hclResponse has something in it
if hclResponse.Context != nil {
ctxMap := map[string]interface{}{}
// set up base map, if found
if res.Context != nil {
ctxMap = res.Context.AsMap()
}
// merge values from hclResponse
for k, v := range hclResponse.Context.AsMap() {
ctxMap[k] = v
}
s, err := structpb.NewStruct(ctxMap)
if err != nil {
return nil, errors.Wrap(err, "set response context")
}
res.Context = s
}
res.Results = hclResponse.Results
res.Conditions = hclResponse.Conditions
return res, nil
}
package funcs
import (
"github.com/hashicorp/hcl/v2/ext/tryfunc"
ctyyaml "github.com/zclconf/go-cty-yaml"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib"
)
// copied from the Terraform 1.5.7 codebase and modified to remove things we don't want to support.
var supportedFunctions = map[string]function.Function{
"abs": stdlib.AbsoluteFunc,
"alltrue": AllTrueFunc,
"anytrue": AnyTrueFunc,
"base64decode": Base64DecodeFunc,
"base64encode": Base64EncodeFunc,
"base64gzip": Base64GzipFunc,
"base64sha256": Base64Sha256Func,
"base64sha512": Base64Sha512Func,
"can": tryfunc.CanFunc,
"ceil": stdlib.CeilFunc,
"chomp": stdlib.ChompFunc,
"cidrhost": CidrHostFunc,
"cidrnetmask": CidrNetmaskFunc,
"cidrsubnet": CidrSubnetFunc,
"cidrsubnets": CidrSubnetsFunc,
"coalesce": CoalesceFunc,
"coalescelist": stdlib.CoalesceListFunc,
"compact": stdlib.CompactFunc,
"concat": stdlib.ConcatFunc,
"contains": stdlib.ContainsFunc,
"csvdecode": stdlib.CSVDecodeFunc,
"distinct": stdlib.DistinctFunc,
"element": stdlib.ElementFunc,
"endswith": EndsWithFunc,
"chunklist": stdlib.ChunklistFunc,
"flatten": stdlib.FlattenFunc,
"floor": stdlib.FloorFunc,
"format": stdlib.FormatFunc,
"formatdate": stdlib.FormatDateFunc,
"formatlist": stdlib.FormatListFunc,
"indent": stdlib.IndentFunc,
"index": IndexFunc, // stdlib.IndexFunc is not compatible
"join": stdlib.JoinFunc,
"jsondecode": stdlib.JSONDecodeFunc,
"jsonencode": stdlib.JSONEncodeFunc,
"keys": stdlib.KeysFunc,
"length": LengthFunc,
"list": ListFunc,
"log": stdlib.LogFunc,
"lookup": LookupFunc,
"lower": stdlib.LowerFunc,
"map": MapFunc,
"matchkeys": MatchkeysFunc,
"max": stdlib.MaxFunc,
"md5": Md5Func,
"merge": stdlib.MergeFunc,
"min": stdlib.MinFunc,
"one": OneFunc,
"parseint": stdlib.ParseIntFunc,
"pow": stdlib.PowFunc,
"range": stdlib.RangeFunc,
"regex": stdlib.RegexFunc,
"regexall": stdlib.RegexAllFunc,
"replace": ReplaceFunc,
"reverse": stdlib.ReverseListFunc,
"rsadecrypt": RsaDecryptFunc,
"setintersection": stdlib.SetIntersectionFunc,
"setproduct": stdlib.SetProductFunc,
"setsubtract": stdlib.SetSubtractFunc,
"setunion": stdlib.SetUnionFunc,
"sha1": Sha1Func,
"sha256": Sha256Func,
"sha512": Sha512Func,
"signum": stdlib.SignumFunc,
"slice": stdlib.SliceFunc,
"sort": stdlib.SortFunc,
"split": stdlib.SplitFunc,
"startswith": StartsWithFunc,
"strcontains": StrContainsFunc,
"strrev": stdlib.ReverseFunc,
"substr": stdlib.SubstrFunc,
"sum": SumFunc,
"textdecodebase64": TextDecodeBase64Func,
"textencodebase64": TextEncodeBase64Func,
"timestamp": TimestampFunc,
"timeadd": stdlib.TimeAddFunc,
"timecmp": TimeCmpFunc,
"title": stdlib.TitleFunc,
"tostring": MakeToFunc(cty.String),
"tonumber": MakeToFunc(cty.Number),
"tobool": MakeToFunc(cty.Bool),
"toset": MakeToFunc(cty.Set(cty.DynamicPseudoType)),
"tolist": MakeToFunc(cty.List(cty.DynamicPseudoType)),
"tomap": MakeToFunc(cty.Map(cty.DynamicPseudoType)),
"transpose": TransposeFunc,
"trim": stdlib.TrimFunc,
"trimprefix": stdlib.TrimPrefixFunc,
"trimspace": stdlib.TrimSpaceFunc,
"trimsuffix": stdlib.TrimSuffixFunc,
"try": tryfunc.TryFunc,
"upper": stdlib.UpperFunc,
"urlencode": URLEncodeFunc,
"values": stdlib.ValuesFunc,
"yamldecode": ctyyaml.YAMLDecodeFunc,
"yamlencode": ctyyaml.YAMLEncodeFunc,
"zipmap": stdlib.ZipmapFunc,
}
// All returns all functions exposed by this module.
func All() map[string]function.Function {
return supportedFunctions
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"fmt"
"math/big"
"github.com/apparentlymart/go-cidr/cidr"
"github.com/crossplane-contrib/function-hcl/internal/funcs/ipaddr"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/gocty"
)
// CidrHostFunc contructs a function that calculates a full host IP address
// within a given IP network address prefix.
var CidrHostFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "prefix",
Type: cty.String,
},
{
Name: "hostnum",
Type: cty.Number,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var hostNum *big.Int
if err := gocty.FromCtyValue(args[1], &hostNum); err != nil {
return cty.UnknownVal(cty.String), err
}
_, network, err := ipaddr.ParseCIDR(args[0].AsString())
if err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("invalid CIDR expression: %s", err)
}
ip, err := cidr.HostBig(network, hostNum)
if err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.StringVal(ip.String()), nil
},
})
// CidrNetmaskFunc contructs a function that converts an IPv4 address prefix given
// in CIDR notation into a subnet mask address.
var CidrNetmaskFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "prefix",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
_, network, err := ipaddr.ParseCIDR(args[0].AsString())
if err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("invalid CIDR expression: %s", err)
}
if network.IP.To4() == nil {
return cty.UnknownVal(cty.String), fmt.Errorf("IPv6 addresses cannot have a netmask: %s", args[0].AsString())
}
return cty.StringVal(ipaddr.IP(network.Mask).String()), nil
},
})
// CidrSubnetFunc contructs a function that calculates a subnet address within
// a given IP network address prefix.
var CidrSubnetFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "prefix",
Type: cty.String,
},
{
Name: "newbits",
Type: cty.Number,
},
{
Name: "netnum",
Type: cty.Number,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var newbits int
if err := gocty.FromCtyValue(args[1], &newbits); err != nil {
return cty.UnknownVal(cty.String), err
}
var netnum *big.Int
if err := gocty.FromCtyValue(args[2], &netnum); err != nil {
return cty.UnknownVal(cty.String), err
}
_, network, err := ipaddr.ParseCIDR(args[0].AsString())
if err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("invalid CIDR expression: %s", err)
}
newNetwork, err := cidr.SubnetBig(network, newbits, netnum)
if err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.StringVal(newNetwork.String()), nil
},
})
// CidrSubnetsFunc is similar to CidrSubnetFunc but calculates many consecutive
// subnet addresses at once, rather than just a single subnet extension.
var CidrSubnetsFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "prefix",
Type: cty.String,
},
},
VarParam: &function.Parameter{
Name: "newbits",
Type: cty.Number,
},
Type: function.StaticReturnType(cty.List(cty.String)),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
_, network, err := ipaddr.ParseCIDR(args[0].AsString())
if err != nil {
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid CIDR expression: %s", err)
}
startPrefixLen, _ := network.Mask.Size()
prefixLengthArgs := args[1:]
if len(prefixLengthArgs) == 0 {
return cty.ListValEmpty(cty.String), nil
}
var firstLength int
if err := gocty.FromCtyValue(prefixLengthArgs[0], &firstLength); err != nil {
return cty.UnknownVal(cty.String), function.NewArgError(1, err)
}
firstLength += startPrefixLen
retVals := make([]cty.Value, len(prefixLengthArgs))
current, _ := cidr.PreviousSubnet(network, firstLength)
for i, lengthArg := range prefixLengthArgs {
var length int
if err := gocty.FromCtyValue(lengthArg, &length); err != nil {
return cty.UnknownVal(cty.String), function.NewArgError(i+1, err)
}
if length < 1 {
return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "must extend prefix by at least one bit")
}
// For portability with 32-bit systems where the subnet number
// will be a 32-bit int, we only allow extension of 32 bits in
// one call even if we're running on a 64-bit machine.
// (Of course, this is significant only for IPv6.)
if length > 32 {
return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "may not extend prefix by more than 32 bits")
}
length += startPrefixLen
if length > (len(network.IP) * 8) {
protocol := "IP"
switch len(network.IP) * 8 {
case 32:
protocol = "IPv4"
case 128:
protocol = "IPv6"
}
return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "would extend prefix to %d bits, which is too long for an %s address", length, protocol)
}
next, rollover := cidr.NextSubnet(current, length)
if rollover || !network.Contains(next.IP) {
// If we run out of suffix bits in the base CIDR prefix then
// NextSubnet will start incrementing the prefix bits, which
// we don't allow because it would then allocate addresses
// outside of the caller's given prefix.
return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "not enough remaining address space for a subnet with a prefix of %d bits after %s", length, current.String())
}
current = next
retVals[i] = cty.StringVal(current.String())
}
return cty.ListVal(retVals), nil
},
})
// CidrHost calculates a full host IP address within a given IP network address prefix.
func CidrHost(prefix, hostnum cty.Value) (cty.Value, error) {
return CidrHostFunc.Call([]cty.Value{prefix, hostnum})
}
// CidrNetmask converts an IPv4 address prefix given in CIDR notation into a subnet mask address.
func CidrNetmask(prefix cty.Value) (cty.Value, error) {
return CidrNetmaskFunc.Call([]cty.Value{prefix})
}
// CidrSubnet calculates a subnet address within a given IP network address prefix.
func CidrSubnet(prefix, newbits, netnum cty.Value) (cty.Value, error) {
return CidrSubnetFunc.Call([]cty.Value{prefix, newbits, netnum})
}
// CidrSubnets calculates a sequence of consecutive subnet prefixes that may
// be of different prefix lengths under a common base prefix.
func CidrSubnets(prefix cty.Value, newbits ...cty.Value) (cty.Value, error) {
args := make([]cty.Value, len(newbits)+1)
args[0] = prefix
copy(args[1:], newbits)
return CidrSubnetsFunc.Call(args)
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"errors"
"fmt"
"math/big"
"sort"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib"
"github.com/zclconf/go-cty/cty/gocty"
)
var LengthFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "value",
Type: cty.DynamicPseudoType,
AllowDynamicType: true,
AllowUnknown: true,
AllowMarked: true,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
collTy := args[0].Type()
switch {
case collTy == cty.String || collTy.IsTupleType() || collTy.IsObjectType() || collTy.IsListType() || collTy.IsMapType() || collTy.IsSetType() || collTy == cty.DynamicPseudoType:
return cty.Number, nil
default:
return cty.Number, errors.New("argument must be a string, a collection type, or a structural type")
}
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
coll := args[0]
collTy := args[0].Type()
marks := coll.Marks()
switch {
case collTy == cty.DynamicPseudoType:
return cty.UnknownVal(cty.Number).WithMarks(marks), nil
case collTy.IsTupleType():
l := len(collTy.TupleElementTypes())
return cty.NumberIntVal(int64(l)).WithMarks(marks), nil
case collTy.IsObjectType():
l := len(collTy.AttributeTypes())
return cty.NumberIntVal(int64(l)).WithMarks(marks), nil
case collTy == cty.String:
// We'll delegate to the cty stdlib strlen function here, because
// it deals with all of the complexities of tokenizing unicode
// grapheme clusters.
return stdlib.Strlen(coll)
case collTy.IsListType() || collTy.IsSetType() || collTy.IsMapType():
return coll.Length(), nil
default:
// Should never happen, because of the checks in our Type func above
return cty.UnknownVal(cty.Number), errors.New("impossible value type for length(...)")
}
},
})
// AllTrueFunc constructs a function that returns true if all elements of the
// list are true. If the list is empty, return true.
var AllTrueFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "list",
Type: cty.List(cty.Bool),
},
},
Type: function.StaticReturnType(cty.Bool),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
result := cty.True
for it := args[0].ElementIterator(); it.Next(); {
_, v := it.Element()
if !v.IsKnown() {
return cty.UnknownVal(cty.Bool), nil
}
if v.IsNull() {
return cty.False, nil
}
result = result.And(v)
if result.False() {
return cty.False, nil
}
}
return result, nil
},
})
// AnyTrueFunc constructs a function that returns true if any element of the
// list is true. If the list is empty, return false.
var AnyTrueFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "list",
Type: cty.List(cty.Bool),
},
},
Type: function.StaticReturnType(cty.Bool),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
result := cty.False
var hasUnknown bool
for it := args[0].ElementIterator(); it.Next(); {
_, v := it.Element()
if !v.IsKnown() {
hasUnknown = true
continue
}
if v.IsNull() {
continue
}
result = result.Or(v)
if result.True() {
return cty.True, nil
}
}
if hasUnknown {
return cty.UnknownVal(cty.Bool), nil
}
return result, nil
},
})
// CoalesceFunc constructs a function that takes any number of arguments and
// returns the first one that isn't empty. This function was copied from go-cty
// stdlib and modified so that it returns the first *non-empty* non-null element
// from a sequence, instead of merely the first non-null.
var CoalesceFunc = function.New(&function.Spec{
Params: []function.Parameter{},
VarParam: &function.Parameter{
Name: "vals",
Type: cty.DynamicPseudoType,
AllowUnknown: true,
AllowDynamicType: true,
AllowNull: true,
},
Type: func(args []cty.Value) (ret cty.Type, err error) {
argTypes := make([]cty.Type, len(args))
for i, val := range args {
argTypes[i] = val.Type()
}
retType, _ := convert.UnifyUnsafe(argTypes)
if retType == cty.NilType {
return cty.NilType, errors.New("all arguments must have the same type")
}
return retType, nil
},
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
for _, argVal := range args {
// We already know this will succeed because of the checks in our Type func above
argVal, _ = convert.Convert(argVal, retType)
if !argVal.IsKnown() {
return cty.UnknownVal(retType), nil
}
if argVal.IsNull() {
continue
}
if retType == cty.String && argVal.RawEquals(cty.StringVal("")) {
continue
}
return argVal, nil
}
return cty.NilVal, errors.New("no non-null, non-empty-string arguments")
},
})
// IndexFunc constructs a function that finds the element index for a given value in a list.
var IndexFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "list",
Type: cty.DynamicPseudoType,
},
{
Name: "value",
Type: cty.DynamicPseudoType,
},
},
Type: function.StaticReturnType(cty.Number),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
if !(args[0].Type().IsListType() || args[0].Type().IsTupleType()) {
return cty.NilVal, errors.New("argument must be a list or tuple")
}
if !args[0].IsKnown() {
return cty.UnknownVal(cty.Number), nil
}
if args[0].LengthInt() == 0 { // Easy path
return cty.NilVal, errors.New("cannot search an empty list")
}
for it := args[0].ElementIterator(); it.Next(); {
i, v := it.Element()
eq, err := stdlib.Equal(v, args[1])
if err != nil {
return cty.NilVal, err
}
if !eq.IsKnown() {
return cty.UnknownVal(cty.Number), nil
}
if eq.True() {
return i, nil
}
}
return cty.NilVal, errors.New("item not found")
},
})
// LookupFunc constructs a function that performs dynamic lookups of map types.
var LookupFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "inputMap",
Type: cty.DynamicPseudoType,
AllowMarked: true,
},
{
Name: "key",
Type: cty.String,
AllowMarked: true,
},
},
VarParam: &function.Parameter{
Name: "default",
Type: cty.DynamicPseudoType,
AllowUnknown: true,
AllowDynamicType: true,
AllowNull: true,
AllowMarked: true,
},
Type: func(args []cty.Value) (ret cty.Type, err error) {
if len(args) < 1 || len(args) > 3 {
return cty.NilType, fmt.Errorf("lookup() takes two or three arguments, got %d", len(args))
}
ty := args[0].Type()
switch {
case ty.IsObjectType():
if !args[1].IsKnown() {
return cty.DynamicPseudoType, nil
}
keyVal, _ := args[1].Unmark()
key := keyVal.AsString()
if ty.HasAttribute(key) {
return args[0].GetAttr(key).Type(), nil
} else if len(args) == 3 {
// if the key isn't found but a default is provided,
// return the default type
return args[2].Type(), nil
}
return cty.DynamicPseudoType, function.NewArgErrorf(0, "the given object has no attribute %q", key)
case ty.IsMapType():
if len(args) == 3 {
_, err = convert.Convert(args[2], ty.ElementType())
if err != nil {
return cty.NilType, function.NewArgErrorf(2, "the default value must have the same type as the map elements")
}
}
return ty.ElementType(), nil
default:
return cty.NilType, function.NewArgErrorf(0, "lookup() requires a map as the first argument")
}
},
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var defaultVal cty.Value
defaultValueSet := false
if len(args) == 3 {
// intentionally leave default value marked
defaultVal = args[2]
defaultValueSet = true
}
// keep track of marks from the collection and key
var markses []cty.ValueMarks
// unmark collection, retain marks to reapply later
mapVar, mapMarks := args[0].Unmark()
markses = append(markses, mapMarks)
// include marks on the key in the result
keyVal, keyMarks := args[1].Unmark()
if len(keyMarks) > 0 {
markses = append(markses, keyMarks)
}
lookupKey := keyVal.AsString()
if !mapVar.IsKnown() {
return cty.UnknownVal(retType).WithMarks(markses...), nil
}
if mapVar.Type().IsObjectType() {
if mapVar.Type().HasAttribute(lookupKey) {
return mapVar.GetAttr(lookupKey).WithMarks(markses...), nil
}
} else if mapVar.HasIndex(cty.StringVal(lookupKey)) == cty.True {
return mapVar.Index(cty.StringVal(lookupKey)).WithMarks(markses...), nil
}
if defaultValueSet {
defaultVal, err = convert.Convert(defaultVal, retType)
if err != nil {
return cty.NilVal, err
}
return defaultVal.WithMarks(markses...), nil
}
return cty.UnknownVal(cty.DynamicPseudoType), fmt.Errorf(
"lookup failed to find key %s", lookupKey)
},
})
// MatchkeysFunc constructs a function that constructs a new list by taking a
// subset of elements from one list whose indexes match the corresponding
// indexes of values in another list.
var MatchkeysFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "values",
Type: cty.List(cty.DynamicPseudoType),
},
{
Name: "keys",
Type: cty.List(cty.DynamicPseudoType),
},
{
Name: "searchset",
Type: cty.List(cty.DynamicPseudoType),
},
},
Type: func(args []cty.Value) (cty.Type, error) {
ty, _ := convert.UnifyUnsafe([]cty.Type{args[1].Type(), args[2].Type()})
if ty == cty.NilType {
return cty.NilType, errors.New("keys and searchset must be of the same type")
}
// the return type is based on args[0] (values)
return args[0].Type(), nil
},
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
if !args[0].IsKnown() {
return cty.UnknownVal(cty.List(retType.ElementType())), nil
}
if args[0].LengthInt() != args[1].LengthInt() {
return cty.ListValEmpty(retType.ElementType()), errors.New("length of keys and values should be equal")
}
output := make([]cty.Value, 0)
values := args[0]
// Keys and searchset must be the same type.
// We can skip error checking here because we've already verified that
// they can be unified in the Type function
ty, _ := convert.UnifyUnsafe([]cty.Type{args[1].Type(), args[2].Type()})
keys, _ := convert.Convert(args[1], ty)
searchset, _ := convert.Convert(args[2], ty)
// if searchset is empty, return an empty list.
if searchset.LengthInt() == 0 {
return cty.ListValEmpty(retType.ElementType()), nil
}
if !values.IsWhollyKnown() || !keys.IsWhollyKnown() {
return cty.UnknownVal(retType), nil
}
i := 0
for it := keys.ElementIterator(); it.Next(); {
_, key := it.Element()
for iter := searchset.ElementIterator(); iter.Next(); {
_, search := iter.Element()
eq, err := stdlib.Equal(key, search)
if err != nil {
return cty.NilVal, err
}
if !eq.IsKnown() {
return cty.ListValEmpty(retType.ElementType()), nil
}
if eq.True() {
v := values.Index(cty.NumberIntVal(int64(i)))
output = append(output, v)
break
}
}
i++
}
// if we haven't matched any key, then output is an empty list.
if len(output) == 0 {
return cty.ListValEmpty(retType.ElementType()), nil
}
return cty.ListVal(output), nil
},
})
// OneFunc returns either the first element of a one-element list, or null
// if given a zero-element list.
var OneFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "list",
Type: cty.DynamicPseudoType,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
ty := args[0].Type()
switch {
case ty.IsListType() || ty.IsSetType():
return ty.ElementType(), nil
case ty.IsTupleType():
etys := ty.TupleElementTypes()
switch len(etys) {
case 0:
// No specific type information, so we'll ultimately return
// a null value of unknown type.
return cty.DynamicPseudoType, nil
case 1:
return etys[0], nil
}
}
return cty.NilType, function.NewArgErrorf(0, "must be a list, set, or tuple value with either zero or one elements")
},
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
val := args[0]
ty := val.Type()
// Our parameter spec above doesn't set AllowUnknown or AllowNull,
// so we can assume our top-level collection is both known and non-null
// in here.
switch {
case ty.IsListType() || ty.IsSetType():
lenVal := val.Length()
if !lenVal.IsKnown() {
return cty.UnknownVal(retType), nil
}
var l int
err := gocty.FromCtyValue(lenVal, &l)
if err != nil {
// It would be very strange to get here, because that would
// suggest that the length is either not a number or isn't
// an integer, which would suggest a bug in cty.
return cty.NilVal, fmt.Errorf("invalid collection length: %s", err)
}
switch l {
case 0:
return cty.NullVal(retType), nil
case 1:
var ret cty.Value
// We'll use an iterator here because that works for both lists
// and sets, whereas indexing directly would only work for lists.
// Since we've just checked the length, we should only actually
// run this loop body once.
for it := val.ElementIterator(); it.Next(); {
_, ret = it.Element()
}
return ret, nil
}
case ty.IsTupleType():
etys := ty.TupleElementTypes()
switch len(etys) {
case 0:
return cty.NullVal(retType), nil
case 1:
ret := val.Index(cty.NumberIntVal(0))
return ret, nil
}
}
return cty.NilVal, function.NewArgErrorf(0, "must be a list, set, or tuple value with either zero or one elements")
},
})
// SumFunc constructs a function that returns the sum of all
// numbers provided in a list.
var SumFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "list",
Type: cty.DynamicPseudoType,
},
},
Type: function.StaticReturnType(cty.Number),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
if !args[0].CanIterateElements() {
return cty.NilVal, function.NewArgErrorf(0, "cannot sum noniterable")
}
if args[0].LengthInt() == 0 { // Easy path
return cty.NilVal, function.NewArgErrorf(0, "cannot sum an empty list")
}
arg := args[0].AsValueSlice()
ty := args[0].Type()
if !ty.IsListType() && !ty.IsSetType() && !ty.IsTupleType() {
return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple. Received %s", ty.FriendlyName())
}
if !args[0].IsWhollyKnown() {
return cty.UnknownVal(cty.Number), nil
}
// big.Float.Add can panic if the input values are opposing infinities,
// so we must catch that here in order to remain within
// the cty Function abstraction.
defer func() {
if r := recover(); r != nil {
if _, ok := r.(big.ErrNaN); ok {
ret = cty.NilVal
err = fmt.Errorf("can't compute sum of opposing infinities")
} else {
// not a panic we recognize
panic(r)
}
}
}()
s := arg[0]
if s.IsNull() {
return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values")
}
s, err = convert.Convert(s, cty.Number)
if err != nil {
return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values")
}
for _, v := range arg[1:] {
if v.IsNull() {
return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values")
}
v, err = convert.Convert(v, cty.Number)
if err != nil {
return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values")
}
s = s.Add(v)
}
return s, nil
},
})
// TransposeFunc constructs a function that takes a map of lists of strings and
// swaps the keys and values to produce a new map of lists of strings.
var TransposeFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "values",
Type: cty.Map(cty.List(cty.String)),
},
},
Type: function.StaticReturnType(cty.Map(cty.List(cty.String))),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
inputMap := args[0]
if !inputMap.IsWhollyKnown() {
return cty.UnknownVal(retType), nil
}
outputMap := make(map[string]cty.Value)
tmpMap := make(map[string][]string)
for it := inputMap.ElementIterator(); it.Next(); {
inKey, inVal := it.Element()
for iter := inVal.ElementIterator(); iter.Next(); {
_, val := iter.Element()
if !val.Type().Equals(cty.String) {
return cty.MapValEmpty(cty.List(cty.String)), errors.New("input must be a map of lists of strings")
}
outKey := val.AsString()
if _, ok := tmpMap[outKey]; !ok {
tmpMap[outKey] = make([]string, 0)
}
outVal := tmpMap[outKey]
outVal = append(outVal, inKey.AsString())
sort.Strings(outVal)
tmpMap[outKey] = outVal
}
}
for outKey, outVal := range tmpMap {
values := make([]cty.Value, 0)
for _, v := range outVal {
values = append(values, cty.StringVal(v))
}
outputMap[outKey] = cty.ListVal(values)
}
if len(outputMap) == 0 {
return cty.MapValEmpty(cty.List(cty.String)), nil
}
return cty.MapVal(outputMap), nil
},
})
// ListFunc constructs a function that takes an arbitrary number of arguments
// and returns a list containing those values in the same order.
//
// Deprecated: This function is deprecated in Terraform v0.12.
var ListFunc = function.New(&function.Spec{
Params: []function.Parameter{},
VarParam: &function.Parameter{
Name: "vals",
Type: cty.DynamicPseudoType,
AllowUnknown: true,
AllowDynamicType: true,
AllowNull: true,
},
Type: func(args []cty.Value) (ret cty.Type, err error) {
return cty.DynamicPseudoType, fmt.Errorf("the \"list\" function was deprecated in Terraform v0.12 and is no longer available; use tolist([ ... ]) syntax to write a literal list")
},
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
return cty.DynamicVal, fmt.Errorf("the \"list\" function was deprecated in Terraform v0.12 and is no longer available; use tolist([ ... ]) syntax to write a literal list")
},
})
// MapFunc constructs a function that takes an even number of arguments and
// returns a map whose elements are constructed from consecutive pairs of arguments.
//
// Deprecated: This function is deprecated in Terraform v0.12.
var MapFunc = function.New(&function.Spec{
Params: []function.Parameter{},
VarParam: &function.Parameter{
Name: "vals",
Type: cty.DynamicPseudoType,
AllowUnknown: true,
AllowDynamicType: true,
AllowNull: true,
},
Type: func(args []cty.Value) (ret cty.Type, err error) {
return cty.DynamicPseudoType, fmt.Errorf("the \"map\" function was deprecated in Terraform v0.12 and is no longer available; use tomap({ ... }) syntax to write a literal map")
},
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
return cty.DynamicVal, fmt.Errorf("the \"map\" function was deprecated in Terraform v0.12 and is no longer available; use tomap({ ... }) syntax to write a literal map")
},
})
// Length returns the number of elements in the given collection or number of
// Unicode characters in the given string.
func Length(collection cty.Value) (cty.Value, error) {
return LengthFunc.Call([]cty.Value{collection})
}
// AllTrue returns true if all elements of the list are true. If the list is empty,
// return true.
func AllTrue(collection cty.Value) (cty.Value, error) {
return AllTrueFunc.Call([]cty.Value{collection})
}
// AnyTrue returns true if any element of the list is true. If the list is empty,
// return false.
func AnyTrue(collection cty.Value) (cty.Value, error) {
return AnyTrueFunc.Call([]cty.Value{collection})
}
// Coalesce takes any number of arguments and returns the first one that isn't empty.
func Coalesce(args ...cty.Value) (cty.Value, error) {
return CoalesceFunc.Call(args)
}
// Index finds the element index for a given value in a list.
func Index(list, value cty.Value) (cty.Value, error) {
return IndexFunc.Call([]cty.Value{list, value})
}
// List takes any number of arguments of types that can unify into a single
// type and returns a list containing those values in the same order, or
// returns an error if there is no single element type that all values can
// convert to.
func List(args ...cty.Value) (cty.Value, error) {
return ListFunc.Call(args)
}
// Lookup performs a dynamic lookup into a map.
// There are two required arguments, map and key, plus an optional default,
// which is a value to return if no key is found in map.
func Lookup(args ...cty.Value) (cty.Value, error) {
return LookupFunc.Call(args)
}
// Map takes an even number of arguments and returns a map whose elements are constructed
// from consecutive pairs of arguments.
func Map(args ...cty.Value) (cty.Value, error) {
return MapFunc.Call(args)
}
// Matchkeys constructs a new list by taking a subset of elements from one list
// whose indexes match the corresponding indexes of values in another list.
func Matchkeys(values, keys, searchset cty.Value) (cty.Value, error) {
return MatchkeysFunc.Call([]cty.Value{values, keys, searchset})
}
// One returns either the first element of a one-element list, or null
// if given a zero-element list..
func One(list cty.Value) (cty.Value, error) {
return OneFunc.Call([]cty.Value{list})
}
// Sum adds numbers in a list, set, or tuple.
func Sum(list cty.Value) (cty.Value, error) {
return SumFunc.Call([]cty.Value{list})
}
// Transpose takes a map of lists of strings and swaps the keys and values to
// produce a new map of lists of strings.
func Transpose(values cty.Value) (cty.Value, error) {
return TransposeFunc.Call([]cty.Value{values})
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"strconv"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/function"
)
// MakeToFunc constructs a "to..." function, like "tostring", which converts
// its argument to a specific type or type kind.
//
// The given type wantTy can be any type constraint that cty's "convert" package
// would accept. In particular, this means that you can pass
// cty.List(cty.DynamicPseudoType) to mean "list of any single type", which
// will then cause cty to attempt to unify all of the element types when given
// a tuple.
func MakeToFunc(wantTy cty.Type) function.Function {
return function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "v",
// We use DynamicPseudoType rather than wantTy here so that
// all values will pass through the function API verbatim and
// we can handle the conversion logic within the Type and
// Impl functions. This allows us to customize the error
// messages to be more appropriate for an explicit type
// conversion, whereas the cty function system produces
// messages aimed at _implicit_ type conversions.
Type: cty.DynamicPseudoType,
AllowNull: true,
AllowMarked: true,
AllowDynamicType: true,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
gotTy := args[0].Type()
if gotTy.Equals(wantTy) {
return wantTy, nil
}
conv := convert.GetConversionUnsafe(args[0].Type(), wantTy)
if conv == nil {
// We'll use some specialized errors for some trickier cases,
// but most we can handle in a simple way.
switch {
case gotTy.IsTupleType() && wantTy.IsTupleType():
return cty.NilType, function.NewArgErrorf(0, "incompatible tuple type for conversion: %s", convert.MismatchMessage(gotTy, wantTy))
case gotTy.IsObjectType() && wantTy.IsObjectType():
return cty.NilType, function.NewArgErrorf(0, "incompatible object type for conversion: %s", convert.MismatchMessage(gotTy, wantTy))
default:
return cty.NilType, function.NewArgErrorf(0, "cannot convert %s to %s", gotTy.FriendlyName(), wantTy.FriendlyNameForConstraint())
}
}
// If a conversion is available then everything is fine.
return wantTy, nil
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
// We didn't set "AllowUnknown" on our argument, so it is guaranteed
// to be known here but may still be null.
ret, err := convert.Convert(args[0], retType)
if err != nil {
val, _ := args[0].UnmarkDeep()
// Because we used GetConversionUnsafe above, conversion can
// still potentially fail in here. For example, if the user
// asks to convert the string "a" to bool then we'll
// optimistically permit it during type checking but fail here
// once we note that the value isn't either "true" or "false".
gotTy := val.Type()
switch {
case gotTy == cty.String && wantTy == cty.Bool:
what := "string"
if !val.IsNull() {
what = strconv.Quote(val.AsString())
}
return cty.NilVal, function.NewArgErrorf(0, `cannot convert %s to bool; only the strings "true" or "false" are allowed`, what)
case gotTy == cty.String && wantTy == cty.Number:
what := "string"
if !val.IsNull() {
what = strconv.Quote(val.AsString())
}
return cty.NilVal, function.NewArgErrorf(0, `cannot convert %s to number; given string must be a decimal representation of a number`, what)
default:
return cty.NilVal, function.NewArgErrorf(0, "cannot convert %s to %s", gotTy.FriendlyName(), wantTy.FriendlyNameForConstraint())
}
}
return ret, nil
},
})
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"crypto/md5"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash"
"strings"
uuidv5 "github.com/google/uuid"
uuid "github.com/hashicorp/go-uuid"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/gocty"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh"
)
var UUIDFunc = function.New(&function.Spec{
Params: []function.Parameter{},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
result, err := uuid.GenerateUUID()
if err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.StringVal(result), nil
},
})
var UUIDV5Func = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "namespace",
Type: cty.String,
},
{
Name: "name",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var namespace uuidv5.UUID
switch {
case args[0].AsString() == "dns":
namespace = uuidv5.NameSpaceDNS
case args[0].AsString() == "url":
namespace = uuidv5.NameSpaceURL
case args[0].AsString() == "oid":
namespace = uuidv5.NameSpaceOID
case args[0].AsString() == "x500":
namespace = uuidv5.NameSpaceX500
default:
if namespace, err = uuidv5.Parse(args[0].AsString()); err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("uuidv5() doesn't support namespace %s (%v)", args[0].AsString(), err)
}
}
val := args[1].AsString()
return cty.StringVal(uuidv5.NewSHA1(namespace, []byte(val)).String()), nil
},
})
// Base64Sha256Func constructs a function that computes the SHA256 hash of a given string
// and encodes it with Base64.
var Base64Sha256Func = makeStringHashFunction(sha256.New, base64.StdEncoding.EncodeToString)
// Base64Sha512Func constructs a function that computes the SHA256 hash of a given string
// and encodes it with Base64.
var Base64Sha512Func = makeStringHashFunction(sha512.New, base64.StdEncoding.EncodeToString)
// BcryptFunc constructs a function that computes a hash of the given string using the Blowfish cipher.
var BcryptFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
},
VarParam: &function.Parameter{
Name: "cost",
Type: cty.Number,
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
defaultCost := 10
if len(args) > 1 {
var val int
if err := gocty.FromCtyValue(args[1], &val); err != nil {
return cty.UnknownVal(cty.String), err
}
defaultCost = val
}
if len(args) > 2 {
return cty.UnknownVal(cty.String), fmt.Errorf("bcrypt() takes no more than two arguments")
}
input := args[0].AsString()
out, err := bcrypt.GenerateFromPassword([]byte(input), defaultCost)
if err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("error occurred generating password %s", err.Error())
}
return cty.StringVal(string(out)), nil
},
})
// Md5Func constructs a function that computes the MD5 hash of a given string and encodes it with hexadecimal digits.
var Md5Func = makeStringHashFunction(md5.New, hex.EncodeToString)
// RsaDecryptFunc constructs a function that decrypts an RSA-encrypted ciphertext.
var RsaDecryptFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "ciphertext",
Type: cty.String,
},
{
Name: "privatekey",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
s := args[0].AsString()
key := args[1].AsString()
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "failed to decode input %q: cipher text must be base64-encoded", s)
}
rawKey, err := ssh.ParseRawPrivateKey([]byte(key))
if err != nil {
var errStr string
switch e := err.(type) {
case asn1.SyntaxError:
errStr = strings.ReplaceAll(e.Error(), "asn1: syntax error", "invalid ASN1 data in the given private key")
case asn1.StructuralError:
errStr = strings.ReplaceAll(e.Error(), "asn1: struture error", "invalid ASN1 data in the given private key")
default:
errStr = fmt.Sprintf("invalid private key: %s", e)
}
return cty.UnknownVal(cty.String), function.NewArgError(1, errors.New(errStr))
}
privateKey, ok := rawKey.(*rsa.PrivateKey)
if !ok {
return cty.UnknownVal(cty.String), function.NewArgErrorf(1, "invalid private key type %t", rawKey)
}
out, err := rsa.DecryptPKCS1v15(nil, privateKey, b)
if err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("failed to decrypt: %s", err)
}
return cty.StringVal(string(out)), nil
},
})
// Sha1Func contructs a function that computes the SHA1 hash of a given string
// and encodes it with hexadecimal digits.
var Sha1Func = makeStringHashFunction(sha1.New, hex.EncodeToString)
// Sha256Func contructs a function that computes the SHA256 hash of a given string
// and encodes it with hexadecimal digits.
var Sha256Func = makeStringHashFunction(sha256.New, hex.EncodeToString)
// Sha512Func contructs a function that computes the SHA512 hash of a given string
// and encodes it with hexadecimal digits.
var Sha512Func = makeStringHashFunction(sha512.New, hex.EncodeToString)
func makeStringHashFunction(hf func() hash.Hash, enc func([]byte) string) function.Function {
return function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
s := args[0].AsString()
h := hf()
h.Write([]byte(s))
rv := enc(h.Sum(nil))
return cty.StringVal(rv), nil
},
})
}
// UUID generates and returns a Type-4 UUID in the standard hexadecimal string
// format.
//
// This is not a pure function: it will generate a different result for each
// call. It must therefore be registered as an impure function in the function
// table in the "lang" package.
func UUID() (cty.Value, error) {
return UUIDFunc.Call(nil)
}
// UUIDV5 generates and returns a Type-5 UUID in the standard hexadecimal string
// format.
func UUIDV5(namespace cty.Value, name cty.Value) (cty.Value, error) {
return UUIDV5Func.Call([]cty.Value{namespace, name})
}
// Base64Sha256 computes the SHA256 hash of a given string and encodes it with
// Base64.
//
// The given string is first encoded as UTF-8 and then the SHA256 algorithm is applied
// as defined in RFC 4634. The raw hash is then encoded with Base64 before returning.
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
func Base64Sha256(str cty.Value) (cty.Value, error) {
return Base64Sha256Func.Call([]cty.Value{str})
}
// Base64Sha512 computes the SHA512 hash of a given string and encodes it with Base64.
//
// The given string is first encoded as UTF-8 and then the SHA256 algorithm is applied
// as defined in RFC 4634. The raw hash is then encoded with Base64 before returning.
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
func Base64Sha512(str cty.Value) (cty.Value, error) {
return Base64Sha512Func.Call([]cty.Value{str})
}
// Bcrypt computes a hash of the given string using the Blowfish cipher,
// returning a string in the Modular Crypt Format
// usually expected in the shadow password file on many Unix systems.
func Bcrypt(str cty.Value, cost ...cty.Value) (cty.Value, error) {
args := make([]cty.Value, len(cost)+1)
args[0] = str
copy(args[1:], cost)
return BcryptFunc.Call(args)
}
// Md5 computes the MD5 hash of a given string and encodes it with hexadecimal digits.
func Md5(str cty.Value) (cty.Value, error) {
return Md5Func.Call([]cty.Value{str})
}
// RsaDecrypt decrypts an RSA-encrypted ciphertext, returning the corresponding
// cleartext.
func RsaDecrypt(ciphertext, privatekey cty.Value) (cty.Value, error) {
return RsaDecryptFunc.Call([]cty.Value{ciphertext, privatekey})
}
// Sha1 computes the SHA1 hash of a given string and encodes it with hexadecimal digits.
func Sha1(str cty.Value) (cty.Value, error) {
return Sha1Func.Call([]cty.Value{str})
}
// Sha256 computes the SHA256 hash of a given string and encodes it with hexadecimal digits.
func Sha256(str cty.Value) (cty.Value, error) {
return Sha256Func.Call([]cty.Value{str})
}
// Sha512 computes the SHA512 hash of a given string and encodes it with hexadecimal digits.
func Sha512(str cty.Value) (cty.Value, error) {
return Sha512Func.Call([]cty.Value{str})
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"fmt"
"time"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
// TimestampFunc constructs a function that returns a string representation of the current date and time.
var TimestampFunc = function.New(&function.Spec{
Params: []function.Parameter{},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.StringVal(time.Now().UTC().Format(time.RFC3339)), nil
},
})
// MakeStaticTimestampFunc constructs a function that returns a string
// representation of the date and time specified by the provided argument.
func MakeStaticTimestampFunc(static time.Time) function.Function {
return function.New(&function.Spec{
Params: []function.Parameter{},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.StringVal(static.Format(time.RFC3339)), nil
},
})
}
// TimeAddFunc constructs a function that adds a duration to a timestamp, returning a new timestamp.
var TimeAddFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "timestamp",
Type: cty.String,
},
{
Name: "duration",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
ts, err := parseTimestamp(args[0].AsString())
if err != nil {
return cty.UnknownVal(cty.String), err
}
duration, err := time.ParseDuration(args[1].AsString())
if err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.StringVal(ts.Add(duration).Format(time.RFC3339)), nil
},
})
// TimeCmpFunc is a function that compares two timestamps.
var TimeCmpFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "timestamp_a",
Type: cty.String,
},
{
Name: "timestamp_b",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.Number),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
tsA, err := parseTimestamp(args[0].AsString())
if err != nil {
return cty.UnknownVal(cty.String), function.NewArgError(0, err)
}
tsB, err := parseTimestamp(args[1].AsString())
if err != nil {
return cty.UnknownVal(cty.String), function.NewArgError(1, err)
}
switch {
case tsA.Equal(tsB):
return cty.NumberIntVal(0), nil
case tsA.Before(tsB):
return cty.NumberIntVal(-1), nil
default:
// By elimintation, tsA must be after tsB.
return cty.NumberIntVal(1), nil
}
},
})
// Timestamp returns a string representation of the current date and time.
//
// In the Terraform language, timestamps are conventionally represented as
// strings using RFC 3339 "Date and Time format" syntax, and so timestamp
// returns a string in this format.
func Timestamp() (cty.Value, error) {
return TimestampFunc.Call([]cty.Value{})
}
// TimeAdd adds a duration to a timestamp, returning a new timestamp.
//
// In the Terraform language, timestamps are conventionally represented as
// strings using RFC 3339 "Date and Time format" syntax. Timeadd requires
// the timestamp argument to be a string conforming to this syntax.
//
// `duration` is a string representation of a time difference, consisting of
// sequences of number and unit pairs, like `"1.5h"` or `1h30m`. The accepted
// units are `ns`, `us` (or `µs`), `"ms"`, `"s"`, `"m"`, and `"h"`. The first
// number may be negative to indicate a negative duration, like `"-2h5m"`.
//
// The result is a string, also in RFC 3339 format, representing the result
// of adding the given direction to the given timestamp.
func TimeAdd(timestamp cty.Value, duration cty.Value) (cty.Value, error) {
return TimeAddFunc.Call([]cty.Value{timestamp, duration})
}
// TimeCmp compares two timestamps, indicating whether they are equal or
// if one is before the other.
//
// TimeCmp considers the UTC offset of each given timestamp when making its
// decision, so for example 6:00 +0200 and 4:00 UTC are equal.
//
// In the Terraform language, timestamps are conventionally represented as
// strings using RFC 3339 "Date and Time format" syntax. TimeCmp requires
// the timestamp argument to be a string conforming to this syntax.
//
// The result is always a number between -1 and 1. -1 indicates that
// timestampA is earlier than timestampB. 1 indicates that timestampA is
// later. 0 indicates that the two timestamps represent the same instant.
func TimeCmp(timestampA, timestampB cty.Value) (cty.Value, error) {
return TimeCmpFunc.Call([]cty.Value{timestampA, timestampB})
}
func parseTimestamp(ts string) (time.Time, error) {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
//nolint: gocritic
switch err := err.(type) {
case *time.ParseError:
// If err is a time.ParseError then its string representation is not
// appropriate since it relies on details of Go's strange date format
// representation, which a caller of our functions is not expected
// to be familiar with.
//
// Therefore we do some light transformation to get a more suitable
// error that should make more sense to our callers. These are
// still not awesome error messages, but at least they refer to
// the timestamp portions by name rather than by Go's example
// values.
if err.LayoutElem == "" && err.ValueElem == "" && err.Message != "" {
// For some reason err.Message is populated with a ": " prefix
// by the time package.
return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp%s", err.Message)
}
var what string
switch err.LayoutElem {
case "2006":
what = "year"
case "01":
what = "month"
case "02":
what = "day of month"
case "15":
what = "hour"
case "04":
what = "minute"
case "05":
what = "second"
case "Z07:00":
what = "UTC offset"
case "T":
return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: missing required time introducer 'T'")
case ":", "-":
if err.ValueElem == "" {
return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: end of string where %q is expected", err.LayoutElem)
} else {
return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: found %q where %q is expected", err.ValueElem, err.LayoutElem)
}
default:
// Should never get here, because time.RFC3339 includes only the
// above portions, but since that might change in future we'll
// be robust here.
what = "timestamp segment"
}
if err.ValueElem == "" {
return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: end of string before %s", what)
} else {
return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: cannot use %q as %s", err.ValueElem, what)
}
}
return time.Time{}, err
}
return t, nil
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import "github.com/zclconf/go-cty/cty/function"
type descriptionEntry struct {
// Description is a description for the function.
Description string
// ParamDescription argument must match the number of parameters of the
// function. If the function has a VarParam then that counts as one
// parameter. The given descriptions will be assigned in order starting
// with the positional arguments in their declared order, followed by the
// variadic parameter if any.
ParamDescription []string
}
// DescriptionList is a consolidated list containing all descriptions for all
// functions available within Terraform. A function's description should point
// to the matching entry in this list.
//
// We keep this as a single list, so we can quickly review descriptions within
// a single file and copy the whole list to other projects, like
// terraform-schema.
var DescriptionList = map[string]descriptionEntry{
"abs": {
Description: "`abs` returns the absolute value of the given number. In other words, if the number is zero or positive then it is returned as-is, but if it is negative then it is multiplied by -1 to make it positive before returning it.",
ParamDescription: []string{""},
},
"abspath": {
Description: "`abspath` takes a string containing a filesystem path and converts it to an absolute path. That is, if the path is not absolute, it will be joined with the current working directory.",
ParamDescription: []string{""},
},
"alltrue": {
Description: "`alltrue` returns `true` if all elements in a given collection are `true` or `\"true\"`. It also returns `true` if the collection is empty.",
ParamDescription: []string{""},
},
"anytrue": {
Description: "`anytrue` returns `true` if any element in a given collection is `true` or `\"true\"`. It also returns `false` if the collection is empty.",
ParamDescription: []string{""},
},
"base64decode": {
Description: "`base64decode` takes a string containing a Base64 character sequence and returns the original string.",
ParamDescription: []string{""},
},
"base64encode": {
Description: "`base64encode` applies Base64 encoding to a string.",
ParamDescription: []string{""},
},
"base64gzip": {
Description: "`base64gzip` compresses a string with gzip and then encodes the result in Base64 encoding.",
ParamDescription: []string{""},
},
"base64sha256": {
Description: "`base64sha256` computes the SHA256 hash of a given string and encodes it with Base64. This is not equivalent to `base64encode(sha256(\"test\"))` since `sha256()` returns hexadecimal representation.",
ParamDescription: []string{""},
},
"base64sha512": {
Description: "`base64sha512` computes the SHA512 hash of a given string and encodes it with Base64. This is not equivalent to `base64encode(sha512(\"test\"))` since `sha512()` returns hexadecimal representation.",
ParamDescription: []string{""},
},
"basename": {
Description: "`basename` takes a string containing a filesystem path and removes all except the last portion from it.",
ParamDescription: []string{""},
},
"bcrypt": {
Description: "`bcrypt` computes a hash of the given string using the Blowfish cipher, returning a string in [the _Modular Crypt Format_](https://passlib.readthedocs.io/en/stable/modular_crypt_format.html) usually expected in the shadow password file on many Unix systems.",
ParamDescription: []string{
"",
"The `cost` argument is optional and will default to 10 if unspecified.",
},
},
"can": {
Description: "`can` evaluates the given expression and returns a boolean value indicating whether the expression produced a result without any errors.",
ParamDescription: []string{""},
},
"ceil": {
Description: "`ceil` returns the closest whole number that is greater than or equal to the given value, which may be a fraction.",
ParamDescription: []string{""},
},
"chomp": {
Description: "`chomp` removes newline characters at the end of a string.",
ParamDescription: []string{""},
},
"chunklist": {
Description: "`chunklist` splits a single list into fixed-size chunks, returning a list of lists.",
ParamDescription: []string{
"",
"The maximum length of each chunk. All but the last element of the result is guaranteed to be of exactly this size.",
},
},
"cidrhost": {
Description: "`cidrhost` calculates a full host IP address for a given host number within a given IP network address prefix.",
ParamDescription: []string{
"`prefix` must be given in CIDR notation, as defined in [RFC 4632 section 3.1](https://tools.ietf.org/html/rfc4632#section-3.1).",
"`hostnum` is a whole number that can be represented as a binary integer with no more than the number of digits remaining in the address after the given prefix.",
},
},
"cidrnetmask": {
Description: "`cidrnetmask` converts an IPv4 address prefix given in CIDR notation into a subnet mask address.",
ParamDescription: []string{
"`prefix` must be given in CIDR notation, as defined in [RFC 4632 section 3.1](https://tools.ietf.org/html/rfc4632#section-3.1).",
},
},
"cidrsubnet": {
Description: "`cidrsubnet` calculates a subnet address within given IP network address prefix.",
ParamDescription: []string{
"`prefix` must be given in CIDR notation, as defined in [RFC 4632 section 3.1](https://tools.ietf.org/html/rfc4632#section-3.1).",
"`newbits` is the number of additional bits with which to extend the prefix.",
"`netnum` is a whole number that can be represented as a binary integer with no more than `newbits` binary digits, which will be used to populate the additional bits added to the prefix.",
},
},
"cidrsubnets": {
Description: "`cidrsubnets` calculates a sequence of consecutive IP address ranges within a particular CIDR prefix.",
ParamDescription: []string{
"`prefix` must be given in CIDR notation, as defined in [RFC 4632 section 3.1](https://tools.ietf.org/html/rfc4632#section-3.1).",
"",
},
},
"coalesce": {
Description: "`coalesce` takes any number of arguments and returns the first one that isn't null or an empty string.",
ParamDescription: []string{""},
},
"coalescelist": {
Description: "`coalescelist` takes any number of list arguments and returns the first one that isn't empty.",
ParamDescription: []string{
"List or tuple values to test in the given order.",
},
},
"compact": {
Description: "`compact` takes a list of strings and returns a new list with any empty string elements removed.",
ParamDescription: []string{""},
},
"concat": {
Description: "`concat` takes two or more lists and combines them into a single list.",
ParamDescription: []string{""},
},
"contains": {
Description: "`contains` determines whether a given list or set contains a given single value as one of its elements.",
ParamDescription: []string{"", ""},
},
"csvdecode": {
Description: "`csvdecode` decodes a string containing CSV-formatted data and produces a list of maps representing that data.",
ParamDescription: []string{""},
},
"dirname": {
Description: "`dirname` takes a string containing a filesystem path and removes the last portion from it.",
ParamDescription: []string{""},
},
"distinct": {
Description: "`distinct` takes a list and returns a new list with any duplicate elements removed.",
ParamDescription: []string{""},
},
"element": {
Description: "`element` retrieves a single element from a list.",
ParamDescription: []string{"", ""},
},
"endswith": {
Description: "`endswith` takes two values: a string to check and a suffix string. The function returns true if the first string ends with that exact suffix.",
ParamDescription: []string{"", ""},
},
"file": {
Description: "`file` reads the contents of a file at the given path and returns them as a string.",
ParamDescription: []string{""},
},
"filebase64": {
Description: "`filebase64` reads the contents of a file at the given path and returns them as a base64-encoded string.",
ParamDescription: []string{""},
},
"filebase64sha256": {
Description: "`filebase64sha256` is a variant of `base64sha256` that hashes the contents of a given file rather than a literal string.",
ParamDescription: []string{""},
},
"filebase64sha512": {
Description: "`filebase64sha512` is a variant of `base64sha512` that hashes the contents of a given file rather than a literal string.",
ParamDescription: []string{""},
},
"fileexists": {
Description: "`fileexists` determines whether a file exists at a given path.",
ParamDescription: []string{""},
},
"filemd5": {
Description: "`filemd5` is a variant of `md5` that hashes the contents of a given file rather than a literal string.",
ParamDescription: []string{""},
},
"fileset": {
Description: "`fileset` enumerates a set of regular file names given a path and pattern. The path is automatically removed from the resulting set of file names and any result still containing path separators always returns forward slash (`/`) as the path separator for cross-system compatibility.",
ParamDescription: []string{"", ""},
},
"filesha1": {
Description: "`filesha1` is a variant of `sha1` that hashes the contents of a given file rather than a literal string.",
ParamDescription: []string{""},
},
"filesha256": {
Description: "`filesha256` is a variant of `sha256` that hashes the contents of a given file rather than a literal string.",
ParamDescription: []string{""},
},
"filesha512": {
Description: "`filesha512` is a variant of `sha512` that hashes the contents of a given file rather than a literal string.",
ParamDescription: []string{""},
},
"flatten": {
Description: "`flatten` takes a list and replaces any elements that are lists with a flattened sequence of the list contents.",
ParamDescription: []string{""},
},
"floor": {
Description: "`floor` returns the closest whole number that is less than or equal to the given value, which may be a fraction.",
ParamDescription: []string{""},
},
"format": {
Description: "The `format` function produces a string by formatting a number of other values according to a specification string. It is similar to the `printf` function in C, and other similar functions in other programming languages.",
ParamDescription: []string{"", ""},
},
"formatdate": {
Description: "`formatdate` converts a timestamp into a different time format.",
ParamDescription: []string{"", ""},
},
"formatlist": {
Description: "`formatlist` produces a list of strings by formatting a number of other values according to a specification string.",
ParamDescription: []string{"", ""},
},
"indent": {
Description: "`indent` adds a given number of spaces to the beginnings of all but the first line in a given multi-line string.",
ParamDescription: []string{
"Number of spaces to add after each newline character.",
"",
},
},
"index": {
Description: "`index` finds the element index for a given value in a list.",
ParamDescription: []string{"", ""},
},
"join": {
Description: "`join` produces a string by concatenating together all elements of a given list of strings with the given delimiter.",
ParamDescription: []string{
"Delimiter to insert between the given strings.",
"One or more lists of strings to join.",
},
},
"jsondecode": {
Description: "`jsondecode` interprets a given string as JSON, returning a representation of the result of decoding that string.",
ParamDescription: []string{""},
},
"jsonencode": {
Description: "`jsonencode` encodes a given value to a string using JSON syntax.",
ParamDescription: []string{""},
},
"keys": {
Description: "`keys` takes a map and returns a list containing the keys from that map.",
ParamDescription: []string{
"The map to extract keys from. May instead be an object-typed value, in which case the result is a tuple of the object attributes.",
},
},
"length": {
Description: "`length` determines the length of a given list, map, or string.",
ParamDescription: []string{""},
},
"list": {
Description: "The `list` function is no longer available. Prior to Terraform v0.12 it was the only available syntax for writing a literal list inside an expression, but Terraform v0.12 introduced a new first-class syntax.",
ParamDescription: []string{""},
},
"log": {
Description: "`log` returns the logarithm of a given number in a given base.",
ParamDescription: []string{"", ""},
},
"lookup": {
Description: "`lookup` retrieves the value of a single element from a map, given its key. If the given key does not exist, the given default value is returned instead.",
ParamDescription: []string{"", "", ""},
},
"lower": {
Description: "`lower` converts all cased letters in the given string to lowercase.",
ParamDescription: []string{""},
},
"map": {
Description: "The `map` function is no longer available. Prior to Terraform v0.12 it was the only available syntax for writing a literal map inside an expression, but Terraform v0.12 introduced a new first-class syntax.",
ParamDescription: []string{""},
},
"matchkeys": {
Description: "`matchkeys` constructs a new list by taking a subset of elements from one list whose indexes match the corresponding indexes of values in another list.",
ParamDescription: []string{"", "", ""},
},
"max": {
Description: "`max` takes one or more numbers and returns the greatest number from the set.",
ParamDescription: []string{""},
},
"md5": {
Description: "`md5` computes the MD5 hash of a given string and encodes it with hexadecimal digits.",
ParamDescription: []string{""},
},
"merge": {
Description: "`merge` takes an arbitrary number of maps or objects, and returns a single map or object that contains a merged set of elements from all arguments.",
ParamDescription: []string{""},
},
"min": {
Description: "`min` takes one or more numbers and returns the smallest number from the set.",
ParamDescription: []string{""},
},
"nonsensitive": {
Description: "`nonsensitive` takes a sensitive value and returns a copy of that value with the sensitive marking removed, thereby exposing the sensitive value.",
ParamDescription: []string{""},
},
"one": {
Description: "`one` takes a list, set, or tuple value with either zero or one elements. If the collection is empty, `one` returns `null`. Otherwise, `one` returns the first element. If there are two or more elements then `one` will return an error.",
ParamDescription: []string{""},
},
"parseint": {
Description: "`parseint` parses the given string as a representation of an integer in the specified base and returns the resulting number. The base must be between 2 and 62 inclusive.",
ParamDescription: []string{"", ""},
},
"pathexpand": {
Description: "`pathexpand` takes a filesystem path that might begin with a `~` segment, and if so it replaces that segment with the current user's home directory path.",
ParamDescription: []string{""},
},
"pow": {
Description: "`pow` calculates an exponent, by raising its first argument to the power of the second argument.",
ParamDescription: []string{"", ""},
},
"range": {
Description: "`range` generates a list of numbers using a start value, a limit value, and a step value.",
ParamDescription: []string{""},
},
"regex": {
Description: "`regex` applies a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) to a string and returns the matching substrings.",
ParamDescription: []string{"", ""},
},
"regexall": {
Description: "`regexall` applies a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) to a string and returns a list of all matches.",
ParamDescription: []string{"", ""},
},
"replace": {
Description: "`replace` searches a given string for another given substring, and replaces each occurrence with a given replacement string.",
ParamDescription: []string{"", "", ""},
},
"reverse": {
Description: "`reverse` takes a sequence and produces a new sequence of the same length with all of the same elements as the given sequence but in reverse order.",
ParamDescription: []string{""},
},
"rsadecrypt": {
Description: "`rsadecrypt` decrypts an RSA-encrypted ciphertext, returning the corresponding cleartext.",
ParamDescription: []string{"", ""},
},
"sensitive": {
Description: "`sensitive` takes any value and returns a copy of it marked so that Terraform will treat it as sensitive, with the same meaning and behavior as for [sensitive input variables](/language/values/variables#suppressing-values-in-cli-output).",
ParamDescription: []string{""},
},
"setintersection": {
Description: "The `setintersection` function takes multiple sets and produces a single set containing only the elements that all of the given sets have in common. In other words, it computes the [intersection](https://en.wikipedia.org/wiki/Intersection_\\(set_theory\\)) of the sets.",
ParamDescription: []string{"", ""},
},
"setproduct": {
Description: "The `setproduct` function finds all of the possible combinations of elements from all of the given sets by computing the [Cartesian product](https://en.wikipedia.org/wiki/Cartesian_product).",
ParamDescription: []string{
"The sets to consider. Also accepts lists and tuples, and if all arguments are of list or tuple type then the result will preserve the input ordering",
},
},
"setsubtract": {
Description: "The `setsubtract` function returns a new set containing the elements from the first set that are not present in the second set. In other words, it computes the [relative complement](https://en.wikipedia.org/wiki/Complement_\\(set_theory\\)#Relative_complement) of the second set.",
ParamDescription: []string{"", ""},
},
"setunion": {
Description: "The `setunion` function takes multiple sets and produces a single set containing the elements from all of the given sets. In other words, it computes the [union](https://en.wikipedia.org/wiki/Union_\\(set_theory\\)) of the sets.",
ParamDescription: []string{"", ""},
},
"sha1": {
Description: "`sha1` computes the SHA1 hash of a given string and encodes it with hexadecimal digits.",
ParamDescription: []string{""},
},
"sha256": {
Description: "`sha256` computes the SHA256 hash of a given string and encodes it with hexadecimal digits.",
ParamDescription: []string{""},
},
"sha512": {
Description: "`sha512` computes the SHA512 hash of a given string and encodes it with hexadecimal digits.",
ParamDescription: []string{""},
},
"signum": {
Description: "`signum` determines the sign of a number, returning a number between -1 and 1 to represent the sign.",
ParamDescription: []string{""},
},
"slice": {
Description: "`slice` extracts some consecutive elements from within a list.",
ParamDescription: []string{"", "", ""},
},
"sort": {
Description: "`sort` takes a list of strings and returns a new list with those strings sorted lexicographically.",
ParamDescription: []string{""},
},
"split": {
Description: "`split` produces a list by dividing a given string at all occurrences of a given separator.",
ParamDescription: []string{"", ""},
},
"startswith": {
Description: "`startswith` takes two values: a string to check and a prefix string. The function returns true if the string begins with that exact prefix.",
ParamDescription: []string{"", ""},
},
"strcontains": {
Description: "`strcontains` takes two values: a string to check and an expected substring. The function returns true if the string has the substring contained within it.",
ParamDescription: []string{"", ""},
},
"strrev": {
Description: "`strrev` reverses the characters in a string. Note that the characters are treated as _Unicode characters_ (in technical terms, Unicode [grapheme cluster boundaries](https://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries) are respected).",
ParamDescription: []string{""},
},
"substr": {
Description: "`substr` extracts a substring from a given string by offset and (maximum) length.",
ParamDescription: []string{"", "", ""},
},
"sum": {
Description: "`sum` takes a list or set of numbers and returns the sum of those numbers.",
ParamDescription: []string{""},
},
"templatefile": {
Description: "`templatefile` reads the file at the given path and renders its content as a template using a supplied set of template variables.",
ParamDescription: []string{"", ""},
},
"textdecodebase64": {
Description: "`textdecodebase64` function decodes a string that was previously Base64-encoded, and then interprets the result as characters in a specified character encoding.",
ParamDescription: []string{"", ""},
},
"textencodebase64": {
Description: "`textencodebase64` encodes the unicode characters in a given string using a specified character encoding, returning the result base64 encoded because Terraform language strings are always sequences of unicode characters.",
ParamDescription: []string{"", ""},
},
"timeadd": {
Description: "`timeadd` adds a duration to a timestamp, returning a new timestamp.",
ParamDescription: []string{"", ""},
},
"timecmp": {
Description: "`timecmp` compares two timestamps and returns a number that represents the ordering of the instants those timestamps represent.",
ParamDescription: []string{"", ""},
},
"timestamp": {
Description: "`timestamp` returns a UTC timestamp string in [RFC 3339](https://tools.ietf.org/html/rfc3339) format.",
ParamDescription: []string{},
},
"plantimestamp": {
Description: "`plantimestamp` returns a UTC timestamp string in [RFC 3339](https://tools.ietf.org/html/rfc3339) format, fixed to a constant time representing the time of the plan.",
ParamDescription: []string{},
},
"title": {
Description: "`title` converts the first letter of each word in the given string to uppercase.",
ParamDescription: []string{""},
},
"tobool": {
Description: "`tobool` converts its argument to a boolean value.",
ParamDescription: []string{""},
},
"tolist": {
Description: "`tolist` converts its argument to a list value.",
ParamDescription: []string{""},
},
"tomap": {
Description: "`tomap` converts its argument to a map value.",
ParamDescription: []string{""},
},
"tonumber": {
Description: "`tonumber` converts its argument to a number value.",
ParamDescription: []string{""},
},
"toset": {
Description: "`toset` converts its argument to a set value.",
ParamDescription: []string{""},
},
"tostring": {
Description: "`tostring` converts its argument to a string value.",
ParamDescription: []string{""},
},
"transpose": {
Description: "`transpose` takes a map of lists of strings and swaps the keys and values to produce a new map of lists of strings.",
ParamDescription: []string{""},
},
"trim": {
Description: "`trim` removes the specified set of characters from the start and end of the given string.",
ParamDescription: []string{
"",
"A string containing all of the characters to trim. Each character is taken separately, so the order of characters is insignificant.",
},
},
"trimprefix": {
Description: "`trimprefix` removes the specified prefix from the start of the given string. If the string does not start with the prefix, the string is returned unchanged.",
ParamDescription: []string{"", ""},
},
"trimspace": {
Description: "`trimspace` removes any space characters from the start and end of the given string.",
ParamDescription: []string{""},
},
"trimsuffix": {
Description: "`trimsuffix` removes the specified suffix from the end of the given string.",
ParamDescription: []string{"", ""},
},
"try": {
Description: "`try` evaluates all of its argument expressions in turn and returns the result of the first one that does not produce any errors.",
ParamDescription: []string{""},
},
"type": {
Description: "`type` returns the type of a given value.",
ParamDescription: []string{""},
},
"upper": {
Description: "`upper` converts all cased letters in the given string to uppercase.",
ParamDescription: []string{""},
},
"urlencode": {
Description: "`urlencode` applies URL encoding to a given string.",
ParamDescription: []string{""},
},
"uuid": {
Description: "`uuid` generates a unique identifier string.",
ParamDescription: []string{},
},
"uuidv5": {
Description: "`uuidv5` generates a _name-based_ UUID, as described in [RFC 4122 section 4.3](https://tools.ietf.org/html/rfc4122#section-4.3), also known as a \"version 5\" UUID.",
ParamDescription: []string{"", ""},
},
"values": {
Description: "`values` takes a map and returns a list containing the values of the elements in that map.",
ParamDescription: []string{""},
},
"yamldecode": {
Description: "`yamldecode` parses a string as a subset of YAML, and produces a representation of its value.",
ParamDescription: []string{""},
},
"yamlencode": {
Description: "`yamlencode` encodes a given value to a string using [YAML 1.2](https://yaml.org/spec/1.2/spec.html) block syntax.",
ParamDescription: []string{""},
},
"zipmap": {
Description: "`zipmap` constructs a map from a list of keys and a corresponding list of values.",
ParamDescription: []string{"", ""},
},
}
// WithDescription looks up the description for a given function and uses
// go-cty's WithNewDescriptions to replace the function's description and
// parameter descriptions.
func WithDescription(name string, f function.Function) function.Function {
desc, ok := DescriptionList[name]
if !ok {
return f
}
// Will panic if ParamDescription doesn't match the number of parameters
// the function expects
return f.WithNewDescriptions(desc.Description, desc.ParamDescription)
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"log"
"net/url"
"unicode/utf8"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"golang.org/x/text/encoding/ianaindex"
)
// Base64DecodeFunc constructs a function that decodes a string containing a base64 sequence.
var Base64DecodeFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
AllowMarked: true,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
s := args[0].AsString()
sDec, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("failed to decode base64 data %q", s)
}
if !utf8.Valid(sDec) {
log.Printf("[DEBUG] the result of decoding the provided string is not valid UTF-8: %s", sDec)
return cty.UnknownVal(cty.String), fmt.Errorf("the result of decoding the provided string is not valid UTF-8")
}
return cty.StringVal(string(sDec)), nil
},
})
// Base64EncodeFunc constructs a function that encodes a string to a base64 sequence.
var Base64EncodeFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.StringVal(base64.StdEncoding.EncodeToString([]byte(args[0].AsString()))), nil
},
})
// TextEncodeBase64Func constructs a function that encodes a string to a target encoding and then to a base64 sequence.
var TextEncodeBase64Func = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "string",
Type: cty.String,
},
{
Name: "encoding",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
encoding, err := ianaindex.IANA.Encoding(args[1].AsString())
if err != nil || encoding == nil {
return cty.UnknownVal(cty.String), function.NewArgErrorf(1, "%q is not a supported IANA encoding name or alias in this Terraform version", args[1].AsString())
}
encName, err := ianaindex.IANA.Name(encoding)
if err != nil { // would be weird, since we just read this encoding out
encName = args[1].AsString()
}
encoder := encoding.NewEncoder()
encodedInput, err := encoder.Bytes([]byte(args[0].AsString()))
if err != nil {
// The string representations of "err" disclose implementation
// details of the underlying library, and the main error we might
// like to return a special message for is unexported as
// golang.org/x/text/encoding/internal.RepertoireError, so this
// is just a generic error message for now.
//
// We also don't include the string itself in the message because
// it can typically be very large, contain newline characters,
// etc.
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given string contains characters that cannot be represented in %s", encName)
}
return cty.StringVal(base64.StdEncoding.EncodeToString(encodedInput)), nil
},
})
// TextDecodeBase64Func constructs a function that decodes a base64 sequence to a target encoding.
var TextDecodeBase64Func = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "source",
Type: cty.String,
},
{
Name: "encoding",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
encoding, err := ianaindex.IANA.Encoding(args[1].AsString())
if err != nil || encoding == nil {
return cty.UnknownVal(cty.String), function.NewArgErrorf(1, "%q is not a supported IANA encoding name or alias in this Terraform version", args[1].AsString())
}
encName, err := ianaindex.IANA.Name(encoding)
if err != nil { // would be weird, since we just read this encoding out
encName = args[1].AsString()
}
s := args[0].AsString()
sDec, err := base64.StdEncoding.DecodeString(s)
if err != nil {
switch err := err.(type) {
case base64.CorruptInputError:
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given value is has an invalid base64 symbol at offset %d", int(err))
default:
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid source string: %w", err)
}
}
decoder := encoding.NewDecoder()
decoded, err := decoder.Bytes(sDec)
if err != nil || bytes.ContainsRune(decoded, '�') {
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given string contains symbols that are not defined for %s", encName)
}
return cty.StringVal(string(decoded)), nil
},
})
// Base64GzipFunc constructs a function that compresses a string with gzip and then encodes the result in
// Base64 encoding.
var Base64GzipFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
s := args[0].AsString()
var b bytes.Buffer
gz := gzip.NewWriter(&b)
if _, err := gz.Write([]byte(s)); err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("failed to write gzip raw data: %w", err)
}
if err := gz.Flush(); err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("failed to flush gzip writer: %w", err)
}
if err := gz.Close(); err != nil {
return cty.UnknownVal(cty.String), fmt.Errorf("failed to close gzip writer: %w", err)
}
return cty.StringVal(base64.StdEncoding.EncodeToString(b.Bytes())), nil
},
})
// URLEncodeFunc constructs a function that applies URL encoding to a given string.
var URLEncodeFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.StringVal(url.QueryEscape(args[0].AsString())), nil
},
})
// Base64Decode decodes a string containing a base64 sequence.
//
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
//
// Strings in the Terraform language are sequences of unicode characters rather
// than bytes, so this function will also interpret the resulting bytes as
// UTF-8. If the bytes after Base64 decoding are _not_ valid UTF-8, this function
// produces an error.
func Base64Decode(str cty.Value) (cty.Value, error) {
return Base64DecodeFunc.Call([]cty.Value{str})
}
// Base64Encode applies Base64 encoding to a string.
//
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
//
// Strings in the Terraform language are sequences of unicode characters rather
// than bytes, so this function will first encode the characters from the string
// as UTF-8, and then apply Base64 encoding to the result.
func Base64Encode(str cty.Value) (cty.Value, error) {
return Base64EncodeFunc.Call([]cty.Value{str})
}
// Base64Gzip compresses a string with gzip and then encodes the result in
// Base64 encoding.
//
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
//
// Strings in the Terraform language are sequences of unicode characters rather
// than bytes, so this function will first encode the characters from the string
// as UTF-8, then apply gzip compression, and then finally apply Base64 encoding.
func Base64Gzip(str cty.Value) (cty.Value, error) {
return Base64GzipFunc.Call([]cty.Value{str})
}
// URLEncode applies URL encoding to a given string.
//
// This function identifies characters in the given string that would have a
// special meaning when included as a query string argument in a URL and
// escapes them using RFC 3986 "percent encoding".
//
// If the given string contains non-ASCII characters, these are first encoded as
// UTF-8 and then percent encoding is applied separately to each UTF-8 byte.
func URLEncode(str cty.Value) (cty.Value, error) {
return URLEncodeFunc.Call([]cty.Value{str})
}
// TextEncodeBase64 applies Base64 encoding to a string that was encoded before with a target encoding.
//
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
//
// First step is to apply the target IANA encoding (e.g. UTF-16LE).
// Strings in the Terraform language are sequences of unicode characters rather
// than bytes, so this function will first encode the characters from the string
// as UTF-8, and then apply Base64 encoding to the result.
func TextEncodeBase64(str, enc cty.Value) (cty.Value, error) {
return TextEncodeBase64Func.Call([]cty.Value{str, enc})
}
// TextDecodeBase64 decodes a string containing a base64 sequence whereas a specific encoding of the string is expected.
//
// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4.
//
// Strings in the Terraform language are sequences of unicode characters rather
// than bytes, so this function will also interpret the resulting bytes as
// the target encoding.
func TextDecodeBase64(str, enc cty.Value) (cty.Value, error) {
return TextDecodeBase64Func.Call([]cty.Value{str, enc})
}
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// IP address manipulations
//
// IPv4 addresses are 4 bytes; IPv6 addresses are 16 bytes.
// An IPv4 address can be converted to an IPv6 address by
// adding a canonical prefix (10 zeros, 2 0xFFs).
// This library accepts either size of byte slice but always
// returns 16-byte addresses.
package ipaddr
import (
stdnet "net"
)
//
// Lean on the standard net lib as much as possible.
//
type (
IP = stdnet.IP
IPNet = stdnet.IPNet
ParseError = stdnet.ParseError
)
const (
IPv4len = stdnet.IPv4len
IPv6len = stdnet.IPv6len
)
var (
CIDRMask = stdnet.CIDRMask
IPv4 = stdnet.IPv4
)
// Parse IPv4 address (d.d.d.d).
func parseIPv4(s string) IP {
var p [IPv4len]byte
for i := 0; i < IPv4len; i++ {
if len(s) == 0 {
// Missing octets.
return nil
}
if i > 0 {
if s[0] != '.' {
return nil
}
s = s[1:]
}
n, c, ok := dtoi(s)
if !ok || n > 0xFF {
return nil
}
//
// NOTE: This correct check was added for go-1.17, but is a
// backwards-incompatible change for Terraform users, who might have
// already written modules with leading zeroes.
//
// if c > 1 && s[0] == '0' {
// // Reject non-zero components with leading zeroes.
// return nil
//}
s = s[c:]
p[i] = byte(n)
}
if len(s) != 0 {
return nil
}
return IPv4(p[0], p[1], p[2], p[3])
}
// parseIPv6 parses s as a literal IPv6 address described in RFC 4291
// and RFC 5952.
func parseIPv6(s string) (ip IP) {
ip = make(IP, IPv6len)
ellipsis := -1 // position of ellipsis in ip
// Might have leading ellipsis
if len(s) >= 2 && s[0] == ':' && s[1] == ':' {
ellipsis = 0
s = s[2:]
// Might be only ellipsis
if len(s) == 0 {
return ip
}
}
// Loop, parsing hex numbers followed by colon.
i := 0
for i < IPv6len {
// Hex number.
n, c, ok := xtoi(s)
if !ok || n > 0xFFFF {
return nil
}
// If followed by dot, might be in trailing IPv4.
if c < len(s) && s[c] == '.' {
if ellipsis < 0 && i != IPv6len-IPv4len {
// Not the right place.
return nil
}
if i+IPv4len > IPv6len {
// Not enough room.
return nil
}
ip4 := parseIPv4(s)
if ip4 == nil {
return nil
}
ip[i] = ip4[12]
ip[i+1] = ip4[13]
ip[i+2] = ip4[14]
ip[i+3] = ip4[15]
s = ""
i += IPv4len
break
}
// Save this 16-bit chunk.
ip[i] = byte(n >> 8)
ip[i+1] = byte(n)
i += 2
// Stop at end of string.
s = s[c:]
if len(s) == 0 {
break
}
// Otherwise must be followed by colon and more.
if s[0] != ':' || len(s) == 1 {
return nil
}
s = s[1:]
// Look for ellipsis.
if s[0] == ':' {
if ellipsis >= 0 { // already have one
return nil
}
ellipsis = i
s = s[1:]
if len(s) == 0 { // can be at end
break
}
}
}
// Must have used entire string.
if len(s) != 0 {
return nil
}
// If didn't parse enough, expand ellipsis.
if i < IPv6len {
if ellipsis < 0 {
return nil
}
n := IPv6len - i
for j := i - 1; j >= ellipsis; j-- {
ip[j+n] = ip[j]
}
for j := ellipsis + n - 1; j >= ellipsis; j-- {
ip[j] = 0
}
} else if ellipsis >= 0 {
// Ellipsis must represent at least one 0 group.
return nil
}
return ip
}
// ParseIP parses s as an IP address, returning the result.
// The string s can be in IPv4 dotted decimal ("192.0.2.1"), IPv6
// ("2001:db8::68"), or IPv4-mapped IPv6 ("::ffff:192.0.2.1") form.
// If s is not a valid textual representation of an IP address,
// ParseIP returns nil.
func ParseIP(s string) IP {
for i := 0; i < len(s); i++ {
switch s[i] {
case '.':
return parseIPv4(s)
case ':':
return parseIPv6(s)
}
}
return nil
}
// ParseCIDR parses s as a CIDR notation IP address and prefix length,
// like "192.0.2.0/24" or "2001:db8::/32", as defined in
// RFC 4632 and RFC 4291.
//
// It returns the IP address and the network implied by the IP and
// prefix length.
// For example, ParseCIDR("192.0.2.1/24") returns the IP address
// 192.0.2.1 and the network 192.0.2.0/24.
func ParseCIDR(s string) (IP, *IPNet, error) {
i := indexByteString(s, '/')
if i < 0 {
return nil, nil, &ParseError{Type: "CIDR address", Text: s}
}
addr, mask := s[:i], s[i+1:]
iplen := IPv4len
ip := parseIPv4(addr)
if ip == nil {
iplen = IPv6len
ip = parseIPv6(addr)
}
n, i, ok := dtoi(mask)
if ip == nil || !ok || i != len(mask) || n < 0 || n > 8*iplen {
return nil, nil, &ParseError{Type: "CIDR address", Text: s}
}
m := CIDRMask(n, 8*iplen)
return ip, &IPNet{IP: ip.Mask(m), Mask: m}, nil
}
// This is copied from go/src/internal/bytealg, which includes versions
// optimized for various platforms. Those optimizations are elided here so we
// don't have to maintain them.
func indexByteString(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Simple file i/o and string manipulation, to avoid
// depending on strconv and bufio and strings.
package ipaddr
// Bigger than we need, not too big to worry about overflow.
const big = 0xFFFFFF
// Decimal to integer.
// Returns number, characters consumed, success.
func dtoi(s string) (n int, i int, ok bool) {
n = 0
for i = 0; i < len(s) && '0' <= s[i] && s[i] <= '9'; i++ {
n = n*10 + int(s[i]-'0')
if n >= big {
return big, i, false
}
}
if i == 0 {
return 0, 0, false
}
return n, i, true
}
// Hexadecimal to integer.
// Returns number, characters consumed, success.
func xtoi(s string) (n int, i int, ok bool) {
n = 0
for i = 0; i < len(s); i++ {
//nolint: gocritic
if '0' <= s[i] && s[i] <= '9' {
n *= 16
n += int(s[i] - '0')
} else if 'a' <= s[i] && s[i] <= 'f' {
n *= 16
n += int(s[i]-'a') + 10
} else if 'A' <= s[i] && s[i] <= 'F' {
n *= 16
n += int(s[i]-'A') + 10
} else {
break
}
if n >= big {
return 0, i, false
}
}
if i == 0 {
return 0, i, false
}
return n, i, true
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"math"
"math/big"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/gocty"
)
// LogFunc contructs a function that returns the logarithm of a given number in a given base.
var LogFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "num",
Type: cty.Number,
},
{
Name: "base",
Type: cty.Number,
},
},
Type: function.StaticReturnType(cty.Number),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var num float64
if err := gocty.FromCtyValue(args[0], &num); err != nil {
return cty.UnknownVal(cty.String), err
}
var base float64
if err := gocty.FromCtyValue(args[1], &base); err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.NumberFloatVal(math.Log(num) / math.Log(base)), nil
},
})
// PowFunc contructs a function that returns the logarithm of a given number in a given base.
var PowFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "num",
Type: cty.Number,
},
{
Name: "power",
Type: cty.Number,
},
},
Type: function.StaticReturnType(cty.Number),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var num float64
if err := gocty.FromCtyValue(args[0], &num); err != nil {
return cty.UnknownVal(cty.String), err
}
var power float64
if err := gocty.FromCtyValue(args[1], &power); err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.NumberFloatVal(math.Pow(num, power)), nil
},
})
// SignumFunc contructs a function that returns the closest whole number greater
// than or equal to the given value.
var SignumFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "num",
Type: cty.Number,
},
},
Type: function.StaticReturnType(cty.Number),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
var num int
if err := gocty.FromCtyValue(args[0], &num); err != nil {
return cty.UnknownVal(cty.String), err
}
switch {
case num < 0:
return cty.NumberIntVal(-1), nil
case num > 0:
return cty.NumberIntVal(+1), nil
default:
return cty.NumberIntVal(0), nil
}
},
})
// ParseIntFunc contructs a function that parses a string argument and returns an integer of the specified base.
var ParseIntFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "number",
Type: cty.DynamicPseudoType,
},
{
Name: "base",
Type: cty.Number,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
if !args[0].Type().Equals(cty.String) {
return cty.Number, function.NewArgErrorf(0, "first argument must be a string, not %s", args[0].Type().FriendlyName())
}
return cty.Number, nil
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
var numstr string
var base int
var err error
numArg := args[0]
if err = gocty.FromCtyValue(numArg, &numstr); err != nil {
return cty.UnknownVal(cty.String), function.NewArgError(0, err)
}
baseArg := args[1]
if err = gocty.FromCtyValue(baseArg, &base); err != nil {
return cty.UnknownVal(cty.Number), function.NewArgError(1, err)
}
if base < 2 || base > 62 {
return cty.UnknownVal(cty.Number), function.NewArgErrorf(
1,
"base must be a whole number between 2 and 62 inclusive",
)
}
num, ok := (&big.Int{}).SetString(numstr, base)
if !ok {
return cty.UnknownVal(cty.Number), function.NewArgErrorf(
0,
"cannot parse %q as a base %d integer", numstr, base)
}
parsedNum := cty.NumberVal((&big.Float{}).SetInt(num))
return parsedNum, nil
},
})
// Log returns returns the logarithm of a given number in a given base.
func Log(num, base cty.Value) (cty.Value, error) {
return LogFunc.Call([]cty.Value{num, base})
}
// Pow returns the logarithm of a given number in a given base.
func Pow(num, power cty.Value) (cty.Value, error) {
return PowFunc.Call([]cty.Value{num, power})
}
// Signum determines the sign of a number, returning a number between -1 and
// 1 to represent the sign.
func Signum(num cty.Value) (cty.Value, error) {
return SignumFunc.Call([]cty.Value{num})
}
// ParseInt parses a string argument and returns an integer of the specified base.
func ParseInt(num cty.Value, base cty.Value) (cty.Value, error) {
return ParseIntFunc.Call([]cty.Value{num, base})
}
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package funcs
import (
"regexp"
"strings"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
// StartsWithFunc constructs a function that checks if a string starts with
// a specific prefix using strings.HasPrefix.
var StartsWithFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
{
Name: "prefix",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.Bool),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
str := args[0].AsString()
prefix := args[1].AsString()
if strings.HasPrefix(str, prefix) {
return cty.True, nil
}
return cty.False, nil
},
})
// EndsWithFunc constructs a function that checks if a string ends with
// a specific suffix using strings.HasSuffix.
var EndsWithFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
{
Name: "suffix",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.Bool),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
str := args[0].AsString()
suffix := args[1].AsString()
if strings.HasSuffix(str, suffix) {
return cty.True, nil
}
return cty.False, nil
},
})
// ReplaceFunc constructs a function that searches a given string for another
// given substring, and replaces each occurrence with a given replacement string.
var ReplaceFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
{
Name: "substr",
Type: cty.String,
},
{
Name: "replace",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
str := args[0].AsString()
substr := args[1].AsString()
replace := args[2].AsString()
// We search/replace using a regexp if the string is surrounded
// in forward slashes.
if len(substr) > 1 && substr[0] == '/' && substr[len(substr)-1] == '/' {
re, err := regexp.Compile(substr[1 : len(substr)-1])
if err != nil {
return cty.UnknownVal(cty.String), err
}
return cty.StringVal(re.ReplaceAllString(str, replace)), nil
}
return cty.StringVal(strings.ReplaceAll(str, substr, replace)), nil
},
})
// Replace searches a given string for another given substring,
// and replaces all occurrences with a given replacement string.
func Replace(str, substr, replace cty.Value) (cty.Value, error) {
return ReplaceFunc.Call([]cty.Value{str, substr, replace})
}
// StrContainsFunc searches a given string for another given substring,
// if found the function returns true, otherwise returns false.
var StrContainsFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
{
Name: "substr",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.Bool),
Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) {
str := args[0].AsString()
substr := args[1].AsString()
if strings.Contains(str, substr) {
return cty.True, nil
}
return cty.False, nil
},
})