Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion function/.scripts/gen-comp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ do
fi
run=1
echo $dir
(cd $dir && fn-hcl-tools package src/*hcl >/tmp/script.txtar)
(cd $dir && fn-hcl-tools package src/ >/tmp/script.txtar)
(cd $dir && cat src/comp-template.yaml | script="$(cat /tmp/script.txtar | jq -sR)" envsubst | yq -P>composition.yaml)
Comment on lines +23 to 24

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell variables should be quoted to avoid breakage if a directory name contains whitespace or glob characters. Quote $dir in the subshell cd command (and consider quoting other expansions in this script for consistency).

Copilot uses AI. Check for mistakes.
done

Expand Down
27 changes: 27 additions & 0 deletions function/api/api.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package api

import (
"github.com/crossplane-contrib/function-hcl/function/internal/composition"
"github.com/crossplane-contrib/function-hcl/function/internal/evaluator"
"github.com/crossplane-contrib/function-hcl/function/internal/format"
"github.com/hashicorp/hcl/v2"
)

// ConfigFile is the well-named file that contains XRD metadata and library file paths.
const ConfigFile = composition.ConfigFile

// FormatHCL formats the supplied code.
func FormatHCL(code string) string {
return format.Source(code, format.Options{StandardizeObjectLiterals: true})
Expand All @@ -19,3 +23,26 @@ func Analyze(files ...File) hcl.Diagnostics {
e, _ := evaluator.New(evaluator.Options{})
return e.AnalyzeHCLFiles(files...)
}

// FS is a minimal filesystem implementation that the caller can implement.
type FS = composition.FS

// XRD provides the XRD information if available as metadata.
type XRD = composition.XRD

// LoadModule loads metadata and HCL files from the supplied directory and returns the
// results. File paths are relative to the directory that was processed.
func LoadModule(fs FS, dir string, ignoreMetadataErrors bool) (*XRD, []string, error) {
cfg, files, err := composition.Load(fs, dir, ignoreMetadataErrors)
if err != nil {
return nil, nil, err
}
var xrd *composition.XRD
if cfg != nil {
xrd = &cfg.XRD
}
if xrd != nil && (xrd.APIVersion == "" || xrd.Kind == "") {
xrd = nil
}
return xrd, files, nil
}
82 changes: 22 additions & 60 deletions function/cmd/fn-hcl-tools/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,35 @@ package main

import (
"fmt"
"log"
"os"

"github.com/crossplane-contrib/function-hcl/function/internal/evaluator"
"github.com/crossplane-contrib/function-hcl/function/internal/composition"
"github.com/crossplane-contrib/function-hcl/function/internal/format"
"github.com/hashicorp/hcl/v2"
"github.com/spf13/cobra"
"golang.org/x/tools/txtar"
)

func doAnalyze(files []evaluator.File) error {
e, err := evaluator.New(evaluator.Options{})
if err != nil {
return err
func getDir(args []string) (string, error) {
if len(args) > 1 {
return "", fmt.Errorf("zero or exactly one argument expected, found %d", len(args))
}
diags := e.Analyze(files...)
for _, diag := range diags {
sev := "ERROR:"
if diag.Severity == hcl.DiagWarning {
sev = "WARN :"
}
log.Println("\t", sev, diag.Error())
dir := "."
if len(args) == 1 {
dir = args[0]
}
if diags.HasErrors() {
return fmt.Errorf("analysis failed")
}
return nil
return dir, nil
}

func analyzeCommand() *cobra.Command {
c := &cobra.Command{
Use: "analyze file1.hcl file2.hcl ...",
Short: "perform a static analysis of the supplied files",
Use: "analyze [dir]",
Short: "perform a static analysis of the supplied directory (default is current directory)",
RunE: func(cmd *cobra.Command, args []string) error {
Comment thread
gotwarlost marked this conversation as resolved.
if len(args) == 0 {
return fmt.Errorf("no files to analyze")
dir, err := getDir(args)
if err != nil {
return err
}
cmd.SilenceUsage = true
var files []evaluator.File
for _, file := range args {
contents, err := os.ReadFile(file)
if err != nil {
return err
}
files = append(files, evaluator.File{
Name: file,
Content: string(contents),
})
}
return doAnalyze(files)
return composition.Analyze(dir)
},
}
return c
Expand All @@ -60,35 +39,18 @@ func analyzeCommand() *cobra.Command {
func packageScriptCommand() *cobra.Command {
var skipAnalysis bool
c := &cobra.Command{
Use: "package file1.hcl file2.hcl ...",
Short: "generate a txtar script for the supplied files",
Use: "package [dir]",
Short: "generate a txtar script for the supplied directory (default is current directory)",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("no files to package")
dir, err := getDir(args)
if err != nil {
return err
}
cmd.SilenceUsage = true
var archive txtar.Archive
var files []evaluator.File
for _, file := range args {
contents, err := os.ReadFile(file)
if err != nil {
return err
}
archive.Files = append(archive.Files, txtar.File{
Name: file,
Data: contents,
})
files = append(files, evaluator.File{
Name: file,
Content: string(contents),
})
}
if !skipAnalysis {
if err := doAnalyze(files); err != nil {
return err
}
b, err := composition.Package(dir, skipAnalysis)
if err != nil {
return err
}
b := txtar.Format(&archive)
_, _ = os.Stdout.Write(b)
return nil
},
Expand Down
2 changes: 1 addition & 1 deletion function/example/basic-locals/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ spec:
debug: true
source: Inline
hcl: |+
-- src/main.hcl --
-- main.hcl --
// top-level locals behave like Terraform locals and are available everywhere
// and accessed just using their name (no need to put "local." in front of it like Terraform)
locals {
Expand Down
2 changes: 1 addition & 1 deletion function/example/basic-resource-list/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --
// the resources block defines multiple resources to be created, the associated name is
// used as a basename.
resources my-bucket {
Expand Down
2 changes: 1 addition & 1 deletion function/example/basic-resource/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --

// the resource block defines a single resource to be created, the name is the crossplane name
resource my-bucket {
Expand Down
2 changes: 1 addition & 1 deletion function/example/extra-resources-absent/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --
locals {
comp = req.composite // req.composite contains the composite resource
compName = comp.metadata.name
Expand Down
8 changes: 4 additions & 4 deletions function/example/extra-resources-absent/src/expected.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ status:
status: "False"
type: FullyResolved
- lastTransitionTime: "2024-01-01T00:00:00Z"
message: 'hcl.Diagnostics contains 1 warnings; src/main.hcl:27,49-52: Attempt to index null value'
message: 'hcl.Diagnostics contains 1 warnings; main.hcl:27,49-52: Attempt to index null value'
reason: Eval
status: "False"
type: HclDiagnostics
---
apiVersion: render.crossplane.io/v1beta1
kind: Result
message: |-
src/main.hcl:21,10-34,4:discarded resource my-bucket
req.extra_resources.labels-config[0].data.labels, src/main.hcl:27,49-52: Attempt to index null value; This value is null, so it does not have any indices.
main.hcl:21,10-34,4:discarded resource my-bucket
req.extra_resources.labels-config[0].data.labels, main.hcl:27,49-52: Attempt to index null value; This value is null, so it does not have any indices.
unknown values: req.extra_resources.labels-config[0].data.labels
severity: SEVERITY_WARNING
step: run hcl composition
Expand All @@ -33,7 +33,7 @@ metadata:
---
apiVersion: render.crossplane.io/v1beta1
kind: Result
message: 'warnings: [src/main.hcl:27,49-52: Attempt to index null value]'
message: 'warnings: [main.hcl:27,49-52: Attempt to index null value]'
severity: SEVERITY_WARNING
step: run hcl composition
metadata:
Expand Down
2 changes: 1 addition & 1 deletion function/example/extra-resources-present/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ spec:
source: Inline
debug: true
hcl: |+
-- src/main.hcl --
-- main.hcl --
locals {
comp = req.composite // req.composite contains the composite resource
compName = comp.metadata.name
Expand Down
2 changes: 1 addition & 1 deletion function/example/set-context/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --
context {
key = "processed"
value = req.composite.metadata.name
Expand Down
2 changes: 1 addition & 1 deletion function/example/set-status-incomplete/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --

resource my-bucket {
body = {
Expand Down
6 changes: 3 additions & 3 deletions function/example/set-status-incomplete/src/expected.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ status:
type: FullyResolved
- type: HclDiagnostics
lastTransitionTime: "2024-01-01T00:00:00Z"
message: "hcl.Diagnostics contains 1 warnings; src/main.hcl:20,32-39: Attempt to get attribute from null value"
message: "hcl.Diagnostics contains 1 warnings; main.hcl:20,32-39: Attempt to get attribute from null value"
reason: Eval
status: "False"
---
Expand Down Expand Up @@ -51,15 +51,15 @@ apiVersion: render.crossplane.io/v1beta1
kind: Result
metadata:
name: r-0
message: "src/main.hcl:19,12-21,6:discarded composite-status \nself.resource.status.atProvider.arn, src/main.hcl:20,32-39: Attempt to get attribute from null value; This value is null, so it does not have any attributes."
message: "main.hcl:19,12-21,6:discarded composite-status \nself.resource.status.atProvider.arn, main.hcl:20,32-39: Attempt to get attribute from null value; This value is null, so it does not have any attributes."
severity: SEVERITY_WARNING
step: "run hcl composition"
---
apiVersion: render.crossplane.io/v1beta1
kind: Result
metadata:
name: r-1
message: "warnings: [src/main.hcl:20,32-39: Attempt to get attribute from null value]"
message: "warnings: [main.hcl:20,32-39: Attempt to get attribute from null value]"
severity: SEVERITY_WARNING
step: "run hcl composition"
---
Expand Down
2 changes: 1 addition & 1 deletion function/example/set-status/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --

resource my-bucket {
body = {
Expand Down
2 changes: 1 addition & 1 deletion function/example/spec-example/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --
resource my-s3-bucket {
// self.name will be set to "my-s3-bucket"

Expand Down
2 changes: 1 addition & 1 deletion function/example/user-function/composition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
kind: HclInput
source: Inline
hcl: |
-- src/main.hcl --
-- main.hcl --
function toProviderK8sObject {
arg name {
description = "metadata name of the return object"
Expand Down
68 changes: 68 additions & 0 deletions function/internal/composition/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Package composition provides loading, analysis and archive creation of a function-hcl
// composition module. It also processes a composition.yaml metadata file that is optionally
// present in the composition directory.
package composition

import (
"io/fs"

"golang.org/x/tools/txtar"
)

const ConfigFile = "composition.yaml"

type XRD struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
}

// FS is the minimal filesystem interface needed to load files for a module.
type FS interface {
Stat(name string) (fs.FileInfo, error)
ReadDir(name string) ([]fs.DirEntry, error)
ReadFile(name string) ([]byte, error)
}
Comment thread
gotwarlost marked this conversation as resolved.

// Config represents the configuration for the composition in terms of library file requirements
// and XRD type information.
type Config struct {
XRD XRD `json:"xrd"`
LibraryFiles []string `json:"libraryFiles"`
}

// Load returns composition information and a list of files to process from a specific directory.
// File paths in the list are relative to the directory that was loaded.
func Load(fs FS, dir string, ignoreMetadataErrors bool) (*Config, []string, error) {
l := newLoader(fs)
l.ignoreMetadataErrors = ignoreMetadataErrors
return l.load(dir)
}

// Package combines all HCL files and any additional library files and returns a byte array
// that contains the entire package in txtar format.
func Package(dir string, skipAnalysis bool) ([]byte, error) {
l := newLoader(osFs{})
archive, files, err := l.loadArchive(dir)
if err != nil {
return nil, err
}
if !skipAnalysis {
if err = doAnalyze(files); err != nil {
return nil, err
}
}
return txtar.Format(archive), nil
}

// Analyze analyzes all HCL files and any additional library files and returns an error on a failed analysis.
func Analyze(dir string) error {
l := newLoader(osFs{})
_, files, err := l.loadArchive(dir)
if err != nil {
return err
}
if err = doAnalyze(files); err != nil {
return err
}
return nil
}
Loading
Loading