embed: Begin refactor to use clearer paths

This commit is contained in:
Tom Wiesing 2022-09-11 12:47:00 +02:00
parent e75dc29de1
commit e1ee569629
No known key found for this signature in database
16 changed files with 431 additions and 181 deletions

View file

@ -13,6 +13,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/hostname" "github.com/FAU-CDI/wisski-distillery/internal/hostname"
"github.com/FAU-CDI/wisski-distillery/internal/logging" "github.com/FAU-CDI/wisski-distillery/internal/logging"
"github.com/FAU-CDI/wisski-distillery/internal/password" "github.com/FAU-CDI/wisski-distillery/internal/password"
"github.com/FAU-CDI/wisski-distillery/internal/unpack"
"github.com/tkw1536/goprogram/exit" "github.com/tkw1536/goprogram/exit"
) )
@ -123,18 +124,23 @@ func (bs bootstrap) Run(context wisski_distillery.Context) error {
return errBootstrapWriteConfig.WithMessageF(err) return errBootstrapWriteConfig.WithMessageF(err)
} }
if err := embed.InstallTemplate(envPath, filepath.Join("resources", "templates", "bootstrap", "env"), map[string]string{ if err := unpack.InstallTemplate(
"DEPLOY_ROOT": root, envPath,
"DEFAULT_DOMAIN": domain, map[string]string{
"SELF_OVERRIDES_FILE": overridesPath, "DEPLOY_ROOT": root,
"AUTHORIZED_KEYS_FILE": authorizedKeysFile, "DEFAULT_DOMAIN": domain,
"SELF_OVERRIDES_FILE": overridesPath,
"AUTHORIZED_KEYS_FILE": authorizedKeysFile,
"GRAPHDB_ADMIN_USER": "admin", "GRAPHDB_ADMIN_USER": "admin",
"GRAPHDB_ADMIN_PASSWORD": password[:64], "GRAPHDB_ADMIN_PASSWORD": password[:64],
"MYSQL_ADMIN_USER": "admin", "MYSQL_ADMIN_USER": "admin",
"MYSQL_ADMIN_PASSWORD": password[64:], "MYSQL_ADMIN_PASSWORD": password[64:],
}); err != nil { },
filepath.Join("resources", "templates", "bootstrap", "env"),
embed.ResourceEmbed,
); err != nil {
return errBootstrapWriteConfig.WithMessageF(err) return errBootstrapWriteConfig.WithMessageF(err)
} }
@ -146,12 +152,18 @@ func (bs bootstrap) Run(context wisski_distillery.Context) error {
if err := logging.LogOperation(func() error { if err := logging.LogOperation(func() error {
context.Println(overridesPath) 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) return errBootstrapCreateFile.WithMessageF(err)
} }
context.Println(authorizedKeysFile) 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) return errBootstrapCreateFile.WithMessageF(err)
} }

View file

@ -11,9 +11,9 @@ import (
"github.com/pkg/errors" "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 //go:embed all:resources
var resourceEmbed embed.FS var ResourceEmbed embed.FS
// InstallResource install a resource src into dest. // InstallResource install a resource src into dest.
// When it encounters a directory, recursively installs the directory is called. // 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 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. // 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 { 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") var errExpectedFileButGotDirectory = errors.New("Expected a file, but got a directory")

View file

@ -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
}

11
env/component.go vendored
View file

@ -3,6 +3,7 @@ package env
import ( import (
"path/filepath" "path/filepath"
"github.com/FAU-CDI/wisski-distillery/embed"
"github.com/FAU-CDI/wisski-distillery/internal/stack" "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) stack.Dir = dis.getComponentPath(component)
name := component.Name() 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 return stack
} }

View file

@ -21,7 +21,7 @@ func (DisComponent) Name() string {
func (dis DisComponent) Stack() stack.Installable { func (dis DisComponent) Stack() stack.Installable {
return dis.dis.makeComponentStack(dis, stack.Installable{ return dis.dis.makeComponentStack(dis, stack.Installable{
EnvFileContext: map[string]string{ EnvContext: map[string]string{
"VIRTUAL_HOST": dis.dis.DefaultVirtualHost(), "VIRTUAL_HOST": dis.dis.DefaultVirtualHost(),
"LETSENCRYPT_HOST": dis.dis.DefaultLetsencryptHost(), "LETSENCRYPT_HOST": dis.dis.DefaultLetsencryptHost(),
"LETSENCRYPT_EMAIL": dis.dis.Config.CertbotEmail, "LETSENCRYPT_EMAIL": dis.dis.Config.CertbotEmail,

View file

@ -35,7 +35,7 @@ func (ResolverComponent) Name() string {
func (resolver ResolverComponent) Stack() stack.Installable { func (resolver ResolverComponent) Stack() stack.Installable {
return resolver.dis.makeComponentStack(resolver, stack.Installable{ return resolver.dis.makeComponentStack(resolver, stack.Installable{
EnvFileContext: map[string]string{ EnvContext: map[string]string{
"VIRTUAL_HOST": resolver.dis.DefaultVirtualHost(), "VIRTUAL_HOST": resolver.dis.DefaultVirtualHost(),
"LETSENCRYPT_HOST": resolver.dis.DefaultLetsencryptHost(), "LETSENCRYPT_HOST": resolver.dis.DefaultLetsencryptHost(),
"LETSENCRYPT_EMAIL": resolver.dis.Config.CertbotEmail, "LETSENCRYPT_EMAIL": resolver.dis.Config.CertbotEmail,
@ -47,6 +47,7 @@ func (resolver ResolverComponent) Stack() stack.Installable {
"SELF_OVERRIDES_FILE": resolver.dis.Config.SelfOverridesFile, "SELF_OVERRIDES_FILE": resolver.dis.Config.SelfOverridesFile,
"RESOLVER_CONFIG": resolver.ConfigPath(), "RESOLVER_CONFIG": resolver.ConfigPath(),
}, },
TouchFiles: []string{resolver.ConfigName},
CopyContextFiles: []string{core.Executable}, CopyContextFiles: []string{core.Executable},
}) })
} }

View file

@ -27,7 +27,7 @@ func (sc SelfComponent) Stack() stack.Installable {
} }
return sc.dis.makeComponentStack(sc, stack.Installable{ return sc.dis.makeComponentStack(sc, stack.Installable{
EnvFileContext: map[string]string{ EnvContext: map[string]string{
"VIRTUAL_HOST": sc.dis.DefaultVirtualHost(), "VIRTUAL_HOST": sc.dis.DefaultVirtualHost(),
"LETSENCRYPT_HOST": sc.dis.DefaultLetsencryptHost(), "LETSENCRYPT_HOST": sc.dis.DefaultLetsencryptHost(),
"LETSENCRYPT_EMAIL": sc.dis.Config.CertbotEmail, "LETSENCRYPT_EMAIL": sc.dis.Config.CertbotEmail,

View file

@ -13,8 +13,10 @@ import (
"time" "time"
"github.com/FAU-CDI/wisski-distillery/embed" "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/logging"
"github.com/FAU-CDI/wisski-distillery/internal/stack" "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/FAU-CDI/wisski-distillery/internal/wait"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/tkw1536/goprogram/exit" "github.com/tkw1536/goprogram/exit"
@ -154,10 +156,14 @@ func (ts TriplestoreComponent) Provision(name, domain, user, password string) er
} }
// prepare the create repo request // prepare the create repo request
createRepo, err := embed.ReadTemplate(filepath.Join("resources", "templates", "repository", "graphdb-repo.ttl"), map[string]string{ // TODO: Move this into a seperate file
"GRAPHDB_REPO": name, createRepo, _, err := unpack.UnpackTemplate(
"INSTANCE_DOMAIN": domain, map[string]string{
}) "GRAPHDB_REPO": name,
"INSTANCE_DOMAIN": domain,
},
fsx.OpenFS(filepath.Join("resources", "templates", "repository", "graphdb-repo.ttl"), embed.ResourceEmbed),
)
if err != nil { if err != nil {
return err return err
} }

View file

@ -18,7 +18,7 @@ func (WebComponent) Name() string {
func (web WebComponent) Stack() stack.Installable { func (web WebComponent) Stack() stack.Installable {
return web.dis.makeComponentStack(web, stack.Installable{ return web.dis.makeComponentStack(web, stack.Installable{
EnvFileContext: map[string]string{ EnvContext: map[string]string{
"DEFAULT_HOST": web.dis.Config.DefaultDomain, "DEFAULT_HOST": web.dis.Config.DefaultDomain,
}, },
}) })

12
env/instances.go vendored
View file

@ -233,10 +233,10 @@ func (instance Instance) Stack() stack.Installable {
Stack: stack.Stack{ Stack: stack.Stack{
Dir: instance.FilesystemBase, Dir: instance.FilesystemBase,
}, },
ContextResource: filepath.Join("resources", "compose", "barrel"), ContextPath: filepath.Join("resources", "compose", "barrel"),
EnvFileResource: filepath.Join("resources", "templates", "docker-env", "barrel"), EnvPath: filepath.Join("resources", "templates", "docker-env", "barrel"),
EnvFileContext: map[string]string{ EnvContext: map[string]string{
"DATA_PATH": filepath.Join(instance.FilesystemBase, "data"), "DATA_PATH": filepath.Join(instance.FilesystemBase, "data"),
"SLUG": instance.Slug, "SLUG": instance.Slug,
@ -263,10 +263,10 @@ func (instance Instance) ReserveStack() stack.Installable {
Stack: stack.Stack{ Stack: stack.Stack{
Dir: instance.FilesystemBase, Dir: instance.FilesystemBase,
}, },
ContextResource: filepath.Join("resources", "compose", "reserve"), ContextPath: filepath.Join("resources", "compose", "reserve"),
EnvFileResource: filepath.Join("resources", "templates", "docker-env", "reserve"), EnvPath: filepath.Join("resources", "templates", "docker-env", "reserve"),
EnvFileContext: map[string]string{ EnvContext: map[string]string{
"VIRTUAL_HOST": instance.Domain(), "VIRTUAL_HOST": instance.Domain(),
"LETSENCRYPT_HOST": instance.dis.IfHttps(instance.Domain()), "LETSENCRYPT_HOST": instance.dis.IfHttps(instance.Domain()),

31
internal/fsx/open.go Normal file
View file

@ -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
}

View file

@ -7,6 +7,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/embed" "github.com/FAU-CDI/wisski-distillery/embed"
"github.com/FAU-CDI/wisski-distillery/internal/fsx" "github.com/FAU-CDI/wisski-distillery/internal/fsx"
"github.com/FAU-CDI/wisski-distillery/internal/unpack"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/tkw1536/goprogram/stream" "github.com/tkw1536/goprogram/stream"
) )
@ -16,10 +17,14 @@ import (
type Installable struct { type Installable struct {
Stack Stack
ContextResource string // Path to the resource containing 'docker compose' context // Installable enabled installing several resources from a (potentially embedded) filesystem.
//
EnvFileResource string // Path to the resource containing dynamically generated env file // The Resources holds these, with appropriate resources specified below.
EnvFileContext map[string]string // Context of variables to replace in the env file // 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 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 // setup the base files
if err := embed.InstallResource( if err := embed.InstallResource(
is.Dir, is.Dir,
is.ContextResource, is.ContextPath,
func(dst, src string) { func(dst, src string) {
io.Printf("[install] %s\n", dst) io.Printf("[install] %s\n", dst)
}, },
@ -50,12 +55,13 @@ func (is Installable) Install(io stream.IOStream, context InstallationContext) e
// configure .env // configure .env
envDest := filepath.Join(is.Dir, ".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) io.Printf("[config] %s\n", envDest)
if err := embed.InstallTemplate( if err := unpack.InstallTemplate(
envDest, envDest,
is.EnvFileResource, is.EnvContext,
is.EnvFileContext, is.EnvPath,
is.Resources,
); err != nil { ); err != nil {
return err return err
} }

82
internal/unpack/dir.go Normal file
View file

@ -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
}

76
internal/unpack/file.go Normal file
View file

@ -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)
}

View file

@ -3,119 +3,140 @@ package unpack
import ( import (
"bufio" "bufio"
"bytes" "fmt"
"io" "io"
"io/fs"
"strings" "strings"
"github.com/pkg/errors"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
var errExpectedFileButGotDirectory = errors.New("Expected a file, but got a directory") // ts represents state of the template parser
type ts int
// 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 ( const (
templateModeNormal templateMode = iota // normal mode tsGobble ts = iota // gobble into dst
templateModeDollar // saw '$' tsSawDollar // saw a '$'
templateModeOpen // saw '${' 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. // 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. // 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 { 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 { for key := range context {
unuusedContext[key] = struct{}{} unusedKeys[key] = struct{}{}
} }
reader := bufio.NewReader(src) // a new reader // When we encounter a missing key, put it into this map.
var missingKeyErr error // error for missing keys // This is so that we can build an error message below.
var builder strings.Builder // holding variable names missingKeys := make(map[string]struct{}, 0)
mode := templateModeNormal // the current mode of the reader
// 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: parseloop:
for { for {
r, _, err := reader.ReadRune() r, _, err := reader.ReadRune()
switch { switch {
case err == io.EOF: case err == io.EOF:
/* finished the source, see below */ // finished parsing the source
break parseloop break parseloop
case err != nil: case err != nil:
/* something went wrong */ // the reader broke
return err return err
case mode == templateModeNormal && r == '$': case mode == tsGobble && r == '$':
// saw a '$' in normal mode // saw a '$' in gobble mode
// => switch to the dollar mode mode = tsSawDollar
mode = templateModeDollar case mode == tsGobble:
case mode == templateModeNormal: // normal gobbleing
// saw anything else // => pass it through
// => just pass it through
if _, err := dst.Write([]byte{byte(r)}); err != nil { if _, err := dst.Write([]byte{byte(r)}); err != nil {
return err return err
} }
case mode == templateModeDollar && r == '{': case mode == tsSawDollar && r == '{':
// saw '{', following the '$' // saw '{', following the '$'
// => read everything else into the buffer // => read everything else into the buffer
mode = templateModeOpen mode = tsGobbleVar
case mode == templateModeDollar && r == '$': case mode == tsSawDollar && r == '$':
// saw a '$' following the '$' // saw a '$' following the '$'
// => write the first '$', and handle the case $${stuff} // => write the first '$', and handle the case $${stuff}
if _, err := dst.Write([]byte("$")); err != nil { if _, err := dst.Write([]byte("$")); err != nil {
return err return err
} }
case mode == templateModeDollar: case mode == tsSawDollar:
// saw anything else following the '$' // 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 { if _, err := dst.Write([]byte{byte('$'), byte(r)}); err != nil {
return err return err
} }
mode = templateModeNormal mode = tsGobble
case mode == templateModeOpen && r != '}': case mode == tsGobbleVar && r != '}':
// saw anything except for closing bracket // saw anything except for closing bracket
// => keep it in the buffer // => keep it in the buffer
if _, err := builder.WriteRune(r); err != nil { if _, err := varB.WriteRune(r); err != nil {
return err return err
} }
case mode == templateModeOpen: case mode == tsGobbleVar:
// saw a closing '}' inside the open mode // saw a closing '}' inside tsGobbleVar mode
// => use the variable // => use the variable
name := builder.String() name := varB.String()
delete(unuusedContext, name) // mark the variable as used
// get the variable from the context // get the variable from the context
value, ok := context[name] 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 // write the replacement into the string
@ -124,48 +145,51 @@ parseloop:
} }
// reset the builder and go back into normal mode // reset the builder and go back into normal mode
builder.Reset() varB.Reset()
mode = templateModeNormal mode = tsGobble
default:
panic("never reached")
} }
} }
// cleanup at end of input //
// CLEANUP UNUSUED INPUT
//
switch mode { switch mode {
case templateModeNormal: case tsSawDollar:
// => everything is fine
case templateModeDollar:
// we had a '$', but no '{' // we had a '$', but no '{'
// => write the trailing '$' into dest // => write the trailing '$' into dest
if _, err := dst.Write([]byte("$")); err != nil { if _, err := dst.Write([]byte("$")); err != nil {
return err return err
} }
case templateModeOpen: case tsGobbleVar:
// we had a "${", followed by somthing unclosed // we had a "${", followed by somthing unclosed
// => write everything back into the dst // => write everything back into the dst
if _, err := dst.Write([]byte("${")); err != nil { if _, err := dst.Write([]byte("${")); err != nil {
return err return err
} }
if _, err := io.WriteString(dst, builder.String()); err != nil { if _, err := io.WriteString(dst, varB.String()); err != nil {
return err return err
} }
default:
panic("never reached")
} }
// check if there was a missing key! // Check if there were missing template keys.
if missingKeyErr != nil { // If so, we sort them and return an appropriate error.
return missingKeyErr if len(missingKeys) != 0 {
} keys := maps.Keys(unusedKeys)
// check if there was an unused key!
if len(unuusedContext) != 0 {
keys := maps.Keys(unuusedContext)
slices.Sort(keys) 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 return nil

65
internal/unpack/unpack.go Normal file
View file

@ -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
}