diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 9affb11..08574bf 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -13,6 +13,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/hostname" "github.com/FAU-CDI/wisski-distillery/internal/logging" "github.com/FAU-CDI/wisski-distillery/internal/password" + "github.com/FAU-CDI/wisski-distillery/internal/unpack" "github.com/tkw1536/goprogram/exit" ) @@ -123,18 +124,23 @@ func (bs bootstrap) Run(context wisski_distillery.Context) error { return errBootstrapWriteConfig.WithMessageF(err) } - if err := embed.InstallTemplate(envPath, filepath.Join("resources", "templates", "bootstrap", "env"), map[string]string{ - "DEPLOY_ROOT": root, - "DEFAULT_DOMAIN": domain, - "SELF_OVERRIDES_FILE": overridesPath, - "AUTHORIZED_KEYS_FILE": authorizedKeysFile, + if err := unpack.InstallTemplate( + envPath, + map[string]string{ + "DEPLOY_ROOT": root, + "DEFAULT_DOMAIN": domain, + "SELF_OVERRIDES_FILE": overridesPath, + "AUTHORIZED_KEYS_FILE": authorizedKeysFile, - "GRAPHDB_ADMIN_USER": "admin", - "GRAPHDB_ADMIN_PASSWORD": password[:64], + "GRAPHDB_ADMIN_USER": "admin", + "GRAPHDB_ADMIN_PASSWORD": password[:64], - "MYSQL_ADMIN_USER": "admin", - "MYSQL_ADMIN_PASSWORD": password[64:], - }); err != nil { + "MYSQL_ADMIN_USER": "admin", + "MYSQL_ADMIN_PASSWORD": password[64:], + }, + filepath.Join("resources", "templates", "bootstrap", "env"), + embed.ResourceEmbed, + ); err != nil { return errBootstrapWriteConfig.WithMessageF(err) } @@ -146,12 +152,18 @@ func (bs bootstrap) Run(context wisski_distillery.Context) error { if err := logging.LogOperation(func() error { context.Println(overridesPath) - if err := embed.InstallTemplate(overridesPath, filepath.Join("resources", "templates", "bootstrap", "overrides.json"), map[string]string{}); err != nil { + if err := unpack.InstallFile( + overridesPath, + fsx.OpenFS(filepath.Join("resources", "templates", "bootstrap", "overrides.json"), embed.ResourceEmbed), + ); err != nil { return errBootstrapCreateFile.WithMessageF(err) } context.Println(authorizedKeysFile) - if err := embed.InstallTemplate(authorizedKeysFile, filepath.Join("resources", "templates", "bootstrap", "global_authorized_keys"), map[string]string{}); err != nil { + if err := unpack.InstallFile( + authorizedKeysFile, + fsx.OpenFS(filepath.Join("resources", "templates", "bootstrap", "global_authorized_keys"), embed.ResourceEmbed), + ); err != nil { return errBootstrapCreateFile.WithMessageF(err) } diff --git a/embed/install.go b/embed/install.go index 25394ff..8a47e00 100644 --- a/embed/install.go +++ b/embed/install.go @@ -11,9 +11,9 @@ import ( "github.com/pkg/errors" ) -// resourceEmbed contains all the resources required by the WissKI-Distillery package. +// ResourceEmbed contains all the resources required by the WissKI-Distillery package. //go:embed all:resources -var resourceEmbed embed.FS +var ResourceEmbed embed.FS // InstallResource install a resource src into dest. // When it encounters a directory, recursively installs the directory is called. @@ -22,7 +22,7 @@ var resourceEmbed embed.FS // If src points to a file, dst must either be an existing file, or not exist. // If src points to a directory, dst must either be an existing directory, or not exist. func InstallResource(dst, src string, onInstallFile func(dst, src string)) error { - return installFile(dst, resourceEmbed, src, onInstallFile) + return installFile(dst, ResourceEmbed, src, onInstallFile) } var errExpectedFileButGotDirectory = errors.New("Expected a file, but got a directory") diff --git a/embed/template.go b/embed/template.go deleted file mode 100644 index c44f5d1..0000000 --- a/embed/template.go +++ /dev/null @@ -1,60 +0,0 @@ -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/env/component.go b/env/component.go index 39ab925..8a3731b 100644 --- a/env/component.go +++ b/env/component.go @@ -3,6 +3,7 @@ package env import ( "path/filepath" + "github.com/FAU-CDI/wisski-distillery/embed" "github.com/FAU-CDI/wisski-distillery/internal/stack" ) @@ -37,8 +38,14 @@ func (dis *Distillery) makeComponentStack(component Component, stack stack.Insta stack.Dir = dis.getComponentPath(component) name := component.Name() - stack.ContextResource = filepath.Join("resources", "compose", name) - stack.EnvFileResource = filepath.Join("resources", "templates", "docker-env", name) + + // TODO: This writes out resources. + // Should migrate this directly! + if stack.Resources == nil { + stack.Resources = embed.ResourceEmbed + stack.ContextPath = filepath.Join("resources", "compose", name) + stack.EnvPath = filepath.Join("resources", "templates", "docker-env", name) + } return stack } diff --git a/env/component_dis.go b/env/component_dis.go index 32e971d..c96a4d5 100644 --- a/env/component_dis.go +++ b/env/component_dis.go @@ -21,7 +21,7 @@ func (DisComponent) Name() string { func (dis DisComponent) Stack() stack.Installable { return dis.dis.makeComponentStack(dis, stack.Installable{ - EnvFileContext: map[string]string{ + EnvContext: map[string]string{ "VIRTUAL_HOST": dis.dis.DefaultVirtualHost(), "LETSENCRYPT_HOST": dis.dis.DefaultLetsencryptHost(), "LETSENCRYPT_EMAIL": dis.dis.Config.CertbotEmail, diff --git a/env/component_resolver.go b/env/component_resolver.go index 8697119..92fc32c 100644 --- a/env/component_resolver.go +++ b/env/component_resolver.go @@ -35,7 +35,7 @@ func (ResolverComponent) Name() string { func (resolver ResolverComponent) Stack() stack.Installable { return resolver.dis.makeComponentStack(resolver, stack.Installable{ - EnvFileContext: map[string]string{ + EnvContext: map[string]string{ "VIRTUAL_HOST": resolver.dis.DefaultVirtualHost(), "LETSENCRYPT_HOST": resolver.dis.DefaultLetsencryptHost(), "LETSENCRYPT_EMAIL": resolver.dis.Config.CertbotEmail, @@ -47,6 +47,7 @@ func (resolver ResolverComponent) Stack() stack.Installable { "SELF_OVERRIDES_FILE": resolver.dis.Config.SelfOverridesFile, "RESOLVER_CONFIG": resolver.ConfigPath(), }, + TouchFiles: []string{resolver.ConfigName}, CopyContextFiles: []string{core.Executable}, }) } diff --git a/env/component_self.go b/env/component_self.go index 88f7c47..618c007 100644 --- a/env/component_self.go +++ b/env/component_self.go @@ -27,7 +27,7 @@ func (sc SelfComponent) Stack() stack.Installable { } return sc.dis.makeComponentStack(sc, stack.Installable{ - EnvFileContext: map[string]string{ + EnvContext: map[string]string{ "VIRTUAL_HOST": sc.dis.DefaultVirtualHost(), "LETSENCRYPT_HOST": sc.dis.DefaultLetsencryptHost(), "LETSENCRYPT_EMAIL": sc.dis.Config.CertbotEmail, diff --git a/env/component_triplestore.go b/env/component_triplestore.go index 36b2546..96d708a 100644 --- a/env/component_triplestore.go +++ b/env/component_triplestore.go @@ -13,8 +13,10 @@ import ( "time" "github.com/FAU-CDI/wisski-distillery/embed" + "github.com/FAU-CDI/wisski-distillery/internal/fsx" "github.com/FAU-CDI/wisski-distillery/internal/logging" "github.com/FAU-CDI/wisski-distillery/internal/stack" + "github.com/FAU-CDI/wisski-distillery/internal/unpack" "github.com/FAU-CDI/wisski-distillery/internal/wait" "github.com/pkg/errors" "github.com/tkw1536/goprogram/exit" @@ -154,10 +156,14 @@ func (ts TriplestoreComponent) Provision(name, domain, user, password string) er } // prepare the create repo request - createRepo, err := embed.ReadTemplate(filepath.Join("resources", "templates", "repository", "graphdb-repo.ttl"), map[string]string{ - "GRAPHDB_REPO": name, - "INSTANCE_DOMAIN": domain, - }) + // TODO: Move this into a seperate file + createRepo, _, err := unpack.UnpackTemplate( + map[string]string{ + "GRAPHDB_REPO": name, + "INSTANCE_DOMAIN": domain, + }, + fsx.OpenFS(filepath.Join("resources", "templates", "repository", "graphdb-repo.ttl"), embed.ResourceEmbed), + ) if err != nil { return err } diff --git a/env/component_web.go b/env/component_web.go index ba269b5..d19b53a 100644 --- a/env/component_web.go +++ b/env/component_web.go @@ -18,7 +18,7 @@ func (WebComponent) Name() string { func (web WebComponent) Stack() stack.Installable { return web.dis.makeComponentStack(web, stack.Installable{ - EnvFileContext: map[string]string{ + EnvContext: map[string]string{ "DEFAULT_HOST": web.dis.Config.DefaultDomain, }, }) diff --git a/env/instances.go b/env/instances.go index d9aee0e..cea8483 100644 --- a/env/instances.go +++ b/env/instances.go @@ -233,10 +233,10 @@ func (instance Instance) Stack() stack.Installable { Stack: stack.Stack{ Dir: instance.FilesystemBase, }, - ContextResource: filepath.Join("resources", "compose", "barrel"), + ContextPath: filepath.Join("resources", "compose", "barrel"), - EnvFileResource: filepath.Join("resources", "templates", "docker-env", "barrel"), - EnvFileContext: map[string]string{ + EnvPath: filepath.Join("resources", "templates", "docker-env", "barrel"), + EnvContext: map[string]string{ "DATA_PATH": filepath.Join(instance.FilesystemBase, "data"), "SLUG": instance.Slug, @@ -263,10 +263,10 @@ func (instance Instance) ReserveStack() stack.Installable { Stack: stack.Stack{ Dir: instance.FilesystemBase, }, - ContextResource: filepath.Join("resources", "compose", "reserve"), + ContextPath: filepath.Join("resources", "compose", "reserve"), - EnvFileResource: filepath.Join("resources", "templates", "docker-env", "reserve"), - EnvFileContext: map[string]string{ + EnvPath: filepath.Join("resources", "templates", "docker-env", "reserve"), + EnvContext: map[string]string{ "VIRTUAL_HOST": instance.Domain(), "LETSENCRYPT_HOST": instance.dis.IfHttps(instance.Domain()), diff --git a/internal/fsx/open.go b/internal/fsx/open.go new file mode 100644 index 0000000..152ac21 --- /dev/null +++ b/internal/fsx/open.go @@ -0,0 +1,31 @@ +package fsx + +import "io/fs" + +// OpenFS opens the named file in filesystem. +// If opening the file results in an error, returns [ErrFile]. +func OpenFS(name string, fsys fs.FS) fs.File { + file, err := fsys.Open(name) + if err != nil { + return ErrFile{Err: err} + } + return file +} + +// ErrFile implements a no-op [fs.File]. +// +// Every operation will return an underlying error +type ErrFile struct { + Err error +} + +func (err ErrFile) Stat() (fs.FileInfo, error) { + return nil, err.Err +} +func (err ErrFile) Read([]byte) (int, error) { + return 0, err.Err +} + +func (err ErrFile) Close() error { + return err.Err +} diff --git a/internal/stack/installable.go b/internal/stack/installable.go index 991fb16..208673a 100644 --- a/internal/stack/installable.go +++ b/internal/stack/installable.go @@ -7,6 +7,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/embed" "github.com/FAU-CDI/wisski-distillery/internal/fsx" + "github.com/FAU-CDI/wisski-distillery/internal/unpack" "github.com/pkg/errors" "github.com/tkw1536/goprogram/stream" ) @@ -16,10 +17,14 @@ import ( type Installable struct { Stack - ContextResource string // Path to the resource containing 'docker compose' context - - EnvFileResource string // Path to the resource containing dynamically generated env file - EnvFileContext map[string]string // Context of variables to replace in the env file + // Installable enabled installing several resources from a (potentially embedded) filesystem. + // + // The Resources holds these, with appropriate resources specified below. + // These all refer to paths within the Resource filesystem. + Resources fs.FS + ContextPath string // the 'docker compose' stack context, containing e.g. 'docker-compose.yml'. + EnvPath string // the '.env' template, will be installed using [unpack.InstallTemplate]. + EnvContext map[string]string // context when instantiating the '.env' template CopyContextFiles []string // Files to copy from the installation context @@ -40,7 +45,7 @@ func (is Installable) Install(io stream.IOStream, context InstallationContext) e // setup the base files if err := embed.InstallResource( is.Dir, - is.ContextResource, + is.ContextPath, func(dst, src string) { io.Printf("[install] %s\n", dst) }, @@ -50,12 +55,13 @@ func (is Installable) Install(io stream.IOStream, context InstallationContext) e // configure .env envDest := filepath.Join(is.Dir, ".env") - if is.EnvFileResource != "" && is.EnvFileContext != nil { + if is.EnvPath != "" && is.EnvContext != nil { io.Printf("[config] %s\n", envDest) - if err := embed.InstallTemplate( + if err := unpack.InstallTemplate( envDest, - is.EnvFileResource, - is.EnvFileContext, + is.EnvContext, + is.EnvPath, + is.Resources, ); err != nil { return err } diff --git a/internal/unpack/dir.go b/internal/unpack/dir.go new file mode 100644 index 0000000..b82d0a4 --- /dev/null +++ b/internal/unpack/dir.go @@ -0,0 +1,82 @@ +package unpack + +import ( + "io/fs" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +// InstallDir installs the directory at src within fsys to dst. +// +// onInstallFile is called for each file or directory being installed. +// +// If the destination path does not exist, it is created using [os.MakeDirs] +// The directory is installed recursively. +func InstallDir(dst string, src string, fsys fs.FS, onInstallFile func(dst, src string)) error { + // open the source file + srcFile, err := fsys.Open(src) + if err != nil { + return err + } + + // stat it! + srcInfo, err := srcFile.Stat() + if err != nil { + return err + } + + // make sure it's a file! + if !srcInfo.IsDir() { + return errExpectedDirectoryButGotFile + } + + // call the hook (if any) + if onInstallFile != nil { + onInstallFile(dst, src) + } + + // do the installation of the directory. + // the type cast should be safe. + return installDir(dst, srcInfo, srcFile.(fs.ReadDirFile), src, fsys, onInstallFile) +} + +func installDir(dst string, srcInfo fs.FileInfo, srcFile fs.ReadDirFile, src string, fsys fs.FS, onInstallFile func(dst, src string)) error { + // create the destination + dstStat, dstErr := os.Stat(dst) + switch { + case os.IsNotExist(dstErr): + if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { + return errors.Wrapf(err, "Error creating destination directory %s", dst) + } + case dstErr != nil: + return errors.Wrapf(dstErr, "Error calling stat on destination %s", dst) + case !dstStat.IsDir(): + return errors.Wrapf(errExpectedDirectoryButGotFile, "Error opening destination %s", dst) + } + + // NOTE(twiesing): We don't use fs.Walk here. + // If we did, we'd have to reconstruct relative paths. + // That would be very ugly! + + // read the directory + entries, err := srcFile.ReadDir(-1) + if err != nil { + return errors.Wrapf(err, "Error reading source directory %s", srcFile) + } + + // iterate over all the children + for _, entry := range entries { + if err := InstallResource( + filepath.Join(dst, entry.Name()), + filepath.Join(src, entry.Name()), + fsys, + onInstallFile, + ); err != nil { + return err + } + } + + return nil +} diff --git a/internal/unpack/file.go b/internal/unpack/file.go new file mode 100644 index 0000000..beef8a0 --- /dev/null +++ b/internal/unpack/file.go @@ -0,0 +1,76 @@ +package unpack + +import ( + "io" + "io/fs" + "os" + + "github.com/pkg/errors" +) + +// InstallFile installs the file from src into dst. +// +// If the destination path does not exist, it is created. +func InstallFile(dst string, src fs.File) error { + // stat it! + srcInfo, err := src.Stat() + if err != nil { + return err + } + + // if this is a directory, something went wrong! + if srcInfo.IsDir() { + return errExpectedFileButGotDirectory + } + + // and store it there! + return installFile(dst, srcInfo, src) +} + +func installFile(dst string, srcInfo fs.FileInfo, src fs.File) error { + // create the file using the right mode! + file, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + defer file.Close() + + // copy over the content! + _, err = io.Copy(file, src) + return errors.Wrapf(err, "Error writing to destination %s", dst) +} + +// InstallTemplate unpacks the resource located at src in fsys, then processes it as a template, and eventually writes it to dst. +// Any existing file is truncated and overwritten. +// +// See [WriteTemplate] for possible errors. +func InstallTemplate(dst string, context map[string]string, src string, fsys fs.FS) error { + + // open the srcFile + srcFile, err := fsys.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // stat it + srcInfo, err := srcFile.Stat() + if err != nil { + return err + } + + // check if it is a directory + if srcInfo.IsDir() { + return errExpectedFileButGotDirectory + } + + // open the destination file + file, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + defer file.Close() + + // write the file! + return WriteTemplate(file, context, srcFile) +} diff --git a/internal/unpack/template.go b/internal/unpack/template.go index 1896b88..1dcd3c8 100644 --- a/internal/unpack/template.go +++ b/internal/unpack/template.go @@ -3,119 +3,140 @@ package unpack import ( "bufio" - "bytes" + "fmt" "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 +// ts represents state of the template parser +type ts int const ( - templateModeNormal templateMode = iota // normal mode - templateModeDollar // saw '$' - templateModeOpen // saw '${' + tsGobble ts = iota // gobble into dst + tsSawDollar // saw a '$' + tsGobbleVar // gobble into var ) +// MissingTemplateKeyError indicates [WriteTemplate] found missing keys in the context +type MissingTemplateKeyError struct { + Keys []string +} + +func (mtke MissingTemplateKeyError) Error() string { + return fmt.Sprintf("missing template keys from context: %v", mtke.Keys) +} + +// UnusuedTemplateKeyError indicates [WriteTemplate] found unusued keys in the context +type UnusuedTemplateKeyError struct { + Keys []string +} + +func (utke UnusuedTemplateKeyError) Error() string { + return fmt.Sprintf("unused template keys from context: %v", utke.Keys) +} + // 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. +// If an underlying read or write fails, it is returned as is. +// Missing template keys return a [MissingTemplateKeyError], but are replaced with the empty string. +// Unused template keys return a [UnusuedTemplateKeyError], but are replaced with the empty string. +// +// Reader / Writer errors are always returned first; next missing template keys, and finally unused template keys. 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)) + + // We keep track of contect keys that have not been used. + // + // We first fill the map with all the keys from the context. + // Then when we use a key, we delete it from the map. + // If there are any keys left at the end of the replacement, that is an error. + unusedKeys := make(map[string]struct{}, len(context)) for key := range context { - unuusedContext[key] = struct{}{} + unusedKeys[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 + // When we encounter a missing key, put it into this map. + // This is so that we can build an error message below. + missingKeys := make(map[string]struct{}, 0) + + // We use a new bufio reader to read data from the input. + // This is a cheap trick to get a ReadRune() method. + reader := bufio.NewReader(src) + + // + // MAIN PARSING LOOP + // + + // start out in gobble mode! + mode := tsGobble + + // keep track of variable names + var varB strings.Builder + parseloop: for { r, _, err := reader.ReadRune() switch { case err == io.EOF: - /* finished the source, see below */ + // finished parsing the source break parseloop case err != nil: - /* something went wrong */ + // the reader broke 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 + case mode == tsGobble && r == '$': + // saw a '$' in gobble mode + mode = tsSawDollar + case mode == tsGobble: + // normal gobbleing + // => pass it through if _, err := dst.Write([]byte{byte(r)}); err != nil { return err } - case mode == templateModeDollar && r == '{': + case mode == tsSawDollar && r == '{': // saw '{', following the '$' // => read everything else into the buffer - mode = templateModeOpen - case mode == templateModeDollar && r == '$': + mode = tsGobbleVar + case mode == tsSawDollar && 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: + case mode == tsSawDollar: // saw anything else following the '$' - // => write both back and switch back to normal mode + // => write both back and switch back to gobble mode if _, err := dst.Write([]byte{byte('$'), byte(r)}); err != nil { return err } - mode = templateModeNormal + mode = tsGobble - case mode == templateModeOpen && r != '}': + case mode == tsGobbleVar && r != '}': // saw anything except for closing bracket // => keep it in the buffer - if _, err := builder.WriteRune(r); err != nil { + if _, err := varB.WriteRune(r); err != nil { return err } - case mode == templateModeOpen: - // saw a closing '}' inside the open mode + case mode == tsGobbleVar: + // saw a closing '}' inside tsGobbleVar mode // => use the variable - name := builder.String() - delete(unuusedContext, name) // mark the variable as used + name := varB.String() // get the variable from the context value, ok := context[name] - if missingKeyErr != nil && !ok { - missingKeyErr = errors.Errorf("key %s missing in context", name) + + delete(unusedKeys, name) // mark the variable as used! + if !ok { + // store unusued variables + missingKeys[name] = struct{}{} + value = "" } // write the replacement into the string @@ -124,48 +145,51 @@ parseloop: } // reset the builder and go back into normal mode - builder.Reset() - mode = templateModeNormal - - default: - panic("never reached") + varB.Reset() + mode = tsGobble } } - // cleanup at end of input + // + // CLEANUP UNUSUED INPUT + // switch mode { - case templateModeNormal: - // => everything is fine - case templateModeDollar: + case tsSawDollar: // we had a '$', but no '{' // => write the trailing '$' into dest if _, err := dst.Write([]byte("$")); err != nil { return err } - case templateModeOpen: + case tsGobbleVar: // 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 { + if _, err := io.WriteString(dst, varB.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) + // Check if there were missing template keys. + // If so, we sort them and return an appropriate error. + if len(missingKeys) != 0 { + keys := maps.Keys(unusedKeys) slices.Sort(keys) - return errors.Errorf("additional keys %s in context", strings.Join(keys, ",")) + return MissingTemplateKeyError{ + Keys: keys, + } + } + + // Check if there were unused template keys. + // If so, we sort them and return an appropriate error. + if len(unusedKeys) != 0 { + keys := maps.Keys(unusedKeys) + slices.Sort(keys) + return UnusuedTemplateKeyError{ + Keys: keys, + } } return nil diff --git a/internal/unpack/unpack.go b/internal/unpack/unpack.go new file mode 100644 index 0000000..85014e3 --- /dev/null +++ b/internal/unpack/unpack.go @@ -0,0 +1,65 @@ +// Package unpack unpacks files and templates to a target directory +package unpack + +import ( + "bytes" + "errors" + "io/fs" +) + +var errExpectedFileButGotDirectory = errors.New("expected a file, but got a directory") +var errExpectedDirectoryButGotFile = errors.New("expected a directory, but got a file") + +// InstallResource installs the resource at src within fsys to dst. +// +// OnInstallFile is called for each source and destination file. +// OnInstallFile may be nil. +// +// See [InstallDir] or [InstallFile]. +func InstallResource(dst string, src string, fsys fs.FS, onInstallFile func(dst, src string)) error { + // open the srcFile + srcFile, err := fsys.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // stat it! + srcInfo, err := srcFile.Stat() + if err != nil { + return err + } + + // call the hook (if any) + if onInstallFile != nil { + onInstallFile(dst, src) + } + + // this is a directory, so the cast is safe! + if srcInfo.IsDir() { + return installDir(dst, srcInfo, srcFile.(fs.ReadDirFile), src, fsys, onInstallFile) + } + + // this is a regular file! + return installFile(dst, srcInfo, srcFile) +} + +// UnpackTemplate unpacks the given file template and template. +// See [WriteTemplate] for possible errors. +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, srcErr + } + + // should not be a directory + if srcStat.IsDir() { + return nil, 0, errExpectedFileButGotDirectory + } + + // read all the bytes into a buffer + var buffer bytes.Buffer + err := WriteTemplate(&buffer, context, src) + return buffer.Bytes(), srcStat.Mode(), err +}