wdcli: Update handling of templates

This commit updates the handling of expanding of templates. This is to
prepare embeding templates directly as opposed to embedding a single
resource directory.
This commit is contained in:
Tom Wiesing 2022-09-09 23:20:47 +02:00
parent 2881a5f65c
commit e75dc29de1
No known key found for this signature in database
6 changed files with 234 additions and 111 deletions

1
embed/paths.go Normal file
View file

@ -0,0 +1 @@
package embed

View file

@ -1,110 +0,0 @@
package embed
import (
"io"
"io/fs"
"os"
"regexp"
"strings"
"github.com/pkg/errors"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
var templateRegexp = regexp.MustCompile(`\${[^}]+}`)
// InstallTemplates open the resource src, and installs it into dst.
// the template resource must fit into memory.
//
// For each variable ${THING} inside dest, a key 'THING' must exist in context.
// Extra or missing template keys are an error.
func InstallTemplate(dst, src string, context map[string]string) error {
bytes, srcMode, err := doTemplate(src, context)
if err != nil {
return err
}
// determine if we need to create the destination file, or if it already exists
dstStat, dstErr := os.Stat(dst)
switch {
case os.IsNotExist(dstErr):
case dstErr != nil:
return errors.Wrapf(dstErr, "Error calling stat on destination %s", dst)
case dstStat.IsDir():
return errors.Wrapf(errExpectedFileButGotDirectory, "Error processing destination %s", dst)
}
// open and write the destination file
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcMode)
if err != nil {
return errors.Wrapf(err, "Unable to open file %s", dst)
}
_, err = dstFile.Write(bytes)
return errors.Wrapf(err, "Unable to write destination %s", dst)
}
// ReadTemplate is like InstallTemplate, except that it writes template into a byte slice and returns it.
func ReadTemplate(src string, context map[string]string) ([]byte, error) {
bytes, _, err := doTemplate(src, context)
return bytes, err
}
func doTemplate(src string, context map[string]string) (bytes []byte, mode fs.FileMode, err error) {
// open the source file!
srcFile, err := resourceEmbed.Open(src)
if err != nil {
return nil, mode, errors.Wrapf(err, "Error opening source file %s", src)
}
defer srcFile.Close()
// stat the source file to install
srcStat, srcErr := srcFile.Stat()
if srcErr != nil {
return nil, mode, errors.Wrapf(srcErr, "Error calling stat on source %s", src)
}
// should not be a directory
if srcStat.IsDir() {
return nil, mode, errors.Wrapf(errExpectedFileButGotDirectory, "Error calling stat on source %s", src)
}
// read the template and replace
templates, err := io.ReadAll(srcFile)
if err != nil {
return nil, mode, errors.Wrapf(err, "Unable to read src file %s", src)
}
// keep track of context keys that have not been used
unuusedContext := make(map[string]struct{}, len(context))
for key := range context {
unuusedContext[key] = struct{}{}
}
// replace the template regexp
// keeping track of unuused errors
var hadError error
templates = templateRegexp.ReplaceAllFunc(templates, func(b []byte) []byte {
name := string(b[2 : len(b)-1]) // remove the leading ${ and trailing }
delete(unuusedContext, name) // mark the key as having been read
value, ok := context[name]
if hadError != nil && !ok {
hadError = errors.Errorf("key %s missing in context", name)
}
return []byte(value)
})
if hadError != nil {
return nil, mode, hadError
}
if len(unuusedContext) != 0 {
keys := maps.Keys(unuusedContext)
slices.Sort(keys)
return nil, mode, errors.Errorf("additional keys %s in context", strings.Join(keys, ","))
}
// return the data and the mode!
return templates, srcStat.Mode(), nil
}

60
embed/template.go Normal file
View file

@ -0,0 +1,60 @@
package embed
import (
"os"
"github.com/FAU-CDI/wisski-distillery/internal/unpack"
"github.com/pkg/errors"
)
// InstallTemplates open the resource src, and installs it into dst.
// the template resource must fit into memory.
//
// For each variable ${THING} inside dest, a key 'THING' must exist in context.
// Extra or missing template keys are an error.
func InstallTemplate(dst, src string, context map[string]string) error {
// open the source file!
srcFile, err := resourceEmbed.Open(src)
if err != nil {
return errors.Wrapf(err, "Error opening source file %s", src)
}
defer srcFile.Close()
// write the template
bytes, srcMode, err := unpack.UnpackTemplate(context, srcFile)
if err != nil {
return err
}
// determine if we need to create the destination file, or if it already exists
dstStat, dstErr := os.Stat(dst)
switch {
case os.IsNotExist(dstErr):
case dstErr != nil:
return errors.Wrapf(dstErr, "Error calling stat on destination %s", dst)
case dstStat.IsDir():
return errors.Wrapf(errExpectedFileButGotDirectory, "Error processing destination %s", dst)
}
// open and write the destination file
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcMode)
if err != nil {
return errors.Wrapf(err, "Unable to open file %s", dst)
}
_, err = dstFile.Write(bytes)
return errors.Wrapf(err, "Unable to write destination %s", dst)
}
// ReadTemplate is like InstallTemplate, except that it writes template into a byte slice and returns it.
func ReadTemplate(src string, context map[string]string) ([]byte, error) {
// open the source file!
srcFile, err := resourceEmbed.Open(src)
if err != nil {
return nil, errors.Wrapf(err, "Error opening source file %s", src)
}
defer srcFile.Close()
// and return it
bytes, _, err := unpack.UnpackTemplate(context, srcFile)
return bytes, err
}

