Skip to content

Commit

Permalink
feat: run local --watch flag (aws#5413)
Browse files Browse the repository at this point in the history
Implements the `--watch` flag for `run local` which watches your copilot workspace for file changes and restarts the docker containers when you make changes.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License.
  • Loading branch information
CaptainCarpensir authored Nov 7, 2023
1 parent e5aebd9 commit 90d3af3
Show file tree
Hide file tree
Showing 11 changed files with 774 additions and 78 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/fatih/color v1.16.0
github.com/fatih/structs v1.1.0
github.com/fsnotify/fsnotify v1.7.0
github.com/golang/mock v1.6.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.4.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
Expand Down
38 changes: 38 additions & 0 deletions internal/pkg/cli/file/filetest/watchertest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package filetest

import "github.com/fsnotify/fsnotify"

// Double is a test double for file.RecursiveWatcher
type Double struct {
EventsFn func() <-chan fsnotify.Event
ErrorsFn func() <-chan error
}

// Add is a no-op for Double.
func (d *Double) Add(string) error {
return nil
}

// Close is a no-op for Double.
func (d *Double) Close() error {
return nil
}

// Events calls the stubbed function.
func (d *Double) Events() <-chan fsnotify.Event {
if d.EventsFn == nil {
return nil
}
return d.EventsFn()
}

// Errors calls the stubbed function.
func (d *Double) Errors() <-chan error {
if d.ErrorsFn == nil {
return nil
}
return d.ErrorsFn()
}
13 changes: 13 additions & 0 deletions internal/pkg/cli/file/hidden.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !windows

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package file

import "path/filepath"

// IsHiddenFile returns true if the file is hidden on non-windows. The filename must be non-empty.
func IsHiddenFile(filename string) (bool, error) {
return filepath.Base(filename)[0] == '.', nil
}
21 changes: 21 additions & 0 deletions internal/pkg/cli/file/hidden_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package file

import (
"syscall"
)

// IsHiddenFile returns true if the file is hidden on windows.
func IsHiddenFile(filename string) (bool, error) {
pointer, err := syscall.UTF16PtrFromString(filename)
if err != nil {
return false, err
}
attributes, err := syscall.GetFileAttributes(pointer)
if err != nil {
return false, err
}
return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil
}
100 changes: 100 additions & 0 deletions internal/pkg/cli/file/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package file

import (
"io/fs"
"path/filepath"

"github.com/fsnotify/fsnotify"
)

// RecursiveWatcher wraps an fsnotify Watcher to recursively watch all files in a directory.
type RecursiveWatcher struct {
fsnotifyWatcher *fsnotify.Watcher
done chan struct{}
closed bool
events chan fsnotify.Event
errors chan error
}

// NewRecursiveWatcher returns a RecursiveWatcher which notifies when changes are made to files inside a recursive directory tree.
func NewRecursiveWatcher(buffer uint) (*RecursiveWatcher, error) {
watcher, err := fsnotify.NewBufferedWatcher(buffer)
if err != nil {
return nil, err
}

rw := &RecursiveWatcher{
events: make(chan fsnotify.Event, buffer),
errors: make(chan error),
fsnotifyWatcher: watcher,
done: make(chan struct{}),
closed: false,
}

go rw.start()

return rw, nil
}

// Add recursively adds a directory tree to the list of watched files.
func (rw *RecursiveWatcher) Add(path string) error {
if rw.closed {
return fsnotify.ErrClosed
}
return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
// swallow error from WalkDir, don't attempt to add to watcher.
return nil
}
if d.IsDir() {
return rw.fsnotifyWatcher.Add(p)
}
return nil
})
}

// Events returns the events channel.
func (rw *RecursiveWatcher) Events() <-chan fsnotify.Event {
return rw.events
}

// Errors returns the errors channel.
func (rw *RecursiveWatcher) Errors() <-chan error {
return rw.errors
}

// Close closes the RecursiveWatcher.
func (rw *RecursiveWatcher) Close() error {
if rw.closed {
return nil
}
rw.closed = true
close(rw.done)
return rw.fsnotifyWatcher.Close()
}

func (rw *RecursiveWatcher) start() {
for {
select {
case <-rw.done:
close(rw.events)
close(rw.errors)
return
case event := <-rw.fsnotifyWatcher.Events:
// handle recursive watch
switch event.Op {
case fsnotify.Create:
if err := rw.Add(event.Name); err != nil {
rw.errors <- err
}
}

rw.events <- event
case err := <-rw.fsnotifyWatcher.Errors:
rw.errors <- err
}
}
}
141 changes: 141 additions & 0 deletions internal/pkg/cli/file/watch_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//go:build integration || localintegration

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package file_test

import (
"fmt"
"io/fs"
"os"
"testing"
"time"

"github.com/aws/copilot-cli/internal/pkg/cli/file"
"github.com/fsnotify/fsnotify"
"github.com/stretchr/testify/require"
)

