xcaddy/environment.go

335 lines
9.6 KiB
Go

// Copyright 2020 Matthew Holt
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package xcaddy
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/caddyserver/xcaddy/internal/utils"
"github.com/google/shlex"
)
func (b Builder) newEnvironment(ctx context.Context) (*environment, error) {
// assume Caddy v2 if no semantic version is provided
caddyModulePath := defaultCaddyModulePath
if !strings.HasPrefix(b.CaddyVersion, "v") || !strings.Contains(b.CaddyVersion, ".") {
caddyModulePath += "/v2"
}
caddyModulePath, err := versionedModulePath(caddyModulePath, b.CaddyVersion)
if err != nil {
return nil, err
}
// clean up any SIV-incompatible module paths real quick
for i, p := range b.Plugins {
b.Plugins[i].PackagePath, err = versionedModulePath(p.PackagePath, p.Version)
if err != nil {
return nil, err
}
}
// create the context for the main module template
tplCtx := goModTemplateContext{
CaddyModule: caddyModulePath,
}
for _, p := range b.Plugins {
tplCtx.Plugins = append(tplCtx.Plugins, p.PackagePath)
}
// evaluate the template for the main module
var buf bytes.Buffer
tpl, err := template.New("main").Parse(mainModuleTemplate)
if err != nil {
return nil, err
}
err = tpl.Execute(&buf, tplCtx)
if err != nil {
return nil, err
}
// create the folder in which the build environment will operate
tempFolder, err := newTempFolder()
if err != nil {
return nil, err
}
defer func() {
if err != nil {
err2 := os.RemoveAll(tempFolder)
if err2 != nil {
err = fmt.Errorf("%w; additionally, cleaning up folder: %v", err, err2)
}
}
}()
log.Printf("[INFO] Temporary folder: %s", tempFolder)
// write the main module file to temporary folder
mainPath := filepath.Join(tempFolder, "main.go")
log.Printf("[INFO] Writing main module: %s\n%s", mainPath, buf.Bytes())
err = os.WriteFile(mainPath, buf.Bytes(), 0644)
if err != nil {
return nil, err
}
env := &environment{
caddyVersion: b.CaddyVersion,
plugins: b.Plugins,
caddyModulePath: caddyModulePath,
tempFolder: tempFolder,
timeoutGoGet: b.TimeoutGet,
skipCleanup: b.SkipCleanup,
buildFlags: b.BuildFlags,
modFlags: b.ModFlags,
}
// initialize the go module
log.Println("[INFO] Initializing Go module")
cmd := env.newGoModCommand(ctx, "init")
cmd.Args = append(cmd.Args, "caddy")
err = env.runCommand(ctx, cmd)
if err != nil {
return nil, err
}
// specify module replacements before pinning versions
replaced := make(map[string]string)
for _, r := range b.Replacements {
log.Printf("[INFO] Replace %s => %s", r.Old.String(), r.New.String())
cmd := env.newGoModCommand(ctx, "edit",
"-replace", fmt.Sprintf("%s=%s", r.Old.Param(), r.New.Param()))
err := env.runCommand(ctx, cmd)
if err != nil {
return nil, err
}
replaced[r.Old.String()] = r.New.String()
}
// check for early abort
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// The timeout for the `go get` command may be different than `go build`,
// so create a new context with the timeout for `go get`
if env.timeoutGoGet > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), env.timeoutGoGet)
defer cancel()
}
// pin versions by populating go.mod, first for Caddy itself and then plugins
log.Println("[INFO] Pinning versions")
err = env.execGoGet(ctx, caddyModulePath, env.caddyVersion, "", "")
if err != nil {
return nil, err
}
nextPlugin:
for _, p := range b.Plugins {
// if module is locally available, do not "go get" it;
// also note that we iterate and check prefixes, because
// a plugin package may be a subfolder of a module, i.e.
// foo/a/plugin is within module foo/a.
for repl := range replaced {
if strings.HasPrefix(p.PackagePath, repl) {
continue nextPlugin
}
}
// also pass the Caddy version to prevent it from being upgraded
err = env.execGoGet(ctx, p.PackagePath, p.Version, caddyModulePath, env.caddyVersion)
if err != nil {
return nil, err
}
// check for early abort
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
// doing an empty "go get -d" can potentially resolve some
// ambiguities introduced by one of the plugins;
// see https://github.com/caddyserver/xcaddy/pull/92
err = env.execGoGet(ctx, "", "", "", "")
if err != nil {
return nil, err
}
log.Println("[INFO] Build environment ready")
return env, nil
}
type environment struct {
caddyVersion string
plugins []Dependency
caddyModulePath string
tempFolder string
timeoutGoGet time.Duration
skipCleanup bool
buildFlags string
modFlags string
}
// Close cleans up the build environment, including deleting
// the temporary folder from the disk.
func (env environment) Close() error {
if env.skipCleanup {
log.Printf("[INFO] Skipping cleanup as requested; leaving folder intact: %s", env.tempFolder)
return nil
}
log.Printf("[INFO] Cleaning up temporary folder: %s", env.tempFolder)
return os.RemoveAll(env.tempFolder)
}
func (env environment) newCommand(ctx context.Context, command string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, command, args...)
cmd.Dir = env.tempFolder
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd
}
// newGoBuildCommand creates a new *exec.Cmd which assumes the first element in `args` is one of: build, clean, get, install, list, run, or test. The
// created command will also have the value of `XCADDY_GO_BUILD_FLAGS` appended to its arguments, if set.
func (env environment) newGoBuildCommand(ctx context.Context, args ...string) *exec.Cmd {
cmd := env.newCommand(ctx, utils.GetGo(), args...)
return parseAndAppendFlags(cmd, env.buildFlags)
}
// newGoModCommand creates a new *exec.Cmd which assumes `args` are the args for `go mod` command. The
// created command will also have the value of `XCADDY_GO_MOD_FLAGS` appended to its arguments, if set.
func (env environment) newGoModCommand(ctx context.Context, args ...string) *exec.Cmd {
args = append([]string{"mod"}, args...)
cmd := env.newCommand(ctx, utils.GetGo(), args...)
return parseAndAppendFlags(cmd, env.modFlags)
}
func parseAndAppendFlags(cmd *exec.Cmd, flags string) *exec.Cmd {
if strings.TrimSpace(flags) == "" {
return cmd
}
fs, err := shlex.Split(flags)
if err != nil {
log.Printf("[ERROR] Splitting arguments failed: %s", flags)
return cmd
}
cmd.Args = append(cmd.Args, fs...)
return cmd
}
func (env environment) runCommand(ctx context.Context, cmd *exec.Cmd) error {
deadline, _ := ctx.Deadline()
log.Printf("[INFO] exec (timeout=%s): %+v ", time.Until(deadline), cmd)
// start the command; if it fails to start, report error immediately
err := cmd.Start()
if err != nil {
return err
}
// wait for the command in a goroutine; the reason for this is
// very subtle: if, in our select, we do `case cmdErr := <-cmd.Wait()`,
// then that case would be chosen immediately, because cmd.Wait() is
// immediately available (even though it blocks for potentially a long
// time, it can be evaluated immediately). So we have to remove that
// evaluation from the `case` statement.
cmdErrChan := make(chan error)
go func() {
cmdErrChan <- cmd.Wait()
}()
// unblock either when the command finishes, or when the done
// channel is closed -- whichever comes first
select {
case cmdErr := <-cmdErrChan:
// process ended; report any error immediately
return cmdErr
case <-ctx.Done():
// context was canceled, either due to timeout or
// maybe a signal from higher up canceled the parent
// context; presumably, the OS also sent the signal
// to the child process, so wait for it to die
select {
case <-time.After(15 * time.Second):
_ = cmd.Process.Kill()
case <-cmdErrChan:
}
return ctx.Err()
}
}
// execGoGet runs "go get -d -v" with the given module/version as an argument.
// Also allows passing in a second module/version pair, meant to be the main
// Caddy module/version we're building against; this will prevent the
// plugin module from causing the Caddy version to upgrade, if the plugin
// version requires a newer version of Caddy.
// See https://github.com/caddyserver/xcaddy/issues/54
func (env environment) execGoGet(ctx context.Context, modulePath, moduleVersion, caddyModulePath, caddyVersion string) error {
mod := modulePath
if moduleVersion != "" {
mod += "@" + moduleVersion
}
caddy := caddyModulePath
if caddyVersion != "" {
caddy += "@" + caddyVersion
}
cmd := env.newGoBuildCommand(ctx, "get", "-d", "-v")
// using an empty string as an additional argument to "go get"
// breaks the command since it treats the empty string as a
// distinct argument, so we're using an if statement to avoid it.
if caddy != "" {
cmd.Args = append(cmd.Args, mod, caddy)
} else {
cmd.Args = append(cmd.Args, mod)
}
return env.runCommand(ctx, cmd)
}
type goModTemplateContext struct {
CaddyModule string
Plugins []string
}
const mainModuleTemplate = `package main
import (
caddycmd "{{.CaddyModule}}/cmd"
// plug in Caddy modules here
_ "{{.CaddyModule}}/modules/standard"
{{- range .Plugins}}
_ "{{.}}"
{{- end}}
)
func main() {
caddycmd.Main()
}
`