Skip to content

Commit

Permalink
Add support for reloadable process types (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
thitch97 authored Nov 17, 2021
1 parent 5d79025 commit a31bc1a
Show file tree
Hide file tree
Showing 15 changed files with 879 additions and 51 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ command is generated from the contents of `package.json`. For example, given a

The start command will be `<prestart-command> && <start-command> && <poststart-command>`.

## Enabling reloadable process types

You can configure this buildpack to wrap the entrypoint process of your app
such that it kills and restarts the process whenever files change in the app's working
directory in the container. With this feature enabled, copying new
verisons of source code into the running container will trigger your app's
process to restart. Set the environment variable `BP_LIVE_RELOAD_ENABLED=true`
at build time to enable this feature.

## Integration

This CNB sets a start command, so there's currently no scenario we can
Expand Down
48 changes: 40 additions & 8 deletions build.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/paketo-buildpacks/packit"
"github.com/paketo-buildpacks/packit/scribe"
Expand Down Expand Up @@ -58,22 +59,53 @@ func Build(pathParser PathParser, logger scribe.Logger) packit.BuildFunc {
command = fmt.Sprintf("cd %s && %s", projectPath, command)
}

processes := []packit.Process{
{
Type: "web",
Command: command,
Default: true,
},
}

shouldReload, err := checkLiveReloadEnabled()
if err != nil {
return packit.BuildResult{}, err
}

if shouldReload {
processes = []packit.Process{
{
Type: "web",
Command: strings.Join([]string{
"watchexec",
"--restart",
fmt.Sprintf("--watch %s", filepath.Join(context.WorkingDir, projectPath)),
fmt.Sprintf("--ignore %s", filepath.Join(context.WorkingDir, projectPath, "package.json")),
fmt.Sprintf("--ignore %s", filepath.Join(context.WorkingDir, projectPath, "package-lock.json")),
fmt.Sprintf("--ignore %s", filepath.Join(context.WorkingDir, projectPath, "node_modules")),
fmt.Sprintf(`"%s"`, command),
}, " "),
Default: true,
},
{
Type: "no-reload",
Command: command,
},
}
}

logger.Process("Assigning launch processes")
logger.Subprocess("web: %s", command)
for _, process := range processes {
logger.Subprocess("%s: %s", process.Type, process.Command)
}
logger.Break()

return packit.BuildResult{
Plan: packit.BuildpackPlan{
Entries: []packit.BuildpackPlanEntry{},
},
Launch: packit.LaunchMetadata{
Processes: []packit.Process{
{
Type: "web",
Command: command,
Default: true,
},
},
Processes: processes,
},
}, nil
}
Expand Down
77 changes: 77 additions & 0 deletions build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package npmstart_test

import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

npmstart "github.com/paketo-buildpacks/npm-start"
Expand Down Expand Up @@ -99,6 +101,54 @@ func testBuild(t *testing.T, context spec.G, it spec.S) {
Expect(buffer.String()).To(ContainSubstring("Assigning launch processes"))
})

context("when BP_LIVE_RELOAD_ENABLED=true in the build environment", func() {
it.Before(func() {
os.Setenv("BP_LIVE_RELOAD_ENABLED", "true")
})

it.After(func() {
os.Unsetenv("BP_LIVE_RELOAD_ENABLED")
})

it("adds a reloadable start command that ignores package manager files and makes it the default", func() {
result, err := build(packit.BuildContext{
WorkingDir: workingDir,
CNBPath: cnbDir,
Stack: "some-stack",
BuildpackInfo: packit.BuildpackInfo{
Name: "Some Buildpack",
Version: "some-version",
},
Plan: packit.BuildpackPlan{
Entries: []packit.BuildpackPlanEntry{},
},
Layers: packit.Layers{Path: layersDir},
})
Expect(err).NotTo(HaveOccurred())

Expect(result.Launch.Processes).To(Equal([]packit.Process{
{
Type: "web",
Command: strings.Join([]string{
"watchexec",
"--restart",
fmt.Sprintf("--watch %s/some-project-dir", workingDir),
fmt.Sprintf("--ignore %s/some-project-dir/package.json", workingDir),
fmt.Sprintf("--ignore %s/some-project-dir/package-lock.json", workingDir),
fmt.Sprintf("--ignore %s/some-project-dir/node_modules", workingDir),
`"cd some-project-dir && some-prestart-command && some-start-command && some-poststart-command"`,
}, " "),
Default: true,
},
{
Type: "no-reload",
Command: "cd some-project-dir && some-prestart-command && some-start-command && some-poststart-command",
},
}))
Expect(pathParser.GetCall.Receives.Path).To(Equal(workingDir))
})
})