2
go.mod
View file

@ -3,6 +3,7 @@ module github.com/FAU-CDI/wisski-distillery
go 1.18 go 1.18
require ( require (
github.com/FAU-CDI/wdresolve v0.0.0-20220909150742-34bde844301d
github.com/Showmax/go-fqdn v1.0.0 github.com/Showmax/go-fqdn v1.0.0
github.com/alessio/shellescape v1.4.1 github.com/alessio/shellescape v1.4.1
github.com/feiin/sqlstring v0.3.0 github.com/feiin/sqlstring v0.3.0
@ -14,7 +15,6 @@ require (
) )
require ( require (
github.com/FAU-CDI/wdresolve v0.0.0-20220909150742-34bde844301d // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/jessevdk/go-flags v1.5.0 // indirect github.com/jessevdk/go-flags v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect

172
internal/unpack/template.go Normal file
View file

@ -0,0 +1,172 @@
// Package unpack unpacks files and templates to a target directory.
package unpack
import (
"bufio"
"bytes"
"io"
"io/fs"
"strings"
"github.com/pkg/errors"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
var errExpectedFileButGotDirectory = errors.New("Expected a file, but got a directory")
// UnpackTemplate unpacks the given file template and template
func UnpackTemplate(context map[string]string, src fs.File) ([]byte, fs.FileMode, error) {
// stat the source file to install
srcStat, srcErr := src.Stat()
if srcErr != nil {
return nil, 0, errors.Wrapf(srcErr, "Error calling stat on source")
}
// should not be a directory
if srcStat.IsDir() {
return nil, 0, errors.Wrapf(errExpectedFileButGotDirectory, "Error calling stat on source %s", srcStat.Name())
}
// read all the bytes into a buffer
var buffer bytes.Buffer
WriteTemplate(&buffer, context, src)
return buffer.Bytes(), srcStat.Mode(), nil
}
type templateMode int
const (
templateModeNormal templateMode = iota // normal mode
templateModeDollar // saw '$'
templateModeOpen // saw '${'
)
// WriteTemplate writes the template defined by src with the given context into reader.
//
// To run the template, variables of the form ${NAME} are replaced with their corresponding value from the context.
//
// Extra or missing variables from the context are an error.
func WriteTemplate(dst io.Writer, context map[string]string, src io.Reader) error {
// keep track of context keys that have not been used
unuusedContext := make(map[string]struct{}, len(context))
for key := range context {
unuusedContext[key] = struct{}{}
}
reader := bufio.NewReader(src) // a new reader
var missingKeyErr error // error for missing keys
var builder strings.Builder // holding variable names
mode := templateModeNormal // the current mode of the reader
parseloop:
for {
r, _, err := reader.ReadRune()
switch {
case err == io.EOF:
/* finished the source, see below */
break parseloop
case err != nil:
/* something went wrong */
return err
case mode == templateModeNormal && r == '$':
// saw a '$' in normal mode
// => switch to the dollar mode
mode = templateModeDollar
case mode == templateModeNormal:
// saw anything else
// => just pass it through
if _, err := dst.Write([]byte{byte(r)}); err != nil {
return err
}
case mode == templateModeDollar && r == '{':
// saw '{', following the '$'
// => read everything else into the buffer
mode = templateModeOpen
case mode == templateModeDollar && r == '$':
// saw a '$' following the '$'
// => write the first '$', and handle the case $${stuff}
if _, err := dst.Write([]byte("$")); err != nil {
return err
}
case mode == templateModeDollar:
// saw anything else following the '$'
// => write both back and switch back to normal mode
if _, err := dst.Write([]byte{byte('$'), byte(r)}); err != nil {
return err
}
mode = templateModeNormal
case mode == templateModeOpen && r != '}':
// saw anything except for closing bracket
// => keep it in the buffer
if _, err := builder.WriteRune(r); err != nil {
return err
}
case mode == templateModeOpen:
// saw a closing '}' inside the open mode
// => use the variable
name := builder.String()
delete(unuusedContext, name) // mark the variable as used
// get the variable from the context
value, ok := context[name]
if missingKeyErr != nil && !ok {
missingKeyErr = errors.Errorf("key %s missing in context", name)
}
// write the replacement into the string
if _, err := io.WriteString(dst, value); err != nil {
return err
}
// reset the builder and go back into normal mode
builder.Reset()
mode = templateModeNormal
default:
panic("never reached")
}
}
// cleanup at end of input
switch mode {
case templateModeNormal:
// => everything is fine
case templateModeDollar:
// we had a '$', but no '{'
// => write the trailing '$' into dest
if _, err := dst.Write([]byte("$")); err != nil {
return err
}
case templateModeOpen:
// we had a "${", followed by somthing unclosed
// => write everything back into the dst
if _, err := dst.Write([]byte("${")); err != nil {
return err
}
if _, err := io.WriteString(dst, builder.String()); err != nil {
return err
}
default:
panic("never reached")
}
// check if there was a missing key!
if missingKeyErr != nil {
return missingKeyErr
}
// check if there was an unused key!
if len(unuusedContext) != 0 {
keys := maps.Keys(unuusedContext)
slices.Sort(keys)
return errors.Errorf("additional keys %s in context", strings.Join(keys, ","))
}
return nil
}