Skip to content

Commit

Permalink
Support context cancellation and cleanup
Browse files Browse the repository at this point in the history
This should avoid leaving temporary build directories lingering after
Ctrl+C.
  • Loading branch information
mholt committed Apr 13, 2020
1 parent 97dd328 commit dcc00b0
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 49 deletions.
19 changes: 13 additions & 6 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package xcaddy

import (
"context"
"fmt"
"io/ioutil"
"log"
Expand All @@ -35,11 +36,12 @@ import (
type Builder struct {
CaddyVersion string `json:"caddy_version,omitempty"`
Plugins []Dependency `json:"plugins,omitempty"`
Replacements []Replace `json:"replacements,omitempty"`
}

// Build builds Caddy at the configured version with the
// configured plugins and plops down a binary at outputFile.
func (b Builder) Build(outputFile string) error {
func (b Builder) Build(ctx context.Context, outputFile string) error {
if b.CaddyVersion == "" {
return fmt.Errorf("CaddyVersion must be set")
}
Expand All @@ -55,7 +57,7 @@ func (b Builder) Build(outputFile string) error {
return err
}

env, err := b.newEnvironment()
env, err := b.newEnvironment(ctx)
if err != nil {
return err
}
Expand All @@ -69,7 +71,7 @@ func (b Builder) Build(outputFile string) error {
"-trimpath",
)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
err = env.runCommand(cmd, 5*time.Minute)
err = env.runCommand(ctx, cmd, 5*time.Minute)
if err != nil {
return err
}
Expand All @@ -88,10 +90,15 @@ type Dependency struct {

// The version of the Go module, like used with `go get`.
Version string
}

// Replace represents a Go module replacement.
type Replace struct {
// The import path of the module being replaced.
Old string

// Optional path to a replacement module. Equivalent to
// a `replace` directive in go.mod.
Replace string
// The path to the replacement module.
New string
}

// newTempFolder creates a new folder in a temporary location.
Expand Down
68 changes: 57 additions & 11 deletions cmd/xcaddy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
package main

import (
"context"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
Expand All @@ -28,20 +30,24 @@ import (
)

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go trapSignals(ctx, cancel)

if len(os.Args) > 1 && os.Args[1] == "build" {
if err := runBuild(os.Args[2:]); err != nil {
if err := runBuild(ctx, os.Args[2:]); err != nil {
log.Fatalf("[ERROR] %v", err)
}
return
}

// TODO: the caddy version needs to be settable by the user... maybe an env var?
if err := runDev("v2.0.0-rc.1", os.Args[1:]); err != nil {
if err := runDev(ctx, "v2.0.0-rc.3", os.Args[1:]); err != nil {
log.Fatalf("[ERROR] %v", err)
}
}

func runBuild(args []string) error {
func runBuild(ctx context.Context, args []string) error {
// parse the command line args... rather primitively
var caddyVersion, output string
var plugins []xcaddy.Dependency
Expand Down Expand Up @@ -93,7 +99,7 @@ func runBuild(args []string) error {
CaddyVersion: caddyVersion,
Plugins: plugins,
}
err := builder.Build(output)
err := builder.Build(ctx, output)
if err != nil {
log.Fatalf("[FATAL] %v", err)
}
Expand All @@ -115,7 +121,7 @@ func runBuild(args []string) error {
return nil
}

func runDev(caddyVersion string, args []string) error {
func runDev(ctx context.Context, caddyVersion string, args []string) error {
const binOutput = "./caddy"

// get current/main module name
Expand All @@ -134,17 +140,44 @@ func runDev(caddyVersion string, args []string) error {
}
moduleDir := strings.TrimSpace(string(out))

// make sure the module being developed is replaced
// so that the local copy is used
replacements := []xcaddy.Replace{
{
Old: currentModule,
New: moduleDir,
},
}

// replace directives only apply to the top-level/main go.mod,
// and since this tool is a carry-through for the user's actual
// go.mod, we need to transfer their replace directives through
// to the one we're making
cmd = exec.Command("go", "list", "-m", "-f={{if .Replace}}{{.Path}} => {{.Replace}}{{end}}", "all")
out, err = cmd.Output()
if err != nil {
return err
}
for _, line := range strings.Split(string(out), "\n") {
parts := strings.Split(line, "=>")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
continue
}
replacements = append(replacements, xcaddy.Replace{
Old: strings.TrimSpace(parts[0]),
New: strings.TrimSpace(parts[1]),
})
}

// build caddy with this module plugged in
builder := xcaddy.Builder{
CaddyVersion: caddyVersion,
Plugins: []xcaddy.Dependency{
{
ModulePath: currentModule,
Replace: moduleDir,
},
{ModulePath: currentModule},
},
Replacements: replacements,
}
err = builder.Build(binOutput)
err = builder.Build(ctx, binOutput)
if err != nil {
return err
}
Expand All @@ -168,9 +201,22 @@ func runDev(caddyVersion string, args []string) error {
}
defer cleanup()
go func() {
time.Sleep(2 * time.Second)
time.Sleep(5 * time.Second)
cleanup()
}()

return cmd.Wait()
}

func trapSignals(ctx context.Context, cancel context.CancelFunc) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)

select {
case <-sig:
log.Printf("[INFO] SIGINT: Shutting down")
cancel()
case <-ctx.Done():
return
}
}
99 changes: 67 additions & 32 deletions environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package xcaddy

import (
"bytes"
"context"
"fmt"
"html/template"
"io/ioutil"
Expand All @@ -27,7 +28,7 @@ import (
"time"
)

func (b Builder) newEnvironment() (*environment, error) {
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, ".") {
Expand All @@ -47,11 +48,11 @@ func (b Builder) newEnvironment() (*environment, error) {
}

// create the context for the main module template
ctx := moduleTemplateContext{
tplCtx := goModTemplateContext{
CaddyModule: caddyModulePath,
}
for _, p := range b.Plugins {
ctx.Plugins = append(ctx.Plugins, p.ModulePath)
tplCtx.Plugins = append(tplCtx.Plugins, p.ModulePath)
}

// evaluate the template for the main module
Expand All @@ -60,7 +61,7 @@ func (b Builder) newEnvironment() (*environment, error) {
if err != nil {
return nil, err
}
err = tpl.Execute(&buf, ctx)
err = tpl.Execute(&buf, tplCtx)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -98,39 +99,52 @@ func (b Builder) newEnvironment() (*environment, error) {
// initialize the go module
log.Println("[INFO] Initializing Go module")
cmd := env.newCommand("go", "mod", "init", "caddy")
err = env.runCommand(cmd, 10*time.Second)
err = env.runCommand(ctx, cmd, 10*time.Second)
if err != nil {
return nil, err
}

// specify module replacements before pinning versions
for _, p := range b.Plugins {
if p.Replace == "" {
continue
}
log.Printf("[INFO] Replace %s => %s", p.ModulePath, p.Replace)
replaced := make(map[string]string)
for _, r := range b.Replacements {
log.Printf("[INFO] Replace %s => %s", r.Old, r.New)
cmd := env.newCommand("go", "mod", "edit",
"-replace", fmt.Sprintf("%s=%s", p.ModulePath, p.Replace))
err := env.runCommand(cmd, 10*time.Second)
"-replace", fmt.Sprintf("%s=%s", r.Old, r.New))
err := env.runCommand(ctx, cmd, 10*time.Second)
if err != nil {
return nil, err
}
replaced[r.Old] = r.New
}

// check for early abort
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}

// pin versions by populating go.mod, first for Caddy itself and then plugins
log.Println("[INFO] Pinning versions")
err = env.execGoGet(caddyModulePath, b.CaddyVersion)
err = env.execGoGet(ctx, caddyModulePath, b.CaddyVersion)
if err != nil {
return nil, err
}
for _, p := range b.Plugins {
if p.Replace != "" {
// if module is locally available; do not "go get" it
if replaced[p.ModulePath] != "" {
continue
}
err = env.execGoGet(p.ModulePath, p.Version)
err = env.execGoGet(ctx, p.ModulePath, p.Version)
if err != nil {
return nil, err
}
// check for early abort
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}

log.Println("[INFO] Build environment ready")
Expand Down Expand Up @@ -160,38 +174,59 @@ func (env environment) newCommand(command string, args ...string) *exec.Cmd {
return cmd
}

func (env environment) runCommand(cmd *exec.Cmd, timeout time.Duration) error {
func (env environment) runCommand(ctx context.Context, cmd *exec.Cmd, timeout time.Duration) error {
log.Printf("[INFO] exec (timeout=%s): %+v ", timeout, cmd)

// no timeout? this is easy; just run it
if timeout == 0 {
return cmd.Run()
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}

// otherwise start it and use a timer
// start the command; if it fails to start, report error immediately
err := cmd.Start()
if err != nil {
return err
}
timer := time.AfterFunc(timeout, func() {
err = fmt.Errorf("timed out (builder-enforced)")
cmd.Process.Kill()
})
waitErr := cmd.Wait()
timer.Stop()
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()
}
return waitErr
}

func (env environment) execGoGet(modulePath, moduleVersion string) error {
func (env environment) execGoGet(ctx context.Context, modulePath, moduleVersion string) error {
mod := modulePath + "@" + moduleVersion
cmd := env.newCommand("go", "get", "-d", "-v", mod)
return env.runCommand(cmd, 60*time.Second)
return env.runCommand(ctx, cmd, 5*time.Minute)
}

type moduleTemplateContext struct {
type goModTemplateContext struct {
CaddyModule string
Plugins []string
}
Expand Down

0 comments on commit dcc00b0

Please sign in to comment.