context("when there is no prestart script", func() {
it.Before(func() {
err := os.WriteFile(filepath.Join(workingDir, "some-project-dir", "package.json"), []byte(`{
Expand Down Expand Up @@ -327,5 +377,32 @@ func testBuild(t *testing.T, context spec.G, it spec.S) {
Expect(err).To(MatchError(ContainSubstring("invalid character '%'")))
})
})

context("when BP_LIVE_RELOAD_ENABLED is set to an invalid value", func() {
it.Before(func() {
os.Setenv("BP_LIVE_RELOAD_ENABLED", "not-a-bool")
})

it.After(func() {
os.Unsetenv("BP_LIVE_RELOAD_ENABLED")
})

it("returns an error", func() {
_, err := build(packit.BuildContext{
WorkingDir: workingDir,
CNBPath: cnbDir,
Stack: "some-stack",
BuildpackInfo: packit.BuildpackInfo{
Name: "Some Buildpack",
Version: "some-version",
},
Plan: packit.BuildpackPlan{
Entries: []packit.BuildpackPlanEntry{},
},
Layers: packit.Layers{Path: layersDir},
})
Expect(err).To(MatchError(ContainSubstring("failed to parse BP_LIVE_RELOAD_ENABLED value not-a-bool")))
})
})
})
}
68 changes: 48 additions & 20 deletions detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"

"github.com/paketo-buildpacks/packit"
)
Expand All @@ -28,29 +29,56 @@ func Detect(projectPathParser PathParser) packit.DetectFunc {
return packit.DetectResult{}, fmt.Errorf("failed to stat package.json: %w", err)
}

requirements := []packit.BuildPlanRequirement{
{
Name: Node,
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: Npm,
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: NodeModules,
Metadata: map[string]interface{}{
"launch": true,
},
},
}

shouldReload, err := checkLiveReloadEnabled()
if err != nil {
return packit.DetectResult{}, err
}

if shouldReload {
requirements = append(requirements, packit.BuildPlanRequirement{
Name: "watchexec",
Metadata: map[string]interface{}{
"launch": true,
},
})
}

return packit.DetectResult{
Plan: packit.BuildPlan{
Requires: []packit.BuildPlanRequirement{
{
Name: Node,
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: Npm,
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: NodeModules,
Metadata: map[string]interface{}{
"launch": true,
},
},
},
Requires: requirements,
},
}, nil
}
}

func checkLiveReloadEnabled() (bool, error) {
if reload, ok := os.LookupEnv("BP_LIVE_RELOAD_ENABLED"); ok {
shouldEnableReload, err := strconv.ParseBool(reload)
if err != nil {
return false, fmt.Errorf("failed to parse BP_LIVE_RELOAD_ENABLED value %s: %w", reload, err)
}
return shouldEnableReload, nil
}
return false, nil
}
66 changes: 63 additions & 3 deletions detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package npmstart_test

import (
"errors"
"io/ioutil"
"os"
"path/filepath"
"testing"
Expand All @@ -26,7 +25,7 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {

it.Before(func() {
var err error
workingDir, err = ioutil.TempDir("", "working-dir")
workingDir, err = os.MkdirTemp("", "working-dir")
Expect(err).NotTo(HaveOccurred())
Expect(os.Mkdir(filepath.Join(workingDir, "custom"), os.ModePerm)).To(Succeed())

Expand All @@ -42,7 +41,7 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {

context("when there is a package.json", func() {
it.Before(func() {
Expect(ioutil.WriteFile(filepath.Join(workingDir, "custom", "package.json"), nil, 0644)).To(Succeed())
Expect(os.WriteFile(filepath.Join(workingDir, "custom", "package.json"), nil, 0644)).To(Succeed())
})
it("detects", func() {
result, err := detect(packit.DetectContext{
Expand Down Expand Up @@ -73,6 +72,49 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {
}))
Expect(projectPathParser.GetCall.Receives.Path).To(Equal(filepath.Join(workingDir)))
})

context("and BP_LIVE_RELOAD_ENABLED = true", func() {
it.Before(func() {
os.Setenv("BP_LIVE_RELOAD_ENABLED", "true")
})
it.After(func() {
os.Unsetenv("BP_LIVE_RELOAD_ENABLED")
})
it("requires watchexec at launch", func() {
result, err := detect(packit.DetectContext{
WorkingDir: workingDir,
})
Expect(err).NotTo(HaveOccurred())
Expect(result.Plan).To(Equal(packit.BuildPlan{
Requires: []packit.BuildPlanRequirement{
{
Name: "node",
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: "npm",
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: "node_modules",
Metadata: map[string]interface{}{
"launch": true,
},
},
{
Name: "watchexec",
Metadata: map[string]interface{}{
"launch": true,
},
},
},
}))
})
})
})

context("when there is no package.json", func() {
Expand Down Expand Up @@ -114,5 +156,23 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {
Expect(err).To(MatchError("some-error"))
})
})

context("when BP_LIVE_RELOAD_ENABLED is set to an invalid value", func() {
it.Before(func() {
Expect(os.WriteFile(filepath.Join(workingDir, "custom", "package.json"), nil, 0644)).To(Succeed())
os.Setenv("BP_LIVE_RELOAD_ENABLED", "not-a-bool")
})

it.After(func() {
os.Unsetenv("BP_LIVE_RELOAD_ENABLED")
})

it("returns an error", func() {
_, err := detect(packit.DetectContext{
WorkingDir: workingDir,
})
Expect(err).To(MatchError(ContainSubstring("failed to parse BP_LIVE_RELOAD_ENABLED value not-a-bool")))
})
})
})
}
21 changes: 21 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,29 @@ go 1.16

require (
github.com/BurntSushi/toml v0.4.1
github.com/Microsoft/go-winio v0.5.1 // indirect
github.com/Microsoft/hcsshim v0.9.1 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/containerd/cgroups v1.0.2 // indirect
github.com/containerd/continuity v0.2.1 // indirect
github.com/docker/docker v20.10.10+incompatible
github.com/fatih/color v1.13.0 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/moby/sys/mount v0.3.0 // indirect
github.com/moby/sys/symlink v0.2.0 // indirect
github.com/onsi/gomega v1.17.0
github.com/opencontainers/selinux v1.9.1 // indirect
github.com/paketo-buildpacks/occam v0.1.4
github.com/paketo-buildpacks/packit v1.3.1
github.com/sclevine/spec v1.4.0
github.com/vbatts/tar-split v0.11.2 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462 // indirect
golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20211117155847-120650a500bb // indirect
google.golang.org/grpc v1.42.0 // indirect
)
Loading

0 comments on commit a31bc1a

Please sign in to comment.