feat: allow fs embedding with --embed (#160)

* feat: allow fs embedding with `--embed`

* code review

* remove utils/io.go

* don't shadow `err`

* add syntax and examples to README
This commit is contained in:
Mohammed Al Sahaf 2023-12-18 23:22:47 +03:00 committed by GitHub
parent a38621145f
commit 2887af6a01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 258 additions and 5 deletions

View file

@ -62,6 +62,7 @@ Syntax:
$ xcaddy build [<caddy_version>] $ xcaddy build [<caddy_version>]
[--output <file>] [--output <file>]
[--with <module[@version][=replacement]>...] [--with <module[@version][=replacement]>...]
[--embed <[alias]:path/to/dir>...]
``` ```
- `<caddy_version>` is the core Caddy version to build; defaults to `CADDY_VERSION` env variable or latest.<br> - `<caddy_version>` is the core Caddy version to build; defaults to `CADDY_VERSION` env variable or latest.<br>
@ -72,6 +73,7 @@ $ xcaddy build [<caddy_version>]
- `--output` changes the output file. - `--output` changes the output file.
- `--with` can be used multiple times to add plugins by specifying the Go module name and optionally its version, similar to `go get`. Module name is required, but specific version and/or local replacement are optional. - `--with` can be used multiple times to add plugins by specifying the Go module name and optionally its version, similar to `go get`. Module name is required, but specific version and/or local replacement are optional.
- `--embed` can be used multiple times to embed directories into the built Caddy executable. The directory can be prefixed with a custom alias and a colon `:` to use it with the `root` directive and sub-directive.
Examples: Examples:
@ -107,6 +109,24 @@ $ xcaddy build \
This allows you to hack on Caddy core (and optionally plug in extra modules at the same time!) with relative ease. This allows you to hack on Caddy core (and optionally plug in extra modules at the same time!) with relative ease.
```
$ xcaddy build --embed foo:./sites/foo --embed bar:./sites/bar
$ cat Caddyfile
foo.localhost {
root * /foo
file_server {
fs embedded
}
}
bar.localhost {
root * /bar
file_server {
fs embedded
}
}
```
This allows you to serve 2 sites from 2 different embedded directories, which are referenced by aliases, from a single Caddy executable.
### For plugin development ### For plugin development

View file

@ -45,6 +45,12 @@ type Builder struct {
Debug bool `json:"debug,omitempty"` Debug bool `json:"debug,omitempty"`
BuildFlags string `json:"build_flags,omitempty"` BuildFlags string `json:"build_flags,omitempty"`
ModFlags string `json:"mod_flags,omitempty"` ModFlags string `json:"mod_flags,omitempty"`
// Experimental: subject to change
EmbedDirs []struct {
Dir string `json:"dir,omitempty"`
Name string `json:"name,omitempty"`
} `json:"embed_dir,omitempty"`
} }
// Build builds Caddy at the configured version with the // Build builds Caddy at the configured version with the
@ -66,6 +72,7 @@ func (b Builder) Build(ctx context.Context, outputFile string) error {
if err != nil { if err != nil {
return err return err
} }
log.Printf("[INFO] absolute output file path: %s", absOutputFile)
// set some defaults from the environment, if applicable // set some defaults from the environment, if applicable
if b.OS == "" { if b.OS == "" {

View file

@ -71,6 +71,7 @@ func runBuild(ctx context.Context, args []string) error {
var argCaddyVersion, output string var argCaddyVersion, output string
var plugins []xcaddy.Dependency var plugins []xcaddy.Dependency
var replacements []xcaddy.Replace var replacements []xcaddy.Replace
var embedDir []string
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "--with": case "--with":
@ -105,7 +106,12 @@ func runBuild(ctx context.Context, args []string) error {
} }
i++ i++
output = args[i] output = args[i]
case "--embed":
if i == len(args)-1 {
return fmt.Errorf("expected value after --embed flag")
}
i++
embedDir = append(embedDir, args[i])
default: default:
if argCaddyVersion != "" { if argCaddyVersion != "" {
return fmt.Errorf("missing flag; caddy version already set at %s", argCaddyVersion) return fmt.Errorf("missing flag; caddy version already set at %s", argCaddyVersion)
@ -139,6 +145,23 @@ func runBuild(ctx context.Context, args []string) error {
BuildFlags: buildFlags, BuildFlags: buildFlags,
ModFlags: modFlags, ModFlags: modFlags,
} }
for _, md := range embedDir {
if before, after, found := strings.Cut(md, ":"); found {
builder.EmbedDirs = append(builder.EmbedDirs, struct {
Dir string `json:"dir,omitempty"`
Name string `json:"name,omitempty"`
}{
after, before,
})
} else {
builder.EmbedDirs = append(builder.EmbedDirs, struct {
Dir string `json:"dir,omitempty"`
Name string `json:"name,omitempty"`
}{
before, "",
})
}
}
err := builder.Build(ctx, output) err := builder.Build(ctx, output)
if err != nil { if err != nil {
log.Fatalf("[FATAL] %v", err) log.Fatalf("[FATAL] %v", err)

View file

@ -86,11 +86,40 @@ func (b Builder) newEnvironment(ctx context.Context) (*environment, error) {
// write the main module file to temporary folder // write the main module file to temporary folder
mainPath := filepath.Join(tempFolder, "main.go") mainPath := filepath.Join(tempFolder, "main.go")
log.Printf("[INFO] Writing main module: %s\n%s", mainPath, buf.Bytes()) log.Printf("[INFO] Writing main module: %s\n%s", mainPath, buf.Bytes())
err = os.WriteFile(mainPath, buf.Bytes(), 0644) err = os.WriteFile(mainPath, buf.Bytes(), 0o644)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(b.EmbedDirs) > 0 {
for _, d := range b.EmbedDirs {
err = copy(d.Dir, filepath.Join(tempFolder, "files", d.Name))
if err != nil {
return nil, err
}
_, err = os.Stat(d.Dir)
if err != nil {
return nil, fmt.Errorf("embed directory does not exist: %s", d.Dir)
}
log.Printf("[INFO] Embedding directory: %s", d.Dir)
buf.Reset()
tpl, err = template.New("embed").Parse(embeddedModuleTemplate)
if err != nil {
return nil, err
}
err = tpl.Execute(&buf, tplCtx)
if err != nil {
return nil, err
}
log.Printf("[INFO] Writing 'embedded' module: %s\n%s", mainPath, buf.Bytes())
emedPath := filepath.Join(tempFolder, "embed.go")
err = os.WriteFile(emedPath, buf.Bytes(), 0o644)
if err != nil {
return nil, err
}
}
}
env := &environment{ env := &environment{
caddyVersion: b.CaddyVersion, caddyVersion: b.CaddyVersion,
plugins: b.Plugins, plugins: b.Plugins,
@ -337,3 +366,113 @@ func main() {
caddycmd.Main() caddycmd.Main()
} }
` `
// originally published in: https://github.com/mholt/caddy-embed
const embeddedModuleTemplate = `package main
import (
"embed"
"io/fs"
"strings"
"{{.CaddyModule}}"
"{{.CaddyModule}}/caddyconfig/caddyfile"
)
// embedded is what will contain your static files. The go command
// will automatically embed the files subfolder into this virtual
// file system. You can optionally change the go:embed directive
// to embed other files or folders.
//
//go:embed files
var embedded embed.FS
// files is the actual, more generic file system to be utilized.
var files fs.FS = embedded
// topFolder is the name of the top folder of the virtual
// file system. go:embed does not let us add the contents
// of a folder to the root of a virtual file system, so
// if we want to trim that root folder prefix, we need to
// also specify it in code as a string. Otherwise the
// user would need to add configuration or code to trim
// this root prefix from all filenames, e.g. specifying
// "root files" in their file_server config.
//
// It is NOT REQUIRED to change this if changing the
// go:embed directive; it is just for convenience in
// the default case.
const topFolder = "files"
func init() {
caddy.RegisterModule(FS{})
stripFolderPrefix()
}
// stripFolderPrefix opens the root of the file system. If it
// contains only 1 file, being a directory with the same
// name as the topFolder const, then the file system will
// be fs.Sub()'ed so the contents of the top folder can be
// accessed as if they were in the root of the file system.
// This is a convenience so most users don't have to add
// additional configuration or prefix their filenames
// unnecessarily.
func stripFolderPrefix() error {
if f, err := files.Open("."); err == nil {
defer f.Close()
if dir, ok := f.(fs.ReadDirFile); ok {
entries, err := dir.ReadDir(2)
if err == nil &&
len(entries) == 1 &&
entries[0].IsDir() &&
entries[0].Name() == topFolder {
if sub, err := fs.Sub(embedded, topFolder); err == nil {
files = sub
}
}
}
}
return nil
}
// FS implements a Caddy module and fs.FS for an embedded
// file system provided by an unexported package variable.
//
// To use, simply put your files in a subfolder called
// "files", then build Caddy with your local copy of this
// plugin. Your site's files will be embedded directly
// into the binary.
//
// If the embedded file system contains only one file in
// its root which is a folder named "files", this module
// will strip that folder prefix using fs.Sub(), so that
// the contents of the folder can be accessed by name as
// if they were in the actual root of the file system.
// In other words, before: files/foo.txt, after: foo.txt.
type FS struct{}
// CaddyModule returns the Caddy module information.
func (FS) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "caddy.fs.embedded",
New: func() caddy.Module { return new(FS) },
}
}
func (FS) Open(name string) (fs.File, error) {
// TODO: the file server doesn't clean up leading and trailing slashes, but embed.FS is particular so we remove them here; I wonder if the file server should be tidy in the first place
name = strings.Trim(name, "/")
return files.Open(name)
}
// UnmarshalCaddyfile exists so this module can be used in
// the Caddyfile, but there is nothing to unmarshal.
func (FS) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return nil }
// Interface guards
var (
_ fs.FS = (*FS)(nil)
_ caddyfile.Unmarshaler = (*FS)(nil)
)
`

2
go.mod
View file

@ -3,6 +3,6 @@ module github.com/caddyserver/xcaddy
go 1.14 go 1.14
require ( require (
github.com/Masterminds/semver/v3 v3.1.1 github.com/Masterminds/semver/v3 v3.2.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
) )

4
go.sum
View file

@ -1,4 +1,4 @@
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=

64
io.go Normal file
View file

@ -0,0 +1,64 @@
package xcaddy
// credit: https://github.com/goreleaser/goreleaser/blob/3f54b5eb2f13e86f07420124818fb6594f966278/internal/gio/copy.go
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
)
// copy recursively copies src into dst with src's file modes.
func copy(src, dst string) error {
src = filepath.ToSlash(src)
dst = filepath.ToSlash(dst)
log.Printf("[INFO] copying files: src=%s dest=%s", src, dst)
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("failed to copy %s to %s: %w", src, dst, err)
}
path = filepath.ToSlash(path)
// We have the following:
// - src = "a/b"
// - dst = "dist/linuxamd64/b"
// - path = "a/b/c.txt"
// So we join "a/b" with "c.txt" and use it as the destination.
dst := filepath.ToSlash(filepath.Join(dst, strings.Replace(path, src, "", 1)))
if info.IsDir() {
return os.MkdirAll(dst, info.Mode())
}
if info.Mode()&os.ModeSymlink != 0 {
return copySymlink(path, dst)
}
return copyFile(path, dst, info.Mode())
})
}
func copySymlink(src, dst string) error {
src, err := os.Readlink(src)
if err != nil {
return err
}
return os.Symlink(src, dst)
}
func copyFile(src, dst string, mode os.FileMode) error {
original, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open '%s': %w", src, err)
}
defer original.Close()
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("failed to open '%s': %w", dst, err)
}
defer f.Close()
if _, err := io.Copy(f, original); err != nil {
return fmt.Errorf("failed to copy: %w", err)
}
return nil
}