func TestRecursiveWatcher(t *testing.T) {
var (
watcher *file.RecursiveWatcher
tmp string
eventsExpected []fsnotify.Event
eventsActual []fsnotify.Event
)

tmp = os.TempDir()
eventsActual = make([]fsnotify.Event, 0)
eventsExpected = []fsnotify.Event{
{
Name: fmt.Sprintf("%s/watch/subdir/testfile", tmp),
Op: fsnotify.Create,
},
{
Name: fmt.Sprintf("%s/watch/subdir/testfile", tmp),
Op: fsnotify.Chmod,
},
{
Name: fmt.Sprintf("%s/watch/subdir/testfile", tmp),
Op: fsnotify.Write,
},
{
Name: fmt.Sprintf("%s/watch/subdir/testfile", tmp),
Op: fsnotify.Write,
},
{
Name: fmt.Sprintf("%s/watch/subdir", tmp),
Op: fsnotify.Rename,
},
{
Name: fmt.Sprintf("%s/watch/subdir2", tmp),
Op: fsnotify.Create,
},
{
Name: fmt.Sprintf("%s/watch/subdir", tmp),
Op: fsnotify.Rename,
},
{
Name: fmt.Sprintf("%s/watch/subdir2/testfile", tmp),
Op: fsnotify.Rename,
},
{
Name: fmt.Sprintf("%s/watch/subdir2/testfile2", tmp),
Op: fsnotify.Create,
},
{
Name: fmt.Sprintf("%s/watch/subdir2/testfile2", tmp),
Op: fsnotify.Remove,
},
}

t.Run("Setup Watcher", func(t *testing.T) {
err := os.MkdirAll(fmt.Sprintf("%s/watch/subdir", tmp), 0755)
require.NoError(t, err)

watcher, err = file.NewRecursiveWatcher(uint(len(eventsExpected)))
require.NoError(t, err)
})

t.Run("Watch", func(t *testing.T) {
// SETUP
err := watcher.Add(fmt.Sprintf("%s/watch", tmp))
require.NoError(t, err)

eventsCh := watcher.Events()
errorsCh := watcher.Errors()

expectEvents := func(t *testing.T, n int) []fsnotify.Event {
receivedEvents := []fsnotify.Event{}
for i := 0; i < n; i++ {
select {
case e := <-eventsCh:
receivedEvents = append(receivedEvents, e)
case <-time.After(time.Second):
}
}
return receivedEvents
}

// WATCH
file, err := os.Create(fmt.Sprintf("%s/watch/subdir/testfile", tmp))
require.NoError(t, err)
eventsActual = append(eventsActual, expectEvents(t, 1)...)

err = os.Chmod(fmt.Sprintf("%s/watch/subdir/testfile", tmp), 0755)
require.NoError(t, err)
eventsActual = append(eventsActual, expectEvents(t, 1)...)

err = os.WriteFile(fmt.Sprintf("%s/watch/subdir/testfile", tmp), []byte("write to file"), fs.ModeAppend)
require.NoError(t, err)
eventsActual = append(eventsActual, expectEvents(t, 2)...)

err = file.Close()
require.NoError(t, err)

err = os.Rename(fmt.Sprintf("%s/watch/subdir", tmp), fmt.Sprintf("%s/watch/subdir2", tmp))
require.NoError(t, err)
eventsActual = append(eventsActual, expectEvents(t, 3)...)

err = os.Rename(fmt.Sprintf("%s/watch/subdir2/testfile", tmp), fmt.Sprintf("%s/watch/subdir2/testfile2", tmp))
require.NoError(t, err)
eventsActual = append(eventsActual, expectEvents(t, 2)...)

err = os.Remove(fmt.Sprintf("%s/watch/subdir2/testfile2", tmp))
require.NoError(t, err)
eventsActual = append(eventsActual, expectEvents(t, 1)...)

// CLOSE
err = watcher.Close()
require.NoError(t, err)
require.Empty(t, errorsCh)

require.Equal(t, eventsExpected, eventsActual)
})

t.Run("Clean", func(t *testing.T) {
err := os.RemoveAll(fmt.Sprintf("%s/watch", tmp))
require.NoError(t, err)
})
}
2 changes: 2 additions & 0 deletions internal/pkg/cli/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const (
envVarOverrideFlag = "env-var-override"
proxyFlag = "proxy"
proxyNetworkFlag = "proxy-network"
watchFlag = "watch"

// Flags for CI/CD.
githubURLFlag = "github-url"
Expand Down Expand Up @@ -324,6 +325,7 @@ Format: [container]:KEY=VALUE. Omit container name to apply to all containers.`
Example: --port-override 5000:80 binds localhost:5000 to the service's port 80.`
proxyFlagDescription = `Optional. Proxy outbound requests to your environment's VPC.`
proxyNetworkFlagDescription = `Optional. Set the IP Network used by --proxy.`
watchFlagDescription = `Optional. Watch changes to local files and restart containers when updated.`

svcManifestFlagDescription = `Optional. Name of the environment in which the service was deployed;
output the manifest file used for that deployment.`
Expand Down
Loading

0 comments on commit 90d3af3

Please sign in to comment.