diff --git a/.github/actions/os-info/README.md b/.github/actions/os-info/README.md new file mode 100644 index 0000000..26c225d --- /dev/null +++ b/.github/actions/os-info/README.md @@ -0,0 +1,28 @@ +# OS Info GitHub Action + +This action detects and outputs information about the current operating system and architecture directly to stdout. + +## Information Provided + +The action outputs the following information directly to the console: + +| Information | Description | +|-------------|-------------| +| OS | The operating system (Linux, macOS, Windows) | +| Architecture | The architecture (amd64, arm64, etc.) | +| OS Version | The OS version or distribution | + +## Example Usage + +```yaml +jobs: + example-job: + runs-on: ubuntu-latest + steps: + - name: Get OS Info + uses: launchrctl/launchr/.github/actions/os-info@main + # The action will output OS information directly to the console + # No need to capture or use outputs in subsequent steps + + - uses: actions/checkout@v4 +``` diff --git a/.github/actions/os-info/action.yml b/.github/actions/os-info/action.yml new file mode 100644 index 0000000..6e9aa3b --- /dev/null +++ b/.github/actions/os-info/action.yml @@ -0,0 +1,41 @@ +name: 'OS Info' +description: 'Get information about the current operating system and architecture' + +runs: + using: "composite" + steps: + - name: OS and Architecture (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + # Set OS + echo "OS Type: Linux" + echo "Architecture: $(uname -m)" + echo "Kernel version: $(uname -r)" + if [ -f /etc/os-release ]; then + . /etc/os-release + echo "OS Version: $NAME $VERSION_ID" + else + echo "OS Version: unknown" + fi + + - name: OS and Architecture (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + echo "OS: macOS" + echo "Architecture: $(uname -m)" + # Get OS Version + OS_VERSION="$(sw_vers -productName) $(sw_vers -productVersion)" + echo "OS Version: $OS_VERSION" + + - name: OS and Architecture (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $arch = [System.Environment]::GetEnvironmentVariable("PROCESSOR_ARCHITECTURE") + $winVer = [System.Environment]::OSVersion.Version.ToString() + + Write-Host "OS: Windows" + Write-Host "Architecture: $arch" + Write-Host "OS Version: Windows $winVer" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..080c4c5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,16 @@ +name: CI +on: + push: + branches: + - '**' + paths-ignore: + - 'README.md' + - 'LICENSE' + - '.gitignore' + - 'example/**' + - 'docs/**' + +jobs: + common-tests: + name: Test + uses: ./.github/workflows/common-tests.yaml diff --git a/.github/workflows/common-tests.yaml b/.github/workflows/common-tests.yaml new file mode 100644 index 0000000..a5dc3f4 --- /dev/null +++ b/.github/workflows/common-tests.yaml @@ -0,0 +1,62 @@ +name: Test +on: + workflow_call: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: Test ${{ matrix.name }} + strategy: + matrix: + include: + - name: Linux (amd64) + os: ubuntu-latest + - name: Linux (arm64) + os: ubuntu-24.04-arm + - name: MacOS (amd64) + os: macos-13 + - name: MacOS (arm64) + os: macos-latest + - name: Windows (amd64) + os: windows-latest + continue-on-error: true # TODO: Windows is not well supported + - name: Windows (arm64) + os: windows-11-arm + continue-on-error: true + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.continue-on-error || false }} + steps: + - uses: actions/checkout@v4 + + - name: OS Info + uses: ./.github/actions/os-info + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Prepare dependencies + run: go mod download + + - name: Test + id: run-tests + run: | + go test ./... + + lint: + name: Lint code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Test + id: run-tests + run: make lint diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 0000000..5824cea --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,52 @@ +name: Debug with SSH Access + +on: + workflow_dispatch: + inputs: + os: + description: 'Operating System' + required: true + default: 'Ubuntu LTS (amd64)' + type: choice + options: + - 'Ubuntu LTS (amd64)' + - 'Ubuntu LTS (arm64)' + - 'macOS Latest (arm64)' + - 'macOS 13 (amd64)' + - 'Windows Latest (amd64)' + - 'Windows Latest (arm64)' + +jobs: + debug: + name: Debug Environment + runs-on: >- + ${{ + { + 'Ubuntu LTS (amd64)': 'ubuntu-latest', + 'Ubuntu LTS (arm64)': 'ubuntu-24.04-arm', + 'macOS Latest (arm64)': 'macos-latest', + 'macOS 13 (amd64)': 'macos-13', + 'Windows Latest (amd64)': 'windows-latest' + 'Windows Latest (arm64)': 'windows-11-arm' + }[github.event.inputs.os] + }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Install dependencies + run: | + go mod download + go install github.com/go-delve/delve/cmd/dlv@latest + + - name: Setup debug session + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + timeout-minutes: 30 diff --git a/Makefile b/Makefile index 2e9a3cb..9c17cc9 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ deps: .PHONY: test test: $(info Running tests...) - go test ./... + go test -short ./... # Build launchr .PHONY: build diff --git a/cmd/launchr/launchr.go b/cmd/launchr/main.go similarity index 100% rename from cmd/launchr/launchr.go rename to cmd/launchr/main.go diff --git a/docs/test.md b/docs/test.md new file mode 100644 index 0000000..bde0783 --- /dev/null +++ b/docs/test.md @@ -0,0 +1,12 @@ +# Application test + +[//]: # (TODO: lint) +[//]: # (TODO: go test unit) +[//]: # (TODO: testscript in go) +[//]: # (TODO: testscript separaterly) +[//]: # (TODO: Reusing GitHub workflow) +[//]: # (TODO: Local tests of other OS + Linux: win/macos - docker container dockur/windows and dockur/macos. lima for other linux distro. + MacOS: win - UTM free, Parallels paid. Linux - lima. + Windows: WSL2 for linux + dockur/macos for macos. + ) diff --git a/gen.go b/gen.go index ddc8103..a836804 100644 --- a/gen.go +++ b/gen.go @@ -20,7 +20,7 @@ func (app *appImpl) gen() error { // Set absolute paths. config.WorkDir = launchr.MustAbs(config.WorkDir) config.BuildDir = launchr.MustAbs(config.BuildDir) - // Change working directory to the selected. + // Change the working directory to the selected. err = os.Chdir(config.WorkDir) if err != nil { return err diff --git a/go.mod b/go.mod index 659d5ab..ad425cd 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/moby/sys/signal v0.7.1 github.com/moby/term v0.5.2 github.com/pterm/pterm v0.12.80 + github.com/rogpeppe/go-internal v1.14.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -70,6 +71,7 @@ require ( golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/term v0.29.0 // indirect golang.org/x/time v0.7.0 // indirect + golang.org/x/tools v0.26.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/go.sum b/go.sum index 51587de..c06f621 100644 --- a/go.sum +++ b/go.sum @@ -329,6 +329,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -523,6 +525,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/launchr/filepath.go b/internal/launchr/filepath.go index a594108..4714b66 100644 --- a/internal/launchr/filepath.go +++ b/internal/launchr/filepath.go @@ -7,6 +7,7 @@ import ( osuser "os/user" "path/filepath" "reflect" + "strings" ) // MustAbs returns absolute filepath and panics on error. @@ -62,7 +63,20 @@ func EnsurePath(parts ...string) error { // IsHiddenPath checks if a path is hidden path. func IsHiddenPath(path string) bool { - return isHiddenPath(path) + return isDotPath(path) || isHiddenPath(path) +} + +func isDotPath(path string) bool { + if path == "." { + return false + } + dirs := strings.Split(filepath.ToSlash(path), "/") + for _, v := range dirs { + if v[0] == '.' { + return true + } + } + return false } // IsSystemPath checks if a path is a system path. @@ -157,6 +171,16 @@ func MkdirTemp(pattern string) (string, error) { if dirPath == "" { return "", fmt.Errorf("failed to create temp directory") } + return dirPath, nil +} + +// MkdirTempWithCleanup creates a temporary directory with MkdirTemp. +// The temp directory is removed when the app terminates. +func MkdirTempWithCleanup(pattern string) (string, error) { + dirPath, err := MkdirTemp(pattern) + if err != nil { + return "", err + } // Make sure the dir is cleaned on finish. RegisterCleanupFn(func() error { @@ -165,3 +189,12 @@ func MkdirTemp(pattern string) (string, error) { return dirPath, nil } + +// EscapePathString escapes characters that may be +// incorrectly treated as a string like backshash "\" in a Windows path. +func EscapePathString(s string) string { + if filepath.Separator == '/' { + return s + } + return strings.Replace(s, "\\", "\\\\", -1) +} diff --git a/internal/launchr/filepath_test.go b/internal/launchr/filepath_test.go index 8128b55..6943f62 100644 --- a/internal/launchr/filepath_test.go +++ b/internal/launchr/filepath_test.go @@ -11,7 +11,7 @@ import ( func TestMkdirTemp(t *testing.T) { t.Parallel() - dir, err := MkdirTemp("test") + dir, err := MkdirTempWithCleanup("test") require.NoError(t, err) require.NotEmpty(t, dir) stat, err := os.Stat(dir) diff --git a/internal/launchr/filepath_unix.go b/internal/launchr/filepath_unix.go index 9d0241b..676beee 100644 --- a/internal/launchr/filepath_unix.go +++ b/internal/launchr/filepath_unix.go @@ -4,7 +4,6 @@ package launchr import ( "path/filepath" - "strings" ) var skipRootDirs = []string{ @@ -46,17 +45,7 @@ var skipUserDirs = []string{ } func isHiddenPath(path string) bool { - if path == "." { - return false - } - dirs := strings.Split(path, string(filepath.Separator)) - for _, v := range dirs { - if v[0] == '.' { - return true - } - } - - return false + return isDotPath(path) } func isRootPath(path string) bool { diff --git a/internal/launchr/lockedfile_windows.go b/internal/launchr/lockedfile_windows.go index 8113bae..419c6c1 100644 --- a/internal/launchr/lockedfile_windows.go +++ b/internal/launchr/lockedfile_windows.go @@ -31,7 +31,7 @@ func (f *LockedFile) unlock() { ol := new(windows.Overlapped) err := windows.UnlockFileEx(windows.Handle(f.file.Fd()), 0, allBytes, allBytes, ol) if err != nil { - Log().Warn("unlock is called on a not locked file: %s", err) + Log().Warn("unlock is called on a not locked file", "err", err) } f.locked = false } diff --git a/internal/launchr/types.go b/internal/launchr/types.go index 01530e7..f95052d 100644 --- a/internal/launchr/types.go +++ b/internal/launchr/types.go @@ -91,6 +91,7 @@ type AppVersion struct { CoreVersion string CoreReplace string Plugins []string + PluginsRepl []string } // PluginInfo provides information about the plugin and is used as a unique data to identify a plugin. diff --git a/internal/launchr/version.go b/internal/launchr/version.go index 8f3a20f..fba835c 100644 --- a/internal/launchr/version.go +++ b/internal/launchr/version.go @@ -43,13 +43,14 @@ func NewVersion(name, ver, bwith string, plugins PluginsMap) *AppVersion { buildInfo, _ := debug.ReadBuildInfo() // Add self as a dependency to get version for it also. buildInfo.Deps = append(buildInfo.Deps, &buildInfo.Main) - // Check core version when built or used in a plugin. + // Check a core version when built or used in a plugin. var coreRep string coreVer, coreRep := getCoreInfo(ver, buildInfo) if bwith == "" { ver = coreVer } + plver, plrepl := getPluginModules(plugins, buildInfo) return &AppVersion{ Name: name, Version: ver, @@ -58,7 +59,8 @@ func NewVersion(name, ver, bwith string, plugins PluginsMap) *AppVersion { CoreVersion: coreVer, CoreReplace: coreRep, BuiltWith: bwith, - Plugins: getPluginModules(plugins, buildInfo), + Plugins: plver, + PluginsRepl: plrepl, } } @@ -101,12 +103,13 @@ func getCoreInfo(v string, bi *debug.BuildInfo) (ver string, repl string) { return } -func getPluginModules(plugins PluginsMap, bi *debug.BuildInfo) []string { +func getPluginModules(plugins PluginsMap, bi *debug.BuildInfo) (res []string, repl []string) { if bi == nil { - return nil + return } - res := make([]string, 0, len(plugins)) + res = make([]string, 0, len(plugins)) + repl = make([]string, 0, len(plugins)) for pi := range plugins { if strings.HasPrefix(pi.pkgPath, PkgPath) { // Do not include info about the default package. @@ -116,15 +119,18 @@ func getPluginModules(plugins PluginsMap, bi *debug.BuildInfo) []string { // Path may be empty on "go run". if d.Path != "" && strings.HasPrefix(pi.pkgPath, d.Path) { s := fmt.Sprintf("%s %s", pi.pkgPath, d.Version) + r := s if d.Replace != nil { - s = fmt.Sprintf("%s => %s %s", s, d.Replace.Path, d.Replace.Version) + r = fmt.Sprintf("%s => %s %s", r, d.Replace.Path, d.Replace.Version) } res = append(res, s) + repl = append(res, r) } } } sort.Strings(res) - return res + sort.Strings(repl) + return } var versionTmpl = template.Must(template.New("version").Parse(versionTmplStr)) @@ -140,9 +146,9 @@ Core version: {{.CoreVersion}} {{- if .CoreReplace}} Core replace: {{.CoreReplace}} {{- end}} -{{- if .Plugins}} +{{- if .PluginsRepl}} Plugins: - {{- range .Plugins}} + {{- range .PluginsRepl}} - {{.}} {{- end}} {{end}}` diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..7ae4a5d --- /dev/null +++ b/main_test.go @@ -0,0 +1,48 @@ +package launchr + +import ( + "runtime" + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestMain(m *testing.M) { + testscript.Main(m, map[string]func(){ + "launchr": RunAndExit, + }) +} + +// TestScriptBuild tests how binary builds and outputs version. +func TestScriptBuild(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + testscript.Run(t, testscript.Params{ + Dir: "test/testdata/build", + RequireExplicitExec: true, + RequireUniqueNames: true, + Setup: func(env *testscript.Env) error { + repoPath := MustAbs("./") + env.Vars = append( + env.Vars, + "REPO_PATH="+repoPath, + "CORE_PKG="+PkgPath, + ) + return nil + }, + }) +} + +func TestScriptCommon(t *testing.T) { + t.Parallel() + testscript.Run(t, testscript.Params{ + Dir: "test/testdata/common", + RequireExplicitExec: true, + RequireUniqueNames: true, + ContinueOnError: true, + }) +} diff --git a/pkg/action/action.go b/pkg/action/action.go index 7f65579..2ccb0d9 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -153,7 +153,7 @@ func (a *Action) syncToDisk() (err error) { // Export to a temporary path. // Make sure the path doesn't have semicolons, because Docker bind doesn't like it. tmpDirName := strings.Replace(a.ID, ":", "_", -1) - tmpDir, err := launchr.MkdirTemp(tmpDirName) + tmpDir, err := launchr.MkdirTempWithCleanup(tmpDirName) if err != nil { return } diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index dfa8379..1df5bf4 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -3,7 +3,6 @@ package action import ( "context" "fmt" - "io/fs" "os" "path/filepath" "strings" @@ -32,8 +31,8 @@ func Test_Action(t *testing.T) { // Test dir assert.Equal(filepath.Dir(act.fpath), act.Dir()) - act.fpath = "test/file/path/action.yaml" - assert.Equal("test/file/path", act.Dir()) + act.fpath = filepath.FromSlash("test/file/path/action.yaml") + assert.Equal(filepath.FromSlash("test/file/path"), act.Dir()) // Test arguments and options. inputArgs := InputParams{"arg1": "arg1", "arg2": "arg2", "arg-1": "arg-1", "arg_12": "arg_12_enum1"} @@ -101,20 +100,11 @@ func Test_Action(t *testing.T) { func Test_Action_NewYAMLFromFS(t *testing.T) { t.Parallel() - // Prepare FS. - fsys := genFsTestMapActions(1, validFullYaml, genPathTypeArbitrary) - // Get first key to make subdir. - var key string - for key = range fsys { - // There is only 1 entry, we get the only key. - break - } - - // Create action. - subfs, _ := fs.Sub(fsys, filepath.Dir(key)) - a, err := NewYAMLFromFS("test", subfs) - require.NotNil(t, a) + // Create action in memory FS. + fsys := genFsTestMapActions(1, validFullYaml, genPathTypeRoot) + a, err := NewYAMLFromFS("test", fsys) require.NoError(t, err) + require.NotNil(t, a) assert.Equal(t, "test", a.ID) require.NoError(t, a.EnsureLoaded()) assert.Equal(t, "Title", a.ActionDef().Title) diff --git a/pkg/action/discover.go b/pkg/action/discover.go index 445aebb..c0951f6 100644 --- a/pkg/action/discover.go +++ b/pkg/action/discover.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "sort" - "strings" "sync" "time" @@ -16,7 +15,7 @@ import ( const actionsDirname = "actions" -var actionsSubdir = strings.Join([]string{"", actionsDirname, ""}, string(filepath.Separator)) +var actionsSubdir = filepath.FromSlash("/" + actionsDirname + "/") // DiscoveryPlugin is a launchr plugin to discover actions. type DiscoveryPlugin interface { @@ -183,6 +182,19 @@ func (ad *Discovery) Discover(ctx context.Context) ([]*Action, error) { // Traverse the FS. chFiles, chErr := ad.findFiles(ctx) + + // Check traversing the tree didn't have error. + // Usually no error, because we check for permissions. + var discoverErr error + errDone := make(chan struct{}) + go func() { + defer close(errDone) + if err := <-chErr; err != nil { + discoverErr = err + } + }() + + // Process files. for f := range chFiles { wg.Add(1) go func(f string) { @@ -196,10 +208,9 @@ func (ad *Discovery) Discover(ctx context.Context) ([]*Action, error) { } wg.Wait() - // Check traversing the tree didn't have error. - // Usually no error, because we check for permissions. - if err := <-chErr; err != nil { - return nil, err + // Wait for error handling to complete + if <-errDone; discoverErr != nil { + return nil, discoverErr } // Sort alphabetically. diff --git a/pkg/action/discover_test.go b/pkg/action/discover_test.go index b98bddd..37d1572 100644 --- a/pkg/action/discover_test.go +++ b/pkg/action/discover_test.go @@ -120,6 +120,12 @@ func Test_Discover_isValid(t *testing.T) { {"incorrect hidden subdir path", "1/2/.github/actions/3/action.yml", false}, // Invalid hidden subdirectory. {"nested action", "1/2/actions/3/4/5/action.yaml", false}, // There is a deeper nesting in actions directory. {"root action", "actions/verb/action.yaml", true}, // Actions are located in root. + {"special chars action in root 1", "actions/foo bar/action.yaml", false}, // Actions are located in root and with special characters. + {"special chars action in root 2", "actions/foo!bar/action.yaml", false}, // Actions are located in root and with special characters. + {"special chars action 1", "?/actions/foo/action.yaml", false}, // Actions with special characters. + {"special chars action 2", "foo/actions/foo<>bar/action.yaml", false}, // Actions with special characters. + {"special chars action 3", "foo/!<>/actions/foo\\bar/action.yaml", false}, // Actions with special characters. + {"special chars action 4", "foo bar/actions/baz/action.yaml", false}, // Actions with special characters. {"root myactions", "myactions/verb/action.yaml", false}, // Actions are located in dir ending with actions. {"dir", "1/2/actions/3", false}, // A directory is given. } diff --git a/pkg/action/loader.go b/pkg/action/loader.go index 9c01a55..75dded9 100644 --- a/pkg/action/loader.go +++ b/pkg/action/loader.go @@ -187,13 +187,13 @@ func addPredefinedVariables(data map[string]any, a *Action) { data["current_uid"] = s[0] data["current_gid"] = s[1] } - data["current_working_dir"] = a.wd // app working directory - data["actions_base_dir"] = a.fs.Realpath() // root directory where the action was found - data["action_dir"] = a.Dir() // directory of action file + data["current_working_dir"] = launchr.EscapePathString(a.wd) // app working directory + data["actions_base_dir"] = launchr.EscapePathString(a.fs.Realpath()) // root directory where the action was found + data["action_dir"] = launchr.EscapePathString(a.Dir()) // directory of action file // Get the path of the executable on the host. bin, err := os.Executable() if err != nil { bin = launchr.Version().Name } - data["current_bin"] = bin + data["current_bin"] = launchr.EscapePathString(bin) } diff --git a/pkg/action/test_utils.go b/pkg/action/test_utils.go index 08f0218..9c6c34a 100644 --- a/pkg/action/test_utils.go +++ b/pkg/action/test_utils.go @@ -7,8 +7,9 @@ import ( "testing" "testing/fstest" - "github.com/docker/docker/pkg/namesgenerator" "github.com/stretchr/testify/assert" + + "github.com/launchrctl/launchr/pkg/driver" ) type genPathType int @@ -17,18 +18,22 @@ const ( genPathTypeValid genPathType = iota // genPathTypeValid is a valid actions path genPathTypeArbitrary // genPathTypeArbitrary is a random string without actions directory. genPathTypeGHActions // genPathTypeGHActions is an incorrect hidden path but with actions directory. + genPathTypeRoot // genPathTypeRoot is a path in root. ) func genActionPath(d int, pathType genPathType) string { + if pathType == genPathTypeRoot { + d = 0 + } elems := make([]string, 0, d+3) for i := 0; i < d; i++ { - elems = append(elems, namesgenerator.GetRandomName(0)) + elems = append(elems, driver.GetRandomName(0)) } switch pathType { case genPathTypeValid: - elems = append(elems, actionsDirname, namesgenerator.GetRandomName(0)) + elems = append(elems, actionsDirname, driver.GetRandomName(0)) case genPathTypeGHActions: - elems = append(elems, ".github", actionsDirname, namesgenerator.GetRandomName(0)) + elems = append(elems, ".github", actionsDirname, driver.GetRandomName(0)) case genPathTypeArbitrary: // Do nothing. default: diff --git a/pkg/action/yaml.discovery.go b/pkg/action/yaml.discovery.go index f633d8d..b124e0f 100644 --- a/pkg/action/yaml.discovery.go +++ b/pkg/action/yaml.discovery.go @@ -4,13 +4,14 @@ import ( "bufio" "bytes" "io" + "path/filepath" "regexp" "sync" ) var ( // rgxYamlFilepath is a regex for a yaml path with unix and windows support. - rgxYamlFilepath = regexp.MustCompile(`(^actions|.*[\\/]actions)[\\/][^\\/]+[\\/]action\.y(a)?ml$`) + rgxYamlFilepath = regexp.MustCompile(`^(actions|[^\s!<>:"|?*]+/actions)/[^\s!<>:"|?*/]+/action\.y(a)?ml$`) // rgxYamlRootFile is a regex for a yaml file located in root dir only. rgxYamlRootFile = regexp.MustCompile(`^action\.y(a)?ml$`) ) @@ -28,7 +29,7 @@ type YamlDiscoveryStrategy struct { // IsValid implements [DiscoveryStrategy]. func (y YamlDiscoveryStrategy) IsValid(path string) bool { - return y.TargetRgx.MatchString(path) + return y.TargetRgx.MatchString(filepath.ToSlash(path)) } // Loader implements [DiscoveryStrategy]. diff --git a/plugins/builder/builder.go b/plugins/builder/builder.go index bbfd3cf..265407f 100644 --- a/plugins/builder/builder.go +++ b/plugins/builder/builder.go @@ -77,7 +77,7 @@ type buildVars struct { Cwd string } -// NewBuilder creates build environment. +// NewBuilder creates a build environment. func NewBuilder(opts *BuildOptions) (*Builder, error) { wd, err := os.Getwd() if err != nil { @@ -94,7 +94,7 @@ func (b *Builder) Build(ctx context.Context, streams launchr.Streams) error { launchr.Term().Info().Printfln("Starting to build %s", b.PkgName) // Prepare build environment dir and go executable. var err error - b.env, err = newBuildEnvironment(streams) + b.env, err = newBuildEnvironment(streams, b.Debug) if err != nil { return err } diff --git a/plugins/builder/environment.go b/plugins/builder/environment.go index 58ab6bd..3a353cf 100644 --- a/plugins/builder/environment.go +++ b/plugins/builder/environment.go @@ -58,8 +58,14 @@ type buildEnvironment struct { streams launchr.Streams } -func newBuildEnvironment(streams launchr.Streams) (*buildEnvironment, error) { - tmpDir, err := launchr.MkdirTemp("build_") +func newBuildEnvironment(streams launchr.Streams, debug bool) (*buildEnvironment, error) { + var err error + var tmpDir string + if !debug { + tmpDir, err = launchr.MkdirTempWithCleanup("build_") + } else { + tmpDir, err = launchr.MkdirTemp("build_") + } if err != nil { return nil, err } @@ -103,18 +109,9 @@ func (env *buildEnvironment) CreateModFile(ctx context.Context, opts *BuildOptio } // Download core. - var coreRepl bool - for repl := range opts.ModReplace { - if strings.HasPrefix(opts.CorePkg.Path, repl) { - coreRepl = true - break - } - } - if !coreRepl { - err = env.execGoGet(ctx, opts.CorePkg.String()) - if err != nil { - return err - } + err = env.execGoGet(ctx, opts.CorePkg.String()) + if err != nil { + return err } // Download plugins. @@ -122,7 +119,7 @@ nextPlugin: for _, p := range opts.Plugins { // Do not get plugins of module subpath. for repl := range opts.ModReplace { - if strings.HasPrefix(p.Path, repl) { + if p.Path != repl && strings.HasPrefix(p.Path, repl) { continue nextPlugin } } @@ -160,7 +157,7 @@ func (env *buildEnvironment) execGoGet(ctx context.Context, args ...string) erro } func (env *buildEnvironment) RunCmd(ctx context.Context, cmd *exec.Cmd) error { - launchr.Log().Debug("executing shell", "cmd", cmd) + launchr.Log().Debug("executing shell", "cmd", cmd, "pwd", cmd.Dir) err := cmd.Start() if err != nil { return err diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..1f554f2 --- /dev/null +++ b/test/README.md @@ -0,0 +1,3 @@ +# Integration tests + +This directory contains integration tests and their test data. \ No newline at end of file diff --git a/test/plugins/genaction/go.mod b/test/plugins/genaction/go.mod new file mode 100644 index 0000000..abdd3b4 --- /dev/null +++ b/test/plugins/genaction/go.mod @@ -0,0 +1,7 @@ +module example.com/genaction + +go 1.24.1 + +replace github.com/launchrctl/launchr => ../../../ + +require github.com/launchrctl/launchr v0.0.0 diff --git a/test/plugins/genaction/plugin.go b/test/plugins/genaction/plugin.go new file mode 100644 index 0000000..929a544 --- /dev/null +++ b/test/plugins/genaction/plugin.go @@ -0,0 +1,74 @@ +package genaction + +import ( + "context" + "os" + "path/filepath" + + "github.com/launchrctl/launchr" + "github.com/launchrctl/launchr/pkg/action" +) + +// pluginTemplate is a go file that will be generated and included in the build. +const pluginTemplate = `package main +import ( + _ "embed" + my "{{.Pkg}}" +) +//go:embed {{.Yaml}} +var y []byte +func init() { my.ActionYaml = y }` + +// ActionYaml is a yaml content that will be set from an embedded file in [pluginTemplate]. +var ActionYaml []byte + +func init() { + launchr.RegisterPlugin(&Plugin{}) +} + +// Plugin is a test plugin declaration. +type Plugin struct{} + +// PluginInfo implements [launchr.Plugin] interface. +func (p *Plugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{} +} + +// Generate implements [launchr.GeneratePlugin] interface. +func (p *Plugin) Generate(config launchr.GenerateConfig) error { + launchr.Term().Info().Printfln("Generating genaction...") + + actionyaml := "action.yaml" + const yaml = "{ runtime: plugin, action: { title: My plugin } }" + type tplvars struct { + Pkg string + Yaml string + } + + tpl := launchr.Template{Tmpl: pluginTemplate, Data: tplvars{ + Pkg: "example.com/genaction", + Yaml: actionyaml, + }} + err := tpl.WriteFile(filepath.Join(config.BuildDir, "genaction.gen.go")) + if err != nil { + return err + } + + err = os.WriteFile(filepath.Join(config.BuildDir, actionyaml), []byte(yaml), 0600) + if err != nil { + return err + } + + return nil +} + +// DiscoverActions implements [launchr.ActionDiscoveryPlugin] interface. +func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) { + a := action.NewFromYAML("genaction:example", ActionYaml) + a.SetRuntime(action.NewFnRuntime(func(_ context.Context, a *action.Action) error { + launchr.Term().Println("hello world") + return nil + })) + + return []*action.Action{a}, nil +} diff --git a/test/testdata/build/build.txtar b/test/testdata/build/build.txtar new file mode 100644 index 0000000..27c3732 --- /dev/null +++ b/test/testdata/build/build.txtar @@ -0,0 +1,54 @@ +# DO NOT USE IT AS A REFERENCE! +# This is a special test case +# where we build the bin from the source. +# See other files for examples. + +# Test 1: Check the version string when built with ldflags +env APP_NAME=myapp +env APP_VERSION='v1.1.0-testscript' +env APP_BUILT_WITH='testscript v1.0.0' +env ARCH_RGX=[a-z0-9]+/[a-z0-9]+ +env APP_VERSION_SHORT=$APP_NAME' version '${APP_VERSION@R}' '$ARCH_RGX + +# Build the binary. +env HOME=$TMPDIR +env APP_LDFLAGS=-X' '"$CORE_PKG.name=$APP_NAME"' -X '"$CORE_PKG.version=$APP_VERSION"' -X '"$CORE_PKG.builtWith=$APP_BUILT_WITH" +exec go build -C $REPO_PATH -ldflags $APP_LDFLAGS -o $WORK/$APP_NAME ./cmd/launchr + +# Test 1: Check version output. +exec ./$APP_NAME --version +stdout ^$APP_VERSION_SHORT'\nBuilt with '${APP_BUILT_WITH@R}\z$ +! stderr . + +# Test 2: Build a new binary using the old, check the version string. +# Replace the core to always build from the latest. +env APP_NAME_2=${APP_NAME}new +env APP_VERSION_2='v1.2.0-testscript' +env APP_VERSION_CORE='Core version: v.*\nCore replace: '${CORE_PKG@R}' v.* => '${REPO_PATH@R}' \(devel\)' +env APP_VERSION_FULL=$APP_NAME_2' version '$APP_VERSION_2' '$ARCH_RGX'\nBuilt with '${APP_VERSION_SHORT}'\n'$APP_VERSION_CORE + +exec ./$APP_NAME build --no-cache --tag nethttpomithttp2 -n $APP_NAME_2 -o $APP_NAME_2 -r $CORE_PKG=$REPO_PATH --build-version $APP_VERSION_2 + +exec ./$APP_NAME_2 --version +stdout ^$APP_VERSION_FULL'\z$' +! stderr . + +# Test 3: Build a new binary with incorrect app name. +! exec ./$APP_NAME build -n under_score -o under_score --build-version invalid +stdout 'invalid application name "under_score"' + +# Test 4: Build with plugins and replace. +# Add 1 arbitrary repository as a plugin, we need to test a public source but prevent possible build errors. +# We will may have a bit broken version, but it's ok for the test. +env APP_PLUGIN_1=golang.org/x/term +# Add 1 test plugin from test data with replace. +env APP_PLUGIN_2=example.com/genaction@v1.1.1 +exec ./$APP_NAME build -n $APP_NAME_2 -o $APP_NAME_2 -r $CORE_PKG=$REPO_PATH -p $APP_PLUGIN_1 -p $APP_PLUGIN_2 -r $APP_PLUGIN_2=$REPO_PATH/test/plugins/genaction --build-version $APP_VERSION_2 + +exec ./$APP_NAME_2 --version +stdout ^$APP_VERSION_FULL'\nPlugins:\n - example\.com/genaction v1\.1\.1\n - example\.com/genaction v1\.1\.1 => '$REPO_PATH'/test/plugins/genaction \(devel\)\n\z$' +! stderr . + +# Test 5: Check the generated action is included and works well. +exec ./$APP_NAME_2 genaction:example +stdout 'hello world' diff --git a/test/testdata/build/version.txtar b/test/testdata/build/version.txtar new file mode 100644 index 0000000..c72e5df --- /dev/null +++ b/test/testdata/build/version.txtar @@ -0,0 +1,3 @@ +# TODO: Separate build.txtar + +# TODO: Test config dir according to application name \ No newline at end of file diff --git a/test/testdata/common/discovery_basic.txtar b/test/testdata/common/discovery_basic.txtar new file mode 100644 index 0000000..a6fd5b6 --- /dev/null +++ b/test/testdata/common/discovery_basic.txtar @@ -0,0 +1,91 @@ +[unix] mkdir foo-bar_baz/actions/waldo*fred +[unix] mkdir actions/waldo*fred +[unix] cp 'foo-bar_baz/actions/waldo fred/action.yaml' foo-bar_baz/actions/waldo*fred/action.yaml +[unix] cp 'actions/waldo fred/action.yaml' actions/waldo*fred/action.yaml + +exec launchr --help + +# Actions are grouped and sorted. +stdout '^\s+Actions:\n\s+bar\s+' + +# Valid discovered actions +stdout '^\s+bar\s+bar$' +stdout '^\s+foo\s+foo$' +stdout '^\s+foo\.bar\.baz:fred\s+fred$' +stdout '^\s+foo-bar_baz:waldo-fred.1\s+valid special chars' +stdout '^\s+foo\.bar\.baz:waldo\s+waldo$' + +# Actions that must not appear +! stdout '^\s+foo-bar_baz:waldo.fred\s+invalid special chars$' +! stdout '^\s+foo-bar_baz:waldo\s+invalid special chars$' +! stdout '^\s+waldo.fred\s+invalid special chars$' +! stdout '^\s+(.)hidden:foo\s+foo hidden skipped$' +! stdout '^\s+(.)hidden:bar\s+bar hidden skipped$' +! stdout '^\s+foo\.bar\.baz:incorrect\s+incorrect actions path$' +! stdout '^\s+foo\.bar\.baz:subdir.*$' + +! stderr . + +-- actions/foo/action.yaml -- +action: + title: foo +runtime: + type: container + image: alpine + command: [/bin/sh, ls] + +-- actions/bar/action.yaml -- +action: + title: bar +runtime: + type: shell + script: ls -al + +-- foo-bar_baz/actions/waldo-fred.1/action.yaml -- +action: { title: valid special chars } +runtime: plugin + +-- foo-bar_baz/actions/waldo fred/action.yaml -- +action: { title: invalid special chars } +runtime: plugin + +-- actions/waldo fred/action.yaml -- +action: { title: invalid special chars } +runtime: plugin + +-- foo/bar/baz/actions/waldo/action.yaml -- +action: + title: waldo +runtime: plugin + +-- foo/bar/baz/actions/fred/action.yaml -- +action: + title: fred +runtime: plugin + +-- foo/bar/baz/actions/broken/action.yaml -- +action: + title: broken +runtime: + type: container + # missing container properties. + +-- .hidden/actions/foo/action.yaml -- +action: + title: foo hidden skipped +runtime: plugin + +-- .hidden/actions/bar/action.yaml -- +action: + title: bar hidden skipped +runtime: plugin + +-- foo/bar/baz/myactions/incorrect/action.yaml -- +action: + title: incorrect actions path +runtime: plugin + +-- foo/bar/baz/actions/subdir/foo/action.yaml -- +action: + title: foo incorrect pos of yaml in subdir +runtime: plugin diff --git a/test/testdata/common/discovery_config_naming.txtar b/test/testdata/common/discovery_config_naming.txtar new file mode 100644 index 0000000..8225b63 --- /dev/null +++ b/test/testdata/common/discovery_config_naming.txtar @@ -0,0 +1,16 @@ +exec launchr --help +stdout '^\s+foo\.baz\.bar-bar:waldo-fred-thud\s+foo$' +! stderr . + +-- foo/bar/baz/bar/bar_bar/actions/waldo-fred_thud/action.yaml -- +action: + title: foo +runtime: plugin + +-- .launchr/config.yaml -- +launchrctl: + actions_naming: + - search: ".bar." + replace: "." + - search: "_" + replace: "-" \ No newline at end of file diff --git a/test/testdata/common/input.txtar b/test/testdata/common/input.txtar new file mode 100644 index 0000000..6286849 --- /dev/null +++ b/test/testdata/common/input.txtar @@ -0,0 +1 @@ +# TODO Test commands with different input types and mandatory + basic jsonschema validation \ No newline at end of file diff --git a/test/testdata/common/processors.txtar b/test/testdata/common/processors.txtar new file mode 100644 index 0000000..4c69908 --- /dev/null +++ b/test/testdata/common/processors.txtar @@ -0,0 +1 @@ +# Test processors \ No newline at end of file diff --git a/test/testdata/common/terminal.txtar b/test/testdata/common/terminal.txtar new file mode 100644 index 0000000..81d1681 --- /dev/null +++ b/test/testdata/common/terminal.txtar @@ -0,0 +1 @@ +# TODO Test terminal log level, log format and quiet mode \ No newline at end of file diff --git a/test/testdata/runtime/docker.txtar b/test/testdata/runtime/docker.txtar new file mode 100644 index 0000000..1352806 --- /dev/null +++ b/test/testdata/runtime/docker.txtar @@ -0,0 +1 @@ +# TODO Test docker runtime \ No newline at end of file diff --git a/test/testdata/runtime/shell.txtar b/test/testdata/runtime/shell.txtar new file mode 100644 index 0000000..5c5d8e4 --- /dev/null +++ b/test/testdata/runtime/shell.txtar @@ -0,0 +1 @@ +# TODO Test shell runtime \ No newline at end of file