mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
* chore(docs): update docs re workspace tag default values * chore(coderdenttest): use random name instead of t.Name() in newExternalProvisionerDaemon * fix(provisioner/terraform/tfparse): allow empty values in coder_workspace_tag defaults
602 lines
19 KiB
Go
602 lines
19 KiB
Go
package tfparse
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/coder/coder/v2/archive"
|
|
"github.com/coder/coder/v2/provisionersdk"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
|
|
"github.com/hashicorp/hcl/v2"
|
|
"github.com/hashicorp/hcl/v2/hclparse"
|
|
"github.com/hashicorp/hcl/v2/hclsyntax"
|
|
"github.com/hashicorp/terraform-config-inspect/tfconfig"
|
|
"github.com/zclconf/go-cty/cty"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
)
|
|
|
|
// NOTE: This is duplicated from coderd but we can't import it here without
|
|
// introducing a circular dependency
|
|
const maxFileSizeBytes = 10 * (10 << 20) // 10 MB
|
|
|
|
// parseHCLFiler is the actual interface of *hclparse.Parser we use
|
|
// to parse HCL. This is extracted to an interface so we can more
|
|
// easily swap this out for an alternative implementation later on.
|
|
type parseHCLFiler interface {
|
|
ParseHCLFile(filename string) (*hcl.File, hcl.Diagnostics)
|
|
}
|
|
|
|
// Parser parses a Terraform module on disk.
|
|
type Parser struct {
|
|
logger slog.Logger
|
|
underlying parseHCLFiler
|
|
module *tfconfig.Module
|
|
workdir string
|
|
}
|
|
|
|
// Option is an option for a new instance of Parser.
|
|
type Option func(*Parser)
|
|
|
|
// WithLogger sets the logger to be used by Parser
|
|
func WithLogger(logger slog.Logger) Option {
|
|
return func(p *Parser) {
|
|
p.logger = logger
|
|
}
|
|
}
|
|
|
|
// New returns a new instance of Parser, as well as any diagnostics
|
|
// encountered while parsing the module.
|
|
func New(workdir string, opts ...Option) (*Parser, tfconfig.Diagnostics) {
|
|
p := Parser{
|
|
logger: slog.Make(),
|
|
underlying: hclparse.NewParser(),
|
|
workdir: workdir,
|
|
module: nil,
|
|
}
|
|
for _, o := range opts {
|
|
o(&p)
|
|
}
|
|
|
|
var diags tfconfig.Diagnostics
|
|
if p.module == nil {
|
|
m, ds := tfconfig.LoadModule(workdir)
|
|
diags = ds
|
|
p.module = m
|
|
}
|
|
|
|
return &p, diags
|
|
}
|
|
|
|
// WorkspaceTags looks for all coder_workspace_tags datasource in the module
|
|
// and returns the raw values for the tags. It also returns the set of
|
|
// variables referenced by any expressions in the raw values of tags.
|
|
func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, map[string]struct{}, error) {
|
|
tags := map[string]string{}
|
|
skipped := []string{}
|
|
requiredVars := map[string]struct{}{}
|
|
for _, dataResource := range p.module.DataResources {
|
|
if dataResource.Type != "coder_workspace_tags" {
|
|
skipped = append(skipped, strings.Join([]string{"data", dataResource.Type, dataResource.Name}, "."))
|
|
continue
|
|
}
|
|
|
|
var file *hcl.File
|
|
var diags hcl.Diagnostics
|
|
|
|
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
|
|
continue
|
|
}
|
|
// We know in which HCL file is the data resource defined.
|
|
file, diags = p.underlying.ParseHCLFile(dataResource.Pos.Filename)
|
|
if diags.HasErrors() {
|
|
return nil, nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
|
|
}
|
|
|
|
// Parse root to find "coder_workspace_tags".
|
|
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
|
|
if diags.HasErrors() {
|
|
return nil, nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
|
|
}
|
|
|
|
// Iterate over blocks to locate the exact "coder_workspace_tags" data resource.
|
|
for _, block := range content.Blocks {
|
|
if !slices.Equal(block.Labels, []string{"coder_workspace_tags", dataResource.Name}) {
|
|
continue
|
|
}
|
|
|
|
// Parse "coder_workspace_tags" to find all key-value tags.
|
|
resContent, _, diags := block.Body.PartialContent(coderWorkspaceTagsSchema)
|
|
if diags.HasErrors() {
|
|
return nil, nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error())
|
|
}
|
|
|
|
if resContent == nil {
|
|
continue // workspace tags are not present
|
|
}
|
|
|
|
if _, ok := resContent.Attributes["tags"]; !ok {
|
|
return nil, nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`)
|
|
}
|
|
|
|
expr := resContent.Attributes["tags"].Expr
|
|
tagsExpr, ok := expr.(*hclsyntax.ObjectConsExpr)
|
|
if !ok {
|
|
return nil, nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`)
|
|
}
|
|
|
|
// Parse key-value entries in "coder_workspace_tags"
|
|
for _, tagItem := range tagsExpr.Items {
|
|
key, err := previewFileContent(tagItem.KeyExpr.Range())
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("can't preview the resource file: %v", err)
|
|
}
|
|
key = strings.Trim(key, `"`)
|
|
|
|
value, err := previewFileContent(tagItem.ValueExpr.Range())
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("can't preview the resource file: %v", err)
|
|
}
|
|
|
|
if _, ok := tags[key]; ok {
|
|
return nil, nil, xerrors.Errorf(`workspace tag %q is defined multiple times`, key)
|
|
}
|
|
tags[key] = value
|
|
|
|
// Find values referenced by the expression.
|
|
refVars := referencedVariablesExpr(tagItem.ValueExpr)
|
|
for _, refVar := range refVars {
|
|
requiredVars[refVar] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
requiredVarNames := maps.Keys(requiredVars)
|
|
slices.Sort(requiredVarNames)
|
|
p.logger.Debug(ctx, "found workspace tags", slog.F("tags", maps.Keys(tags)), slog.F("skipped", skipped), slog.F("required_vars", requiredVarNames))
|
|
return tags, requiredVars, nil
|
|
}
|
|
|
|
// referencedVariablesExpr determines the variables referenced in expr
|
|
// and returns the names of those variables.
|
|
func referencedVariablesExpr(expr hclsyntax.Expression) (names []string) {
|
|
var parts []string
|
|
for _, expVar := range expr.Variables() {
|
|
for _, tr := range expVar {
|
|
switch v := tr.(type) {
|
|
case hcl.TraverseRoot:
|
|
parts = append(parts, v.Name)
|
|
case hcl.TraverseAttr:
|
|
parts = append(parts, v.Name)
|
|
default: // skip
|
|
}
|
|
}
|
|
|
|
cleaned := cleanupTraversalName(parts)
|
|
names = append(names, strings.Join(cleaned, "."))
|
|
}
|
|
return names
|
|
}
|
|
|
|
// cleanupTraversalName chops off extraneous pieces of the traversal.
|
|
// for example:
|
|
// - var.foo -> unchanged
|
|
// - data.coder_parameter.bar.value -> data.coder_parameter.bar
|
|
// - null_resource.baz.zap -> null_resource.baz
|
|
func cleanupTraversalName(parts []string) []string {
|
|
if len(parts) == 0 {
|
|
return parts
|
|
}
|
|
if len(parts) > 3 && parts[0] == "data" {
|
|
return parts[:3]
|
|
}
|
|
if len(parts) > 2 {
|
|
return parts[:2]
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func (p *Parser) WorkspaceTagDefaults(ctx context.Context) (map[string]string, error) {
|
|
// This only gets us the expressions. We need to evaluate them.
|
|
// Example: var.region -> "us"
|
|
tags, requiredVars, err := p.WorkspaceTags(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("extract workspace tags: %w", err)
|
|
}
|
|
|
|
if len(tags) == 0 {
|
|
return map[string]string{}, nil
|
|
}
|
|
|
|
// To evaluate the expressions, we need to load the default values for
|
|
// variables and parameters.
|
|
varsDefaults, err := p.VariableDefaults(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("load variable defaults: %w", err)
|
|
}
|
|
paramsDefaults, err := p.CoderParameterDefaults(ctx, varsDefaults, requiredVars)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("load parameter defaults: %w", err)
|
|
}
|
|
|
|
// Evaluate the tags expressions given the inputs.
|
|
// This will resolve any variables or parameters to their default
|
|
// values.
|
|
evalTags, err := evaluateWorkspaceTags(varsDefaults, paramsDefaults, tags)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("eval provisioner tags: %w", err)
|
|
}
|
|
|
|
return evalTags, nil
|
|
}
|
|
|
|
// TemplateVariables returns all of the Terraform variables in the module
|
|
// as TemplateVariables.
|
|
func (p *Parser) TemplateVariables() ([]*proto.TemplateVariable, error) {
|
|
// Sort variables by (filename, line) to make the ordering consistent
|
|
variables := make([]*tfconfig.Variable, 0, len(p.module.Variables))
|
|
for _, v := range p.module.Variables {
|
|
variables = append(variables, v)
|
|
}
|
|
sort.Slice(variables, func(i, j int) bool {
|
|
return compareSourcePos(variables[i].Pos, variables[j].Pos)
|
|
})
|
|
|
|
var templateVariables []*proto.TemplateVariable
|
|
for _, v := range variables {
|
|
mv, err := convertTerraformVariable(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
templateVariables = append(templateVariables, mv)
|
|
}
|
|
return templateVariables, nil
|
|
}
|
|
|
|
// WriteArchive is a helper function to write a in-memory archive
|
|
// with the given mimetype to disk. Only zip and tar archives
|
|
// are currently supported.
|
|
func WriteArchive(bs []byte, mimetype string, path string) error {
|
|
// Check if we need to convert the file first!
|
|
var rdr io.Reader
|
|
switch mimetype {
|
|
case "application/x-tar":
|
|
rdr = bytes.NewReader(bs)
|
|
case "application/zip":
|
|
if zr, err := zip.NewReader(bytes.NewReader(bs), int64(len(bs))); err != nil {
|
|
return xerrors.Errorf("read zip file: %w", err)
|
|
} else if tarBytes, err := archive.CreateTarFromZip(zr, maxFileSizeBytes); err != nil {
|
|
return xerrors.Errorf("convert zip to tar: %w", err)
|
|
} else {
|
|
rdr = bytes.NewReader(tarBytes)
|
|
}
|
|
default:
|
|
return xerrors.Errorf("unsupported mimetype: %s", mimetype)
|
|
}
|
|
|
|
// Untar the file into the temporary directory
|
|
if err := provisionersdk.Untar(path, rdr); err != nil {
|
|
return xerrors.Errorf("untar: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VariableDefaults returns the default values for all variables in the module.
|
|
func (p *Parser) VariableDefaults(ctx context.Context) (map[string]string, error) {
|
|
// iterate through vars to get the default values for all
|
|
// required variables.
|
|
m := make(map[string]string)
|
|
for _, v := range p.module.Variables {
|
|
if v == nil {
|
|
continue
|
|
}
|
|
sv, err := interfaceToString(v.Default)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("can't convert variable default value to string: %v", err)
|
|
}
|
|
m[v.Name] = strings.Trim(sv, `"`)
|
|
}
|
|
p.logger.Debug(ctx, "found default values for variables", slog.F("defaults", m))
|
|
return m, nil
|
|
}
|
|
|
|
// CoderParameterDefaults returns the default values of all coder_parameter data sources
|
|
// in the parsed module.
|
|
func (p *Parser) CoderParameterDefaults(ctx context.Context, varsDefaults map[string]string, names map[string]struct{}) (map[string]string, error) {
|
|
defaultsM := make(map[string]string)
|
|
var (
|
|
skipped []string
|
|
file *hcl.File
|
|
diags hcl.Diagnostics
|
|
)
|
|
|
|
for _, dataResource := range p.module.DataResources {
|
|
if dataResource == nil {
|
|
continue
|
|
}
|
|
|
|
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
|
|
continue
|
|
}
|
|
|
|
needle := strings.Join([]string{"data", dataResource.Type, dataResource.Name}, ".")
|
|
if dataResource.Type != "coder_parameter" {
|
|
skipped = append(skipped, needle)
|
|
continue
|
|
}
|
|
|
|
if _, found := names[needle]; !found {
|
|
skipped = append(skipped, needle)
|
|
continue
|
|
}
|
|
|
|
// We know in which HCL file is the data resource defined.
|
|
// NOTE: hclparse.Parser will cache multiple successive calls to parse the same file.
|
|
file, diags = p.underlying.ParseHCLFile(dataResource.Pos.Filename)
|
|
if diags.HasErrors() {
|
|
return nil, xerrors.Errorf("can't parse the resource file %q: %s", dataResource.Pos.Filename, diags.Error())
|
|
}
|
|
|
|
// Parse root to find "coder_parameter".
|
|
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
|
|
if diags.HasErrors() {
|
|
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
|
|
}
|
|
|
|
// Iterate over blocks to locate the exact "coder_parameter" data resource.
|
|
for _, block := range content.Blocks {
|
|
if !slices.Equal(block.Labels, []string{"coder_parameter", dataResource.Name}) {
|
|
continue
|
|
}
|
|
|
|
// Parse "coder_parameter" to find the default value.
|
|
resContent, _, diags := block.Body.PartialContent(coderParameterSchema)
|
|
if diags.HasErrors() {
|
|
return nil, xerrors.Errorf(`can't parse the coder_parameter: %s`, diags.Error())
|
|
}
|
|
|
|
if _, ok := resContent.Attributes["default"]; !ok {
|
|
p.logger.Warn(ctx, "coder_parameter data source does not have a default value", slog.F("name", dataResource.Name))
|
|
defaultsM[dataResource.Name] = ""
|
|
} else {
|
|
expr := resContent.Attributes["default"].Expr
|
|
value, err := previewFileContent(expr.Range())
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
|
|
}
|
|
// Issue #15795: the "default" value could also be an expression we need
|
|
// to evaluate.
|
|
// TODO: should we support coder_parameter default values that reference other coder_parameter data sources?
|
|
evalCtx := BuildEvalContext(varsDefaults, nil)
|
|
val, diags := expr.Value(evalCtx)
|
|
if diags.HasErrors() {
|
|
return nil, xerrors.Errorf("failed to evaluate coder_parameter %q default value %q: %s", dataResource.Name, value, diags.Error())
|
|
}
|
|
// Do not use "val.AsString()" as it can panic
|
|
strVal, err := CtyValueString(val)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to marshal coder_parameter %q default value %q as string: %s", dataResource.Name, value, err)
|
|
}
|
|
defaultsM[dataResource.Name] = strings.Trim(strVal, `"`)
|
|
}
|
|
}
|
|
}
|
|
p.logger.Debug(ctx, "found default values for parameters", slog.F("defaults", defaultsM), slog.F("skipped", skipped))
|
|
return defaultsM, nil
|
|
}
|
|
|
|
// evaluateWorkspaceTags evaluates the given workspaceTags based on the given
|
|
// default values for variables and coder_parameter data sources.
|
|
func evaluateWorkspaceTags(varsDefaults, paramsDefaults, workspaceTags map[string]string) (map[string]string, error) {
|
|
// Filter only allowed data sources for preflight check.
|
|
// This is not strictly required but provides a friendlier error.
|
|
if err := validWorkspaceTagValues(workspaceTags); err != nil {
|
|
return nil, err
|
|
}
|
|
// We only add variables and coder_parameter data sources. Anything else will be
|
|
// undefined and will raise a Terraform error.
|
|
evalCtx := BuildEvalContext(varsDefaults, paramsDefaults)
|
|
tags := make(map[string]string)
|
|
for workspaceTagKey, workspaceTagValue := range workspaceTags {
|
|
expr, diags := hclsyntax.ParseExpression([]byte(workspaceTagValue), "expression.hcl", hcl.InitialPos)
|
|
if diags.HasErrors() {
|
|
return nil, xerrors.Errorf("failed to parse workspace tag key %q value %q: %s", workspaceTagKey, workspaceTagValue, diags.Error())
|
|
}
|
|
|
|
val, diags := expr.Value(evalCtx)
|
|
if diags.HasErrors() {
|
|
return nil, xerrors.Errorf("failed to evaluate workspace tag key %q value %q: %s", workspaceTagKey, workspaceTagValue, diags.Error())
|
|
}
|
|
|
|
// Do not use "val.AsString()" as it can panic
|
|
str, err := CtyValueString(val)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to marshal workspace tag key %q value %q as string: %s", workspaceTagKey, workspaceTagValue, err)
|
|
}
|
|
tags[workspaceTagKey] = str
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
// validWorkspaceTagValues returns an error if any value of the given tags map
|
|
// evaluates to a datasource other than "coder_parameter".
|
|
// This only serves to provide a friendly error if a user attempts to reference
|
|
// a data source other than "coder_parameter" in "coder_workspace_tags".
|
|
func validWorkspaceTagValues(tags map[string]string) error {
|
|
for _, v := range tags {
|
|
parts := strings.SplitN(v, ".", 3)
|
|
if len(parts) != 3 {
|
|
continue
|
|
}
|
|
if parts[0] == "data" && parts[1] != "coder_parameter" {
|
|
return xerrors.Errorf("invalid workspace tag value %q: only the \"coder_parameter\" data source is supported here", v)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildEvalContext builds an evaluation context for the given variable and parameter defaults.
|
|
func BuildEvalContext(vars map[string]string, params map[string]string) *hcl.EvalContext {
|
|
varDefaultsM := map[string]cty.Value{}
|
|
for varName, varDefault := range vars {
|
|
varDefaultsM[varName] = cty.MapVal(map[string]cty.Value{
|
|
"value": cty.StringVal(varDefault),
|
|
})
|
|
}
|
|
|
|
paramDefaultsM := map[string]cty.Value{}
|
|
for paramName, paramDefault := range params {
|
|
paramDefaultsM[paramName] = cty.MapVal(map[string]cty.Value{
|
|
"value": cty.StringVal(paramDefault),
|
|
})
|
|
}
|
|
|
|
evalCtx := &hcl.EvalContext{
|
|
Variables: map[string]cty.Value{},
|
|
// NOTE: we do not currently support function execution here.
|
|
// The default function map for Terraform is not exposed, so we would essentially
|
|
// have to re-implement or copy the entire map or a subset thereof.
|
|
// ref: https://github.com/hashicorp/terraform/blob/e044e569c5bc81f82e9a4d7891f37c6fbb0a8a10/internal/lang/functions.go#L54
|
|
Functions: Functions(),
|
|
}
|
|
if len(varDefaultsM) != 0 {
|
|
evalCtx.Variables["var"] = cty.MapVal(varDefaultsM)
|
|
}
|
|
if len(paramDefaultsM) != 0 {
|
|
evalCtx.Variables["data"] = cty.MapVal(map[string]cty.Value{
|
|
"coder_parameter": cty.MapVal(paramDefaultsM),
|
|
})
|
|
}
|
|
|
|
return evalCtx
|
|
}
|
|
|
|
var rootTemplateSchema = &hcl.BodySchema{
|
|
Blocks: []hcl.BlockHeaderSchema{
|
|
{
|
|
Type: "data",
|
|
LabelNames: []string{"type", "name"},
|
|
},
|
|
},
|
|
}
|
|
|
|
var coderWorkspaceTagsSchema = &hcl.BodySchema{
|
|
Attributes: []hcl.AttributeSchema{
|
|
{
|
|
Name: "tags",
|
|
},
|
|
},
|
|
}
|
|
|
|
var coderParameterSchema = &hcl.BodySchema{
|
|
Attributes: []hcl.AttributeSchema{
|
|
{
|
|
Name: "default",
|
|
},
|
|
},
|
|
}
|
|
|
|
func previewFileContent(fileRange hcl.Range) (string, error) {
|
|
body, err := os.ReadFile(fileRange.Filename)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(fileRange.SliceBytes(body)), nil
|
|
}
|
|
|
|
// convertTerraformVariable converts a Terraform variable to a template-wide variable, processed by Coder.
|
|
func convertTerraformVariable(variable *tfconfig.Variable) (*proto.TemplateVariable, error) {
|
|
var defaultData string
|
|
if variable.Default != nil {
|
|
var valid bool
|
|
defaultData, valid = variable.Default.(string)
|
|
if !valid {
|
|
defaultDataRaw, err := json.Marshal(variable.Default)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse variable %q default: %w", variable.Name, err)
|
|
}
|
|
defaultData = string(defaultDataRaw)
|
|
}
|
|
}
|
|
|
|
return &proto.TemplateVariable{
|
|
Name: variable.Name,
|
|
Description: variable.Description,
|
|
Type: variable.Type,
|
|
DefaultValue: defaultData,
|
|
// variable.Required is always false. Empty string is a valid default value, so it doesn't enforce required to be "true".
|
|
Required: variable.Default == nil,
|
|
Sensitive: variable.Sensitive,
|
|
}, nil
|
|
}
|
|
|
|
func compareSourcePos(x, y tfconfig.SourcePos) bool {
|
|
if x.Filename != y.Filename {
|
|
return x.Filename < y.Filename
|
|
}
|
|
return x.Line < y.Line
|
|
}
|
|
|
|
// CtyValueString converts a cty.Value to a string.
|
|
// It supports only primitive types - bool, number, and string.
|
|
// As a special case, it also supports map[string]interface{} with key "value".
|
|
func CtyValueString(val cty.Value) (string, error) {
|
|
switch val.Type() {
|
|
case cty.Bool:
|
|
if val.True() {
|
|
return "true", nil
|
|
} else {
|
|
return "false", nil
|
|
}
|
|
case cty.Number:
|
|
return val.AsBigFloat().String(), nil
|
|
case cty.String:
|
|
return val.AsString(), nil
|
|
// We may also have a map[string]interface{} with key "value".
|
|
case cty.Map(cty.String):
|
|
valval, ok := val.AsValueMap()["value"]
|
|
if !ok {
|
|
return "", xerrors.Errorf("map does not have key 'value'")
|
|
}
|
|
return CtyValueString(valval)
|
|
default:
|
|
return "", xerrors.Errorf("only primitive types are supported - bool, number, and string")
|
|
}
|
|
}
|
|
|
|
func interfaceToString(i interface{}) (string, error) {
|
|
switch v := i.(type) {
|
|
case nil:
|
|
return "", nil
|
|
case string:
|
|
return v, nil
|
|
case []byte:
|
|
return string(v), nil
|
|
case int:
|
|
return strconv.FormatInt(int64(v), 10), nil
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64), nil
|
|
case bool:
|
|
return strconv.FormatBool(v), nil
|
|
default: // just try to JSON-encode it.
|
|
var sb strings.Builder
|
|
if err := json.NewEncoder(&sb).Encode(i); err != nil {
|
|
return "", xerrors.Errorf("convert %T: %w", v, err)
|
|
}
|
|
return strings.TrimSpace(sb.String()), nil
|
|
}
|
|
}
|