From e75dc29de17f3aface0fbe9513b14f5d837d6696 Mon Sep 17 00:00:00 2001 From: Tom Wiesing Date: Fri, 9 Sep 2022 23:20:47 +0200 Subject: [PATCH] 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. --- embed/{resources.go => install.go} | 0 embed/paths.go | 1 + embed/resources_template.go | 110 ------------------ embed/template.go | 60 ++++++++++ go.mod | 2 +- internal/unpack/template.go | 172 +++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 111 deletions(-) rename embed/{resources.go => install.go} (100%) create mode 100644 embed/paths.go delete mode 100644 embed/resources_template.go create mode 100644 embed/template.go create mode 100644 internal/unpack/template.go diff --git a/embed/resources.go b/embed/install.go similarity index 100% rename from embed/resources.go rename to embed/install.go diff --git a/embed/paths.go b/embed/paths.go new file mode 100644 index 0000000..e92b434 --- /dev/null +++ b/embed/paths.go @@ -0,0 +1 @@ +package embed diff --git a/embed/resources_template.go b/embed/resources_template.go deleted file mode 100644 index d5301e7..0000000 --- a/embed/resources_template.go +++ /dev/null @@ -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 -} diff --git a/embed/template.go b/embed/template.go new file mode 100644 index 0000000..c44f5d1 --- /dev/null +++ b/embed/template.go @@ -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 +} diff --git a/go.mod b/go.mod index 8136400..7b231dd 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/FAU-CDI/wisski-distillery go 1.18 require ( + github.com/FAU-CDI/wdresolve v0.0.0-20220909150742-34bde844301d github.com/Showmax/go-fqdn v1.0.0 github.com/alessio/shellescape v1.4.1 github.com/feiin/sqlstring v0.3.0 @@ -14,7 +15,6 @@ 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/jessevdk/go-flags v1.5.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect diff --git a/internal/unpack/template.go b/internal/unpack/template.go new file mode 100644 index 0000000..1896b88 --- /dev/null +++ b/internal/unpack/template.go @@ -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 +}