Refactor Package structure
This commit cleans up the package structure, to make two new top-level packages `internal` (for internal-use packages) and `pkg` (for general shared utility code).
This commit is contained in:
parent
487ce09979
commit
a360324f62
124 changed files with 97 additions and 101 deletions
68
pkg/bookkeeping/bookkeeping.go
Normal file
68
pkg/bookkeeping/bookkeeping.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Package bookkeeping implements reading and writing from the bookkeeping table
|
||||
package bookkeeping
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Instance is a WissKI Instance inside the bookkeeping table.
|
||||
// It does not represent a running instance; it does not perform any validation.
|
||||
type Instance struct {
|
||||
// NOTE: Modifying this struct requires a database migration.
|
||||
// This should nnever be done unless you know what you're doing.
|
||||
|
||||
// Primary key for the instance
|
||||
Pk uint `gorm:"column:pk;primaryKey"`
|
||||
|
||||
// time the instance was created
|
||||
Created time.Time `gorm:"column:created;autoCreateTime"`
|
||||
|
||||
// slug of the system
|
||||
Slug string `gorm:"column:slug;not null;unique"`
|
||||
|
||||
// email address of the system owner (if any)
|
||||
OwnerEmail string `gorm:"column:owner_email;type:varchar(320)"`
|
||||
|
||||
// should we automatically enable updates for the system?
|
||||
AutoBlindUpdateEnabled SQLBit1 `gorm:"column:auto_blind_update_enabled;default:1"`
|
||||
|
||||
// The filesystem path the system can be found under
|
||||
FilesystemBase string `gorm:"column:filesystem_base;not null"`
|
||||
|
||||
// SQL Database credentials for the system
|
||||
SqlDatabase string `gorm:"column:sql_database;not null"`
|
||||
SqlUser string `gorm:"column:sql_user;not null"`
|
||||
SqlPassword string `gorm:"column:sql_password;not null"`
|
||||
|
||||
// GraphDB Repository
|
||||
GraphDBRepository string `gorm:"column:graphdb_repository;not null"`
|
||||
GraphDBUser string `gorm:"column:graphdb_user;not null"`
|
||||
GraphDBPassword string `gorm:"column:graphdb_password;not null"`
|
||||
}
|
||||
|
||||
func (i Instance) IsBlindUpdateEnabled() bool {
|
||||
return bool(i.AutoBlindUpdateEnabled)
|
||||
}
|
||||
|
||||
// SQLBit1 implements a boolean as a BIT(1)
|
||||
type SQLBit1 bool
|
||||
|
||||
func (sb SQLBit1) Value() (driver.Value, error) {
|
||||
if sb {
|
||||
return []byte{1}, nil
|
||||
} else {
|
||||
return []byte{0}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var errBadBool = errors.New("SQLBit1: Database does not contain Bit(1)")
|
||||
|
||||
func (sb *SQLBit1) Scan(src interface{}) error {
|
||||
if bytes, ok := src.([]byte); ok && len(bytes) == 1 {
|
||||
*sb = bytes[0] == 1
|
||||
return nil
|
||||
}
|
||||
return errBadBool
|
||||
}
|
||||
46
pkg/execx/exec.go
Normal file
46
pkg/execx/exec.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Package execx defines extensions to the "os/exec" package
|
||||
package execx
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
)
|
||||
|
||||
// ExecCommandError is returned by Exec when a command could not be executed.
|
||||
// This typically hints that the executable cannot be found, but may have other causes.
|
||||
const ExecCommandError = 127
|
||||
|
||||
// Exec executes a system command with the specified input/output streams, working directory, and arguments.
|
||||
//
|
||||
// If the command executes, it's exit code will be returned.
|
||||
// If the command can not be executed, returns [ExecCommandError].
|
||||
func Exec(io stream.IOStream, workdir string, exe string, argv ...string) int {
|
||||
// setup the command
|
||||
cmd := exec.Command(exe, argv...)
|
||||
cmd.Dir = workdir
|
||||
cmd.Stdin = io.Stdin
|
||||
cmd.Stdout = io.Stdout
|
||||
cmd.Stderr = io.Stderr
|
||||
|
||||
// run it
|
||||
err := cmd.Run()
|
||||
|
||||
// non-zero exit
|
||||
if err, ok := err.(*exec.ExitError); ok {
|
||||
return err.ExitCode()
|
||||
}
|
||||
|
||||
// unknown error
|
||||
if err != nil {
|
||||
return ExecCommandError
|
||||
}
|
||||
|
||||
// everything is fine!
|
||||
return 0
|
||||
}
|
||||
|
||||
// MustExec is like Exec, except that it returns true if the command exited successfully, and else false.
|
||||
func MustExec(io stream.IOStream, workdir string, exe string, argv ...string) bool {
|
||||
return Exec(io, workdir, exe, argv...) == 0
|
||||
}
|
||||
15
pkg/execx/look.go
Normal file
15
pkg/execx/look.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package execx
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LookPathAbs is like [exec.LookPath], but always returns an absolute path
|
||||
func LookPathAbs(file string) (string, error) {
|
||||
path, err := exec.LookPath(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
107
pkg/fsx/copy.go
Normal file
107
pkg/fsx/copy.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package fsx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var ErrCopySameFile = errors.New("src and dst must be different files")
|
||||
|
||||
// CopyFile copies a file from src to dst.
|
||||
// When dst and src are the same file, returns ErrCopySameFile.
|
||||
func CopyFile(dst, src string) error {
|
||||
if SameFile(src, dst) {
|
||||
return ErrCopySameFile
|
||||
}
|
||||
|
||||
// open the source
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// stat it to get the mode!
|
||||
srcStat, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// open or create the destination
|
||||
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE, srcStat.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// and do the copy!
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
var ErrCopyNoDirectory = errors.New("dst is not a directory")
|
||||
|
||||
// CopyDirectory copies the directory src to dst recursively.
|
||||
// The destination directory must exist, or an error is returned.
|
||||
//
|
||||
// onCopy, when not nil, is called for each file or directory being copied.
|
||||
func CopyDirectory(dst, src string, onCopy func(dst, src string)) error {
|
||||
// sanity checks
|
||||
if SameFile(src, dst) {
|
||||
return ErrCopySameFile
|
||||
}
|
||||
if !IsDirectory(dst) {
|
||||
return ErrCopyNoDirectory
|
||||
}
|
||||
|
||||
// call onCopy for this directory!
|
||||
if onCopy != nil {
|
||||
onCopy(dst, src)
|
||||
}
|
||||
|
||||
// iterate over the entries or bail out
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
eDest := filepath.Join(dst, name)
|
||||
eSrc := filepath.Join(src, name)
|
||||
|
||||
// it is not a directory => Use CopyFile
|
||||
if !entry.IsDir() {
|
||||
if onCopy != nil {
|
||||
onCopy(eDest, eSrc)
|
||||
}
|
||||
|
||||
// do the copy!
|
||||
if err := CopyFile(eDest, eSrc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// find out the mode of the entry
|
||||
eInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// make the target directory
|
||||
if err := os.Mkdir(eDest, eInfo.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// do the copy!
|
||||
if err := CopyDirectory(eDest, eSrc, onCopy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
87
pkg/fsx/same.go
Normal file
87
pkg/fsx/same.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package fsx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// SameFile checks if path1 and path2 refer to the same file.
|
||||
// If both files exist, they are compared using [os.SameFile].
|
||||
// If both files do not exist, the paths are first compared syntactically and then via recursion on [filepath.Dir].
|
||||
func SameFile(path1, path2 string) bool {
|
||||
|
||||
// initial attempt: check if directly
|
||||
same, certain := couldBeSameFile(path1, path2)
|
||||
if certain {
|
||||
return same
|
||||
}
|
||||
|
||||
// second attempt: find the directory names and base paths
|
||||
d1, n1 := filepath.Dir(path1), filepath.Base(path1)
|
||||
d2, n2 := filepath.Dir(path2), filepath.Base(path2)
|
||||
|
||||
// if we have different file names (and they don't exist)
|
||||
// we don't need to continue
|
||||
if n1 != n2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// compare the base names!
|
||||
{
|
||||
same, _ := couldBeSameFile(d1, d2)
|
||||
return same
|
||||
}
|
||||
}
|
||||
|
||||
// couldBeSameFile checks if path1 might be the same as path2.
|
||||
//
|
||||
// If both files exist, compares using [os.SameFile].
|
||||
// Otherwise compares absolute paths using string comparison.
|
||||
//
|
||||
// same indicates if they might be the same file.
|
||||
// authorative indiciates if the result is authorative.
|
||||
func couldBeSameFile(path1, path2 string) (same, authorative bool) {
|
||||
{
|
||||
// stat both files
|
||||
info1, err1 := os.Stat(path1)
|
||||
info2, err2 := os.Stat(path2)
|
||||
|
||||
// both files exist => check using os.SameFile
|
||||
// the result is always authorative
|
||||
if err1 == nil && err2 == nil {
|
||||
same = os.SameFile(info1, info2)
|
||||
authorative = true
|
||||
return
|
||||
}
|
||||
|
||||
// only 1 file errored => they could be different
|
||||
if (err1 == nil) != (err2 == nil) {
|
||||
return
|
||||
}
|
||||
|
||||
// only 1 file does not exist => they could be different
|
||||
if os.IsNotExist(err1) != os.IsNotExist(err2) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// resolve paths absolutely
|
||||
rpath1, err1 := filepath.Abs(path1)
|
||||
rpath2, err2 := filepath.Abs(path2)
|
||||
|
||||
// if either path could not be resolved absolutely
|
||||
// fallback to just using clean!
|
||||
if err1 != nil {
|
||||
rpath1 = filepath.Clean(path1)
|
||||
}
|
||||
if err2 != nil {
|
||||
rpath2 = filepath.Clean(path2)
|
||||
}
|
||||
|
||||
// compare using strings
|
||||
same = rpath1 == rpath2
|
||||
authorative = same // positive result is authorative!
|
||||
return
|
||||
}
|
||||
}
|
||||
29
pkg/fsx/touch.go
Normal file
29
pkg/fsx/touch.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package fsx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Touch touches a file.
|
||||
// It is similar to the unix 'touch' command.
|
||||
//
|
||||
// If the file does not exist exists, it is created using [os.Create].
|
||||
// If the file does exist, it's access and modification times are updated to the current time.
|
||||
func Touch(path string) error {
|
||||
_, err := os.Stat(path)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
now := time.Now().Local()
|
||||
return os.Chtimes(path, now, now)
|
||||
}
|
||||
}
|
||||
24
pkg/fsx/type.go
Normal file
24
pkg/fsx/type.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Package fsx provides convenient abstractions to work with the filesystem.
|
||||
package fsx
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Exists checks if the given path exists
|
||||
func Exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDirectory checks if the provided path exists and is a directory
|
||||
func IsDirectory(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode().IsDir()
|
||||
}
|
||||
|
||||
// IsFile checks if the provided path exists and is a regular file
|
||||
func IsFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
32
pkg/hostname/hostname.go
Normal file
32
pkg/hostname/hostname.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Package hostname provides the hostname.
|
||||
package hostname
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Showmax/go-fqdn"
|
||||
)
|
||||
|
||||
// FQDN attempts to return the fully qualified domain name of the host system.
|
||||
// If an error occurs, may fall back to the empty string.
|
||||
func FQDN() string {
|
||||
|
||||
// try the hostname function
|
||||
{
|
||||
fqdn, err := fqdn.FqdnHostname()
|
||||
if err == nil {
|
||||
return fqdn
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to os hostname
|
||||
{
|
||||
hostname, err := os.Hostname()
|
||||
if err == nil {
|
||||
return hostname
|
||||
}
|
||||
}
|
||||
|
||||
// use the empty string
|
||||
return ""
|
||||
}
|
||||
59
pkg/logging/level.go
Normal file
59
pkg/logging/level.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
)
|
||||
|
||||
var logLevelMutex sync.Mutex
|
||||
var logLevelMap = make(map[uintptr]int)
|
||||
|
||||
func getIndent(io stream.IOStream) int {
|
||||
logLevelMutex.Lock()
|
||||
defer logLevelMutex.Unlock()
|
||||
|
||||
id, ok := logID(io)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return logLevelMap[id]
|
||||
}
|
||||
|
||||
func incIndent(io stream.IOStream) int {
|
||||
logLevelMutex.Lock()
|
||||
defer logLevelMutex.Unlock()
|
||||
|
||||
id, ok := logID(io)
|
||||
if !ok { // if we don't have an id, then inc statically returns 1
|
||||
return 1
|
||||
}
|
||||
|
||||
logLevelMap[id]++
|
||||
return logLevelMap[id]
|
||||
}
|
||||
|
||||
func decIndent(io stream.IOStream) int {
|
||||
logLevelMutex.Lock()
|
||||
defer logLevelMutex.Unlock()
|
||||
id, ok := logID(io)
|
||||
|
||||
if !ok { // if we don't have an id, then dec statically returns 0
|
||||
return 0
|
||||
}
|
||||
|
||||
logLevelMap[id]--
|
||||
if logLevelMap[id] < 0 {
|
||||
panic("DecLogIdent: decrease below 0")
|
||||
}
|
||||
return logLevelMap[id]
|
||||
}
|
||||
|
||||
func logID(io stream.IOStream) (uintptr, bool) {
|
||||
file, ok := io.Stdin.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return file.Fd(), true
|
||||
}
|
||||
30
pkg/logging/log.go
Normal file
30
pkg/logging/log.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
)
|
||||
|
||||
// LogOperation logs a message that is displayed to the user, and then increases the log indent level.
|
||||
func LogOperation(operation func() error, io stream.IOStream, format string, args ...interface{}) error {
|
||||
logOperation(io, getIndent(io), format, args...)
|
||||
incIndent(io)
|
||||
defer decIndent(io)
|
||||
|
||||
return operation()
|
||||
}
|
||||
|
||||
// LogMessage logs a message that is displayed to the user
|
||||
func LogMessage(io stream.IOStream, format string, args ...interface{}) (int, error) {
|
||||
return logOperation(io, getIndent(io), format, args...)
|
||||
}
|
||||
|
||||
func logOperation(io stream.IOStream, indent int, format string, args ...interface{}) (int, error) {
|
||||
message := "\033[1m" + strings.Repeat(" ", indent+1) + "=> " + format + "\033[0m\n"
|
||||
if !io.StdinIsATerminal() {
|
||||
message = " => " + format
|
||||
}
|
||||
|
||||
return io.Printf(message, args...)
|
||||
}
|
||||
46
pkg/password/password.go
Normal file
46
pkg/password/password.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Package password allows generating random passwords
|
||||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NOTE(twiesing): A bunch of scripts cannot properly handle the extra characters in the password.
|
||||
// For now it is disabled, but it should be re-enabled later.
|
||||
const PasswordCharSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // + "!@#$%&*"
|
||||
var passwordCharCount = big.NewInt(int64(len(PasswordCharSet)))
|
||||
|
||||
// Password returns a randomly generated string with the provided length.
|
||||
// It consists of alphanumeric characters only.
|
||||
//
|
||||
// When an error occurs, it is guaranteed to return "", err.
|
||||
// [rand.Reader] is used as the source of randomness.
|
||||
func Password(length int) (string, error) {
|
||||
if length < 0 {
|
||||
panic("length < 0")
|
||||
}
|
||||
|
||||
// create a buffer to write the string to!
|
||||
var password strings.Builder
|
||||
password.Grow(length)
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
|
||||
// grab a random bIndex!
|
||||
bIndex, err := rand.Int(rand.Reader, passwordCharCount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// and use that index!
|
||||
index := int(bIndex.Int64())
|
||||
if err := password.WriteByte(PasswordCharSet[index]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// return the password!
|
||||
return password.String(), nil
|
||||
}
|
||||
39
pkg/sqle/name.go
Normal file
39
pkg/sqle/name.go
Normal file
File diff suppressed because one or more lines are too long
10
pkg/sqle/sqle.go
Normal file
10
pkg/sqle/sqle.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package sqle
|
||||
|
||||
import (
|
||||
"github.com/feiin/sqlstring"
|
||||
)
|
||||
|
||||
// Format formats the provided query with the given parameters.
|
||||
func Format(query string, params ...interface{}) string {
|
||||
return sqlstring.Format(query, params...)
|
||||
}
|
||||
80
pkg/targz/targz.go
Normal file
80
pkg/targz/targz.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Package targz provides facilities for packaging tar.gz files
|
||||
package targz
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Package packages the source directory into a 'tar.gz' file into destination.
|
||||
// If the destination already exists, it is truncated.
|
||||
//
|
||||
// onCopy, when not nil, is called for each file being copied into the archive.
|
||||
func Package(dst, src string, onCopy func(rel string, src string)) (count int64, err error) {
|
||||
// create the target archive
|
||||
archive, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
// create a gzip writer
|
||||
zipHandle := gzip.NewWriter(archive)
|
||||
defer zipHandle.Close()
|
||||
|
||||
// create a tar writer
|
||||
tarHandle := tar.NewWriter(zipHandle)
|
||||
defer tarHandle.Close()
|
||||
|
||||
// and walk through it!
|
||||
err = filepath.Walk(src, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// determine the relative path
|
||||
var relpath string
|
||||
relpath, err = filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if onCopy != nil {
|
||||
onCopy(relpath, path)
|
||||
}
|
||||
|
||||
// create a file info header!
|
||||
tInfo, err := tar.FileInfoHeader(info, relpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tInfo.Name = filepath.ToSlash(relpath)
|
||||
|
||||
// write it!
|
||||
if err := tarHandle.WriteHeader(tInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// a directory => no more writing required
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// open the file
|
||||
handle, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
// and copy it into the archive
|
||||
ccount, err := io.Copy(tarHandle, handle)
|
||||
count += ccount
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
131
pkg/unpack/resource.go
Normal file
131
pkg/unpack/resource.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package unpack
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var errExpectedFileButGotDirectory = errors.New("expected a file, but got a directory")
|
||||
var errExpectedDirectoryButGotFile = errors.New("expected a directory, but got a file")
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// installResource installs the resource at src within fsys to dst.
|
||||
//
|
||||
// OnInstallFile is called for each source and destination file.
|
||||
// OnInstallFile may be nil.
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
233
pkg/unpack/template.go
Normal file
233
pkg/unpack/template.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Package unpack unpacks files and templates to a target directory.
|
||||
package unpack
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// ts represents state of the template parser
|
||||
type ts int
|
||||
|
||||
const (
|
||||
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.
|
||||
//
|
||||
// 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 {
|
||||
|
||||
// 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 {
|
||||
unusedKeys[key] = struct{}{}
|
||||
}
|
||||
|
||||
// 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 parsing the source
|
||||
break parseloop
|
||||
case err != nil:
|
||||
// the reader broke
|
||||
return err
|
||||
|
||||
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 == tsSawDollar && r == '{':
|
||||
// saw '{', following the '$'
|
||||
// => read everything else into the buffer
|
||||
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 == tsSawDollar:
|
||||
// saw anything else following the '$'
|
||||
// => write both back and switch back to gobble mode
|
||||
if _, err := dst.Write([]byte{byte('$'), byte(r)}); err != nil {
|
||||
return err
|
||||
}
|
||||
mode = tsGobble
|
||||
|
||||
case mode == tsGobbleVar && r != '}':
|
||||
// saw anything except for closing bracket
|
||||
// => keep it in the buffer
|
||||
if _, err := varB.WriteRune(r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case mode == tsGobbleVar:
|
||||
// saw a closing '}' inside tsGobbleVar mode
|
||||
// => use the variable
|
||||
|
||||
name := varB.String()
|
||||
|
||||
// get the variable from the context
|
||||
value, ok := 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
|
||||
if _, err := io.WriteString(dst, value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// reset the builder and go back into normal mode
|
||||
varB.Reset()
|
||||
mode = tsGobble
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// CLEANUP UNUSUED INPUT
|
||||
//
|
||||
|
||||
switch mode {
|
||||
case tsSawDollar:
|
||||
// we had a '$', but no '{'
|
||||
// => write the trailing '$' into dest
|
||||
if _, err := dst.Write([]byte("$")); err != nil {
|
||||
return err
|
||||
}
|
||||
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, varB.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
31
pkg/wait/wait.go
Normal file
31
pkg/wait/wait.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package wait
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Wait repeatedly invokes f, until it returns true or the context is closed.
|
||||
// The invocation interval is determined by interval.
|
||||
func Wait(f func() bool, interval time.Duration, context context.Context) error {
|
||||
// create a new timer
|
||||
timer := time.NewTimer(interval)
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
if f() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reset the timer, and wait for it again!
|
||||
timer.Reset(interval)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-context.Done():
|
||||
return context.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue