├── cmd ├── testdata │ ├── base │ │ └── plan.yaml │ ├── invalid-yaml │ │ └── shuttle.yaml │ ├── project-git │ │ └── shuttle.yaml │ ├── custom-template-alternative-delims.tmpl │ ├── project-git-branched │ │ └── shuttle.yaml │ ├── project-local │ │ ├── service │ │ │ └── shuttle.yaml │ │ └── plan │ │ │ └── plan.yaml │ ├── custom-template.tmpl │ └── project │ │ └── shuttle.yaml ├── version_test.go ├── prepare.go ├── error.go ├── version.go ├── git-plan.go ├── template_test.go ├── ls_test.go ├── plan_test.go ├── get_test.go ├── telemetry.go ├── get.go ├── documentation.go ├── has_test.go ├── has.go ├── ls.go ├── config_test.go ├── telemetry_cmd.go ├── config.go ├── plan.go ├── test.go ├── cmd_test.go ├── template.go └── run_test.go ├── examples ├── moon-base │ ├── actions │ │ ├── go.sum │ │ ├── go.mod │ │ └── dev.go │ ├── main.go │ └── shuttle.yaml ├── stepping-stone │ ├── sub-dir │ │ └── .gitkeep │ └── shuttle.yaml ├── custom-template-alternate-delims.tmpl ├── bad │ ├── yaml-invalid │ │ └── shuttle.yaml │ └── no-repo-project │ │ ├── main.go │ │ └── shuttle.yaml ├── repo-project │ ├── main.go │ └── shuttle.yaml ├── repo-project-branched │ ├── main.go │ └── shuttle.yaml ├── no-plan-project │ └── shuttle.yaml ├── station-plan │ ├── Dockerfile.tmpl │ └── plan.yaml └── custom-template.tmpl ├── CODEOWNERS ├── pkg ├── templates │ └── testdata │ │ └── dir │ │ └── file.test ├── telemetry │ ├── testdata │ │ ├── no-telemetry-files │ │ │ └── .gitkeep │ │ ├── telemetry-files │ │ │ └── shuttle-telemetry.jsonl │ │ ├── TestTelemetryUploader │ │ │ ├── lock_already_exists │ │ │ │ └── .shuttle-telemetry-lock │ │ │ ├── old_lock_create_new_lock │ │ │ │ └── .shuttle-telemetry-lock │ │ │ └── new_lock_doesnt_create_new_lock │ │ │ │ └── .shuttle-telemetry-lock │ │ └── get-shuttle-telemetry-file │ │ │ └── shuttle-telemetry.jsonl │ ├── noop.go │ ├── logging.go │ ├── context_test.go │ ├── telemetry.go │ ├── context.go │ ├── setup.go │ ├── jsonlines.go │ └── utility.go ├── sdk │ ├── testdata │ │ ├── valid.yaml │ │ ├── get_unknown_value.yaml │ │ └── get_file_content.yaml │ ├── context.go │ ├── template_test.go │ └── template.go ├── config │ ├── testdata │ │ ├── unknown_field │ │ │ ├── plan.yaml │ │ │ └── shuttle.yaml │ │ └── valid │ │ │ ├── plan.yaml │ │ │ └── shuttle.yaml │ ├── planargument.go │ ├── documentation.go │ ├── shuttleplan_test.go │ ├── shuttleconfig_test.go │ ├── documentation_test.go │ ├── shuttleplan.go │ └── shuttleconfig.go ├── executors │ ├── golang │ │ ├── compile │ │ │ ├── testdata │ │ │ │ └── simple │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── actions │ │ │ │ │ ├── somefolder │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── download.go │ │ │ │ │ ├── build_test.go │ │ │ │ │ ├── go.mod │ │ │ │ │ └── build.go │ │ │ │ │ └── shuttle.yaml │ │ │ ├── compile_test.go │ │ │ └── matcher │ │ │ │ └── matcher.go │ │ ├── discover │ │ │ ├── testdata │ │ │ │ ├── child │ │ │ │ │ ├── actions │ │ │ │ │ │ ├── somefolder │ │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ │ ├── build.go │ │ │ │ │ │ ├── build_test.go │ │ │ │ │ │ ├── download.go │ │ │ │ │ │ └── go.mod │ │ │ │ │ └── shuttle.yaml │ │ │ │ ├── parent │ │ │ │ │ ├── actions │ │ │ │ │ │ ├── somefolder │ │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ │ ├── build.go │ │ │ │ │ │ ├── download.go │ │ │ │ │ │ ├── build_test.go │ │ │ │ │ │ └── go.mod │ │ │ │ │ ├── shuttle.yaml │ │ │ │ │ └── plan.yaml │ │ │ │ └── simple │ │ │ │ │ ├── actions │ │ │ │ │ ├── somefolder │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── build.go │ │ │ │ │ ├── download.go │ │ │ │ │ └── build_test.go │ │ │ │ │ └── shuttle.yaml │ │ │ ├── discover_test.go │ │ │ └── discover.go │ │ ├── executer │ │ │ ├── testdata │ │ │ │ ├── child │ │ │ │ │ ├── actions │ │ │ │ │ │ ├── somefolder │ │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ │ ├── build_test.go │ │ │ │ │ │ ├── go.mod │ │ │ │ │ │ ├── version.go │ │ │ │ │ │ └── build.go │ │ │ │ │ └── shuttle.yaml │ │ │ │ └── parent │ │ │ │ │ ├── actions │ │ │ │ │ ├── somefolder │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── build_test.go │ │ │ │ │ ├── go.mod │ │ │ │ │ ├── build.go │ │ │ │ │ └── download.go │ │ │ │ │ ├── shuttle.yaml │ │ │ │ │ └── plan.yaml │ │ │ ├── list_test.go │ │ │ ├── run.go │ │ │ ├── prepare.go │ │ │ ├── run_test.go │ │ │ ├── actions.go │ │ │ ├── list.go │ │ │ ├── actions_test.go │ │ │ └── executer.go │ │ ├── codegen │ │ │ ├── testdata │ │ │ │ └── patch │ │ │ │ │ ├── root_module │ │ │ │ │ ├── go.mod │ │ │ │ │ └── .shuttle │ │ │ │ │ │ └── actions │ │ │ │ │ │ └── tmp │ │ │ │ │ │ └── go.mod │ │ │ │ │ ├── root_workspace │ │ │ │ │ ├── go.mod │ │ │ │ │ ├── subpackage │ │ │ │ │ │ └── go.mod │ │ │ │ │ ├── other │ │ │ │ │ │ └── subpackage │ │ │ │ │ │ │ └── go.mod │ │ │ │ │ ├── go.work │ │ │ │ │ └── .shuttle │ │ │ │ │ │ └── actions │ │ │ │ │ │ └── tmp │ │ │ │ │ │ └── go.mod │ │ │ │ │ └── replace_existing │ │ │ │ │ ├── go.mod │ │ │ │ │ └── .shuttle │ │ │ │ │ └── actions │ │ │ │ │ └── tmp │ │ │ │ │ └── go.mod │ │ │ ├── patch_default.go │ │ │ ├── format.go │ │ │ ├── tidy.go │ │ │ ├── module.go │ │ │ ├── templates │ │ │ │ └── mainFile.tmpl │ │ │ ├── compilation.go │ │ │ ├── patch.go │ │ │ ├── codegen.go │ │ │ ├── patch_patcher.go │ │ │ ├── patch_finder.go │ │ │ ├── mod_file.go │ │ │ ├── patch_gomodule.go │ │ │ ├── patch_test.go │ │ │ ├── patch_workspace.go │ │ │ └── mod_file_test.go │ │ ├── errors │ │ │ └── errors.go │ │ ├── shuttlefolder │ │ │ ├── binaryName.go │ │ │ └── prepare.go │ │ ├── cmder │ │ │ ├── cmder_test.go │ │ │ └── cmder.go │ │ └── parser │ │ │ └── parser.go │ ├── docker.go │ ├── help.go │ ├── task.go │ ├── shell.go │ ├── help_test.go │ └── executor.go ├── ui │ ├── template.go │ ├── level.go │ └── ui.go ├── errors │ └── errors.go ├── copy │ └── copy.go ├── git │ └── parse_test.go └── browser │ ├── browser.go │ └── browser_test.go ├── run ├── docs ├── logo.png ├── logo-big.png └── features │ ├── telemetry.md │ └── golang-actions.md ├── .gitignore ├── main.go ├── .github └── workflows │ ├── release-drafter.yml │ ├── build.yml │ └── release.yml ├── shuttle.yaml ├── .goreleaser.yml ├── renovate.json ├── action.yml └── go.mod /cmd/testdata/base/plan.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/moon-base/actions/go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @lunarway/squad-esa 2 | -------------------------------------------------------------------------------- /pkg/templates/testdata/dir/file.test: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/stepping-stone/sub-dir/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cmd/testdata/invalid-yaml/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan 2 | -------------------------------------------------------------------------------- /pkg/telemetry/testdata/no-telemetry-files/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/sdk/testdata/valid.yaml: -------------------------------------------------------------------------------- 1 | foo: {{ get "bar" .Vars }} 2 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | go build && ./shuttle "$@" 3 | -------------------------------------------------------------------------------- /pkg/config/testdata/unknown_field/plan.yaml: -------------------------------------------------------------------------------- 1 | unknown: field 2 | -------------------------------------------------------------------------------- /pkg/telemetry/testdata/telemetry-files/shuttle-telemetry.jsonl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/moon-base/actions/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /pkg/config/testdata/unknown_field/shuttle.yaml: -------------------------------------------------------------------------------- 1 | nothing: important 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/.gitignore: -------------------------------------------------------------------------------- 1 | .shuttle/ 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/actions/somefolder/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/child/actions/somefolder/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/actions/somefolder/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/simple/actions/somefolder/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/child/actions/somefolder/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/actions/somefolder/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunarway/shuttle/HEAD/docs/logo.png -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/child/actions/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/simple/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | -------------------------------------------------------------------------------- /pkg/sdk/testdata/get_unknown_value.yaml: -------------------------------------------------------------------------------- 1 | foo: {{ get "unknown.bar" .Vars }} 2 | -------------------------------------------------------------------------------- /examples/custom-template-alternate-delims.tmpl: -------------------------------------------------------------------------------- 1 | FROM >> get "docker.image" .Vars << -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/actions/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/child/actions/build_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/child/actions/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/child/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: "../parent" 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/actions/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/actions/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/simple/actions/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/simple/actions/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/child/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: "../parent" 2 | -------------------------------------------------------------------------------- /docs/logo-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunarway/shuttle/HEAD/docs/logo-big.png -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/actions/build_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/actions/build_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/simple/actions/build_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/child/actions/build_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | -------------------------------------------------------------------------------- /pkg/telemetry/testdata/TestTelemetryUploader/lock_already_exists/.shuttle-telemetry-lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/bad/yaml-invalid/shuttle.yaml: -------------------------------------------------------------------------------- 1 | &aplan: '../../station-plan' 2 | vars: 3 | 23.1 4 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/actions/build_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | -------------------------------------------------------------------------------- /pkg/telemetry/testdata/TestTelemetryUploader/old_lock_create_new_lock/.shuttle-telemetry-lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | dist/ 3 | vendor/ 4 | shuttle 5 | .shuttle 6 | .idea 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /pkg/sdk/testdata/get_file_content.yaml: -------------------------------------------------------------------------------- 1 | bar: baz 2 | {{ getFileContent (string "path" .Vars) }} 3 | -------------------------------------------------------------------------------- /cmd/testdata/project-git/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: https://github.com/lunarway/shuttle-example-go-plan.git 2 | -------------------------------------------------------------------------------- /examples/moon-base/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | println("hello moon!") 5 | } 6 | -------------------------------------------------------------------------------- /pkg/telemetry/testdata/TestTelemetryUploader/new_lock_doesnt_create_new_lock/.shuttle-telemetry-lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/repo-project/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | println("hello world!") 5 | } 6 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_module/go.mod: -------------------------------------------------------------------------------- 1 | module root_module 2 | 3 | go 1.21.4 4 | -------------------------------------------------------------------------------- /examples/bad/no-repo-project/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | println("hello world!") 5 | } 6 | -------------------------------------------------------------------------------- /examples/repo-project-branched/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | println("hello world!") 5 | } 6 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_workspace/go.mod: -------------------------------------------------------------------------------- 1 | module root_workspace 2 | 3 | go 1.21.4 4 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/replace_existing/go.mod: -------------------------------------------------------------------------------- 1 | module replace_existing 2 | 3 | go 1.21.4 4 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_workspace/subpackage/go.mod: -------------------------------------------------------------------------------- 1 | module subpackage 2 | 3 | go 1.21.4 4 | -------------------------------------------------------------------------------- /cmd/testdata/custom-template-alternative-delims.tmpl: -------------------------------------------------------------------------------- 1 | FROM golang:1.17-alpine 2 | LABEL svc=>> get "service" .Vars << 3 | -------------------------------------------------------------------------------- /cmd/testdata/project-git-branched/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: https://github.com/lunarway/shuttle-example-go-plan.git#change-build 2 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_workspace/other/subpackage/go.mod: -------------------------------------------------------------------------------- 1 | module othersubpackage 2 | 3 | go 1.21.4 4 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/plan.yaml: -------------------------------------------------------------------------------- 1 | scripts: 2 | someAction: 3 | actions: 4 | - shell: someAction 5 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/plan.yaml: -------------------------------------------------------------------------------- 1 | scripts: 2 | someAction: 3 | actions: 4 | - shell: someAction 5 | -------------------------------------------------------------------------------- /examples/stepping-stone/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | scripts: 3 | say: 4 | description: Write output 5 | actions: 6 | - shell: echo "$project" 7 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_module/.shuttle/actions/tmp/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | require ( 4 | root_module 5 | ) 6 | 7 | go 1.21.4 8 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_workspace/go.work: -------------------------------------------------------------------------------- 1 | go 1.21.4 2 | 3 | use ( 4 | . 5 | ./subpackage 6 | ./other/subpackage 7 | ignored 8 | ) 9 | -------------------------------------------------------------------------------- /examples/no-plan-project/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | vars: 3 | T: hello 4 | scripts: 5 | hello: 6 | actions: 7 | - shell: echo "Hello no plan project" 8 | -------------------------------------------------------------------------------- /examples/bad/no-repo-project/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: 'git://git@github.com:lunarway/not-existing-shuttle-plan.git' 2 | vars: 3 | docker: 4 | image: shuttle/no-repo-project -------------------------------------------------------------------------------- /pkg/config/testdata/valid/plan.yaml: -------------------------------------------------------------------------------- 1 | vars: 2 | shared: var 3 | scripts: 4 | hello: 5 | description: Say hello 6 | actions: 7 | - shell: echo "Hello world" 8 | -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/actions/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | go 1.18 4 | 5 | replace "github.com/lunarway/shuttle" => ../../../../../../../../../ 6 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/child/actions/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | go 1.18 4 | 5 | replace github.com/lunarway/shuttle => ../../../../../../../../../ 6 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/testdata/parent/actions/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | go 1.18 4 | 5 | replace github.com/lunarway/shuttle => ../../../../../../../../../../../ 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/lunarway/shuttle/cmd" 7 | ) 8 | 9 | func main() { 10 | cmd.Execute(os.Stdout, os.Stderr) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/executors/golang/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrGolangActionNoBuilder = errors.New("golang actions no builder enabled") 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/child/actions/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | go 1.18 4 | 5 | replace "github.com/lunarway/shuttle" => ../../../../../../../../../ 6 | 7 | 8 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/actions/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | go 1.18 4 | 5 | replace "github.com/lunarway/shuttle" => ../../../../../../../../../../../ 6 | -------------------------------------------------------------------------------- /cmd/testdata/project-local/service/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | scripts: 3 | hello-shuttle: 4 | description: Write output 5 | actions: 6 | - shell: echo "Hello from shuttle" 7 | -------------------------------------------------------------------------------- /pkg/config/testdata/valid/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: . 2 | vars: 3 | squad: name 4 | scripts: 5 | shout: 6 | description: Shout hello 7 | actions: 8 | - shell: echo "HELLO WORLD" 9 | -------------------------------------------------------------------------------- /cmd/testdata/project-local/plan/plan.yaml: -------------------------------------------------------------------------------- 1 | vars: 2 | language: go 3 | scripts: 4 | hello-plan: 5 | description: Write output 6 | actions: 7 | - shell: echo "Hello from plan" 8 | -------------------------------------------------------------------------------- /examples/moon-base/actions/dev.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func Dev(ctx context.Context) error { 8 | println("dev") 9 | 10 | return nil 11 | } 12 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/child/actions/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "context" 4 | 5 | func Version(ctx context.Context) error { 6 | println("version") 7 | 8 | return nil 9 | } 10 | -------------------------------------------------------------------------------- /cmd/testdata/custom-template.tmpl: -------------------------------------------------------------------------------- 1 | # Custom docker file template not located inside a project 2 | {{- 3 | $imageTag := 4 | get "GO_VERSION" .Args | 5 | printf "%s-alpine" 6 | }} 7 | FROM golang:{{ $imageTag }} 8 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/child/actions/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "context" 4 | 5 | func Build(ctx context.Context) error { 6 | println("build: child") 7 | 8 | return nil 9 | } 10 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/actions/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "context" 4 | 5 | func Build(ctx context.Context) error { 6 | println("build: parent") 7 | 8 | return nil 9 | } 10 | -------------------------------------------------------------------------------- /examples/repo-project/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: 'https://github.com/lunarway/shuttle-example-go-plan.git' 2 | vars: 3 | docker: 4 | baseImage: golang 5 | baseTag: stretch 6 | destImage: repo-project 7 | destTag: latest -------------------------------------------------------------------------------- /pkg/executors/golang/compile/testdata/simple/actions/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "context" 4 | 5 | func Build(ctx context.Context, something string) error { 6 | println("build") 7 | 8 | return nil 9 | } 10 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/testdata/parent/actions/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "context" 4 | 5 | func Download(ctx context.Context) error { 6 | println("download: parent") 7 | 8 | return nil 9 | } 10 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/replace_existing/.shuttle/actions/tmp/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | require ( 4 | replace_existing v0.0.0 5 | ) 6 | 7 | go 1.21.4 8 | 9 | replace replace_existing => ../bogus 10 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/testdata/patch/root_workspace/.shuttle/actions/tmp/go.mod: -------------------------------------------------------------------------------- 1 | module actions 2 | 3 | require ( 4 | root_workspace v0.0.0 5 | subpackage v0.0.0 6 | othersubpackage v0.0.0 7 | ) 8 | 9 | go 1.21.4 10 | -------------------------------------------------------------------------------- /examples/repo-project-branched/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: 'https://github.com/lunarway/shuttle-example-go-plan.git#change-build' 2 | vars: 3 | docker: 4 | baseImage: golang 5 | baseTag: stretch 6 | destImage: repo-project 7 | destTag: latest -------------------------------------------------------------------------------- /examples/station-plan/Dockerfile.tmpl: -------------------------------------------------------------------------------- 1 | FROM golang:{{ get "GO_VERSION" .Args }}-alpine as builder 2 | 3 | COPY . /go 4 | 5 | RUN go build -v -o app 6 | 7 | FROM alpine 8 | 9 | COPY --from=builder /go/app /app 10 | 11 | ENTRYPOINT [ "/app" ] -------------------------------------------------------------------------------- /pkg/telemetry/noop.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import "context" 4 | 5 | type NoopTelemetryClient struct{} 6 | 7 | func (*NoopTelemetryClient) Trace(ctx context.Context, properties map[string]string) { 8 | } 9 | 10 | var _ TelemetryClient = &NoopTelemetryClient{} 11 | -------------------------------------------------------------------------------- /pkg/telemetry/testdata/get-shuttle-telemetry-file/shuttle-telemetry.jsonl: -------------------------------------------------------------------------------- 1 | { "app": "some-app", "timestamp": "2006-01-02T15:04:05Z", "properties": {}} 2 | { "app": "some-app", "timestamp": "2007-01-02T15:04:05Z", "properties": {"some-key": "some-value", "some-other-key": "some-other-value"}} 3 | -------------------------------------------------------------------------------- /examples/moon-base/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: "../station-plan" 2 | vars: 3 | docker: 4 | image: earth-united/moon-base 5 | env: 6 | field: 7 | key: value 8 | run-as-root: false 9 | scripts: 10 | serve: 11 | description: Serve starts an nginx image as a long running process. 12 | actions: 13 | - shell: docker run --rm -i --name shuttle-nginx nginx 14 | -------------------------------------------------------------------------------- /examples/custom-template.tmpl: -------------------------------------------------------------------------------- 1 | # Custom docker file template not located inside a project 2 | {{- 3 | $imageTag := 4 | get "GO_VERSION" .Args | 5 | printf "%s-alpine" 6 | }} 7 | FROM golang:{{ $imageTag }} as builder 8 | 9 | LABEL custom=field 10 | 11 | COPY . /go 12 | 13 | RUN go build -v -o app 14 | 15 | FROM alpine 16 | 17 | COPY --from=builder /go/app /app 18 | 19 | ENTRYPOINT [ "/app" ] 20 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch_default.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import "context" 4 | 5 | type defaultFinder struct{} 6 | 7 | func newDefaultFinder() *defaultFinder { 8 | return &defaultFinder{} 9 | } 10 | 11 | func (s *defaultFinder) Find(ctx context.Context, _ string) (packages map[string]string, ok bool, err error) { 12 | // We return true, as this should be placed last in the chain 13 | return make(map[string]string, 0), true, nil 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v6 14 | with: 15 | config-name: release-drafter.yml 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /pkg/ui/template.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "text/template" 7 | 8 | "github.com/lunarway/shuttle/pkg/templates" 9 | ) 10 | 11 | func Template(destination io.Writer, name, text string, data interface{}) error { 12 | t := template.New(name) 13 | t.Funcs(templates.GetFuncMap()) 14 | t, err := t.Parse(text) 15 | if err != nil { 16 | return fmt.Errorf("invalid template: %v", err) 17 | } 18 | return t.Execute(destination, data) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/executors/golang/shuttlefolder/binaryName.go: -------------------------------------------------------------------------------- 1 | package shuttlefolder 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "path" 7 | ) 8 | 9 | const ( 10 | TaskBinaryDir string = "binaries" 11 | TaskBinaryPrefix = "actions" 12 | ) 13 | 14 | func CalculateBinaryPath(shuttledir, hash string) string { 15 | return path.Join( 16 | shuttledir, 17 | "binaries", 18 | fmt.Sprintf("%s-%s", TaskBinaryPrefix, hex.EncodeToString([]byte(hash)[:16])), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "fmt" 4 | 5 | // ExitCode is an error indicating a specific exit code is used upon exit of 6 | // shuttle. 7 | type ExitCode struct { 8 | Code int 9 | Message string 10 | } 11 | 12 | func (e *ExitCode) Error() string { 13 | return fmt.Sprintf("exit code %d - %s", e.Code, e.Message) 14 | } 15 | 16 | func NewExitCode(code int, format string, args ...interface{}) error { 17 | return &ExitCode{ 18 | Code: code, 19 | Message: fmt.Sprintf(format, args...), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/format.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "path" 7 | 8 | "github.com/lunarway/shuttle/pkg/ui" 9 | ) 10 | 11 | func Format(ctx context.Context, ui *ui.UI, shuttlelocaldir string) error { 12 | cmd := exec.Command("go", "fmt", "./...") 13 | cmd.Dir = path.Join(shuttlelocaldir, "tmp") 14 | 15 | output, err := cmd.CombinedOutput() 16 | if err != nil { 17 | ui.Errorln("go fmt: %s, error: %v", string(output), err) 18 | return err 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/tidy.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "path" 7 | 8 | "github.com/lunarway/shuttle/pkg/ui" 9 | ) 10 | 11 | func ModTidy(ctx context.Context, ui *ui.UI, shuttlelocaldir string) error { 12 | cmd := exec.Command("go", "mod", "tidy") 13 | cmd.Dir = path.Join(shuttlelocaldir, "tmp") 14 | 15 | output, err := cmd.CombinedOutput() 16 | if err != nil { 17 | ui.Errorln("mod tidy: %s, error: %v", string(output), err) 18 | return err 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVersion(t *testing.T) { 8 | testCases := []testCase{ 9 | { 10 | name: "no args", 11 | input: args("version"), 12 | stdoutput: "\n", 13 | erroutput: "", 14 | err: nil, 15 | }, 16 | { 17 | name: "with commit", 18 | input: args("version", "--commit"), 19 | stdoutput: "\n", 20 | erroutput: "", 21 | err: nil, 22 | }, 23 | } 24 | executeTestCases(t, testCases) 25 | } 26 | -------------------------------------------------------------------------------- /cmd/testdata/project/shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | vars: 3 | service: shuttle 4 | boolVar: false 5 | nested: 6 | var: foo 7 | sub: 8 | field: baz 9 | scripts: 10 | hello_stdout: 11 | actions: 12 | - shell: echo "Hello stdout" 13 | hello_stderr: 14 | actions: 15 | - shell: '>&2 echo "Hello stderr"' 16 | exit_0: 17 | actions: 18 | - shell: exit 0 19 | exit_1: 20 | actions: 21 | - shell: exit 1 22 | required_arg: 23 | args: 24 | - name: foo 25 | required: true 26 | actions: 27 | - shell: echo $foo 28 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/module.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/exp/slices" 7 | ) 8 | 9 | type module struct { 10 | name string 11 | path string 12 | } 13 | 14 | func modulesFromMap(packages map[string]string) []module { 15 | modules := make([]module, 0, len(packages)) 16 | for moduleName, modulePath := range packages { 17 | modules = append(modules, module{ 18 | name: moduleName, 19 | path: modulePath, 20 | }) 21 | } 22 | slices.SortFunc(modules, func(a, b module) int { 23 | return strings.Compare(a.name, b.name) 24 | }) 25 | 26 | return modules 27 | } 28 | -------------------------------------------------------------------------------- /cmd/prepare.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lunarway/shuttle/pkg/ui" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func newPrepare(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 9 | prepareCmd := &cobra.Command{ 10 | Use: "prepare", 11 | Short: "Load external resources", 12 | Long: `Load external resources as a preparation step, before starting to use shuttle`, 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | _, err := contextProvider() 15 | if err != nil { 16 | return err 17 | } 18 | 19 | return nil 20 | }, 21 | } 22 | 23 | return prepareCmd 24 | } 25 | -------------------------------------------------------------------------------- /shuttle.yaml: -------------------------------------------------------------------------------- 1 | plan: false 2 | vars: 3 | service: shuttle 4 | domain: developer-productivity 5 | squad: esa 6 | 7 | scripts: 8 | build: 9 | description: build code 10 | actions: 11 | - shell: go build 12 | test: 13 | description: Run shuttle test suite 14 | actions: 15 | - shell: go test -v ./... 16 | release: 17 | description: Prepare for a release of shuttle. 18 | args: 19 | - name: version 20 | description: The new version to release. 21 | required: true 22 | actions: 23 | - shell: git tag -fa $version -m "$version release" && git push origin $version 24 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/templates/mainFile.tmpl: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/lunarway/shuttle/pkg/executors/golang/cmder" 5 | ) 6 | 7 | func main() { 8 | rootcmd := cmder.NewRoot() 9 | 10 | {{ range . -}} 11 | {{- $name := .Name -}} 12 | {{ lower $name }}cmd := cmder.NewCmd("{{ lower $name }}", {{ $name }}) 13 | {{ range .Input -}} 14 | {{ lower $name }}cmd = cmder.WithArgs({{ lower $name }}cmd, "{{ lower .Name }}") 15 | {{ end -}} 16 | {{ end -}} 17 | 18 | rootcmd.AddCmds( 19 | {{- range . -}} 20 | {{ lower .Name }}cmd, 21 | {{ end }} 22 | ) 23 | 24 | rootcmd.Execute() 25 | } 26 | -------------------------------------------------------------------------------- /cmd/error.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | stdcontext "context" 5 | "errors" 6 | "os" 7 | 8 | shuttleerrors "github.com/lunarway/shuttle/pkg/errors" 9 | "github.com/lunarway/shuttle/pkg/ui" 10 | ) 11 | 12 | func checkError(uii *ui.UI, err error) { 13 | if err == nil { 14 | return 15 | } 16 | var exitCode *shuttleerrors.ExitCode 17 | if errors.As(err, &exitCode) { 18 | uii.Errorln("shuttle failed\n%s", exitCode.Message) 19 | os.Exit(exitCode.Code) 20 | } 21 | if errors.Is(err, stdcontext.Canceled) { 22 | uii.Errorln("Operation cancelled") 23 | os.Exit(2) 24 | } 25 | uii.Errorln("shuttle failed\nError: %s", err) 26 | os.Exit(1) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/telemetry/logging.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | ) 8 | 9 | type LoggingTelemetryClient struct { 10 | labelPrefix string 11 | properties map[string]string 12 | } 13 | 14 | func (t *LoggingTelemetryClient) Trace( 15 | ctx context.Context, 16 | properties map[string]string, 17 | ) { 18 | properties = copyHostMap(t.properties, properties) 19 | 20 | content, err := json.Marshal(properties) 21 | if err != nil { 22 | log.Printf("failed to serialize properties") 23 | return 24 | } 25 | 26 | log.Printf("%s: %s\n", t.labelPrefix, string(content)) 27 | } 28 | 29 | var _ TelemetryClient = &LoggingTelemetryClient{} 30 | -------------------------------------------------------------------------------- /pkg/ui/level.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | // Level specifies the level of output that commands should print 4 | type Level string 5 | 6 | // LevelVerbose includes all output 7 | const ( 8 | LevelVerbose Level = "Verbose" 9 | LevelInfo Level = "Info" 10 | LevelError Level = "Error" 11 | LevelSilent Level = "Silent" 12 | ) 13 | 14 | var levelMap = map[Level]int{ 15 | LevelVerbose: 3, 16 | LevelInfo: 2, 17 | LevelError: 1, 18 | LevelSilent: 0, 19 | } 20 | 21 | // OutputIsIncluded returns true if levelA specifies that levelB should be included for output 22 | func (levelA Level) OutputIsIncluded(levelB Level) bool { 23 | return levelMap[levelA] >= levelMap[levelB] 24 | } 25 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - env: 4 | - CGO_ENABLED=0 5 | goos: 6 | - linux 7 | - darwin 8 | goarch: 9 | - amd64 10 | - arm64 11 | ldflags: 12 | - "-s -w -X github.com/lunarway/shuttle/cmd.version={{.Version}} -X github.com/lunarway/shuttle/cmd.commit={{.Commit}}" 13 | 14 | archives: 15 | - id: archives 16 | format: binary 17 | name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" 18 | 19 | checksum: 20 | name_template: "{{ .ProjectName }}-checksums.txt" 21 | 22 | snapshot: 23 | name_template: "git-{{.Commit}}" 24 | 25 | release: 26 | name_template: "v{{.Version}}" 27 | 28 | changelog: 29 | disable: true 30 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lunarway/shuttle/pkg/ui" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func newVersion(uii *ui.UI) *cobra.Command { 11 | var showCommit bool 12 | 13 | versionCmd := &cobra.Command{ 14 | Use: "version", 15 | Short: "Info about version of shuttle", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | uii.SetContext(ui.LevelSilent) 18 | if showCommit { 19 | fmt.Fprintln(cmd.OutOrStdout(), commit) 20 | } else { 21 | fmt.Fprintln(cmd.OutOrStdout(), version) 22 | } 23 | }, 24 | } 25 | 26 | versionCmd.Flags(). 27 | BoolVar(&showCommit, "commit", false, "Get git commit sha for current version") 28 | 29 | return versionCmd 30 | } 31 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/compilation.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path" 9 | 10 | "github.com/lunarway/shuttle/pkg/ui" 11 | ) 12 | 13 | func CompileBinary(ctx context.Context, ui *ui.UI, shuttlelocaldir string) (string, error) { 14 | cmd := exec.Command("go", "build") 15 | cmd.Env = os.Environ() 16 | // We need to set workspaces off, as we don't want users to have to add the golang modules to their go.work 17 | cmd.Env = append(cmd.Env, "GOWORK=off") 18 | 19 | cmd.Dir = path.Join(shuttlelocaldir, "tmp") 20 | 21 | output, err := cmd.CombinedOutput() 22 | if err != nil { 23 | log.Printf("compile-binary output: %s", string(output)) 24 | return "", err 25 | } 26 | 27 | return path.Join(shuttlelocaldir, "tmp", "actions"), nil 28 | } 29 | -------------------------------------------------------------------------------- /cmd/git-plan.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lunarway/shuttle/pkg/git" 7 | "github.com/lunarway/shuttle/pkg/ui" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func newGitPlan(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 12 | gitPlanCmd := &cobra.Command{ 13 | Use: "git-plan [...git_args]", 14 | Short: "Run a git command for the plan", 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | // TODO: this is no longer possible to configure 17 | // skipGitPlanPulling = true 18 | context, err := contextProvider() 19 | if err != nil { 20 | return err 21 | } 22 | 23 | git.RunGitPlanCommand(strings.Join(args, " "), context.LocalPlanPath, context.UI) 24 | 25 | return nil 26 | }, 27 | } 28 | 29 | return gitPlanCmd 30 | } 31 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>lunarway/renovate-config" 5 | ], 6 | "regexManagers": [ 7 | { 8 | "description": "Update docker images in go files", 9 | "fileMatch": [ 10 | "^.*\\.go$" 11 | ], 12 | "matchStrings": [ 13 | "\\/\\/ renovate: datasource=(?[a-z-]+?) depName=(?[a-z-]+)\\s+([a-zA-Z]*)\\s*[:|=]\\s+\"(?.*)\"\\,?" 14 | ], 15 | "versioningTemplate": "docker" 16 | } 17 | ], 18 | "packageRules": [ 19 | { 20 | "description": "Update docker tags frequently", 21 | "matchDatasources": [ 22 | "docker" 23 | ], 24 | "extends": [ 25 | "schedule:nonOfficeHours" 26 | ] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v6 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Go 18 | uses: actions/setup-go@v6 19 | with: 20 | go-version: 1.25.5 21 | - name: Cache Go modules 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/go/pkg/mod 25 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 26 | restore-keys: | 27 | ${{ runner.os }}-go- 28 | - name: Build 29 | run: | 30 | go build 31 | - name: Tests 32 | run: | 33 | ./shuttle run test 34 | -------------------------------------------------------------------------------- /pkg/telemetry/context_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestContext(t *testing.T) { 12 | t.Run("run without inherititng context_id", func(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | t.Setenv("SHUTTLE_CONTEXT_ID", "") 16 | 17 | ctx = WithContextID(ctx) 18 | 19 | value := ctx.Value(telemetryContextID).(string) 20 | 21 | assert.NotEmpty(t, value) 22 | }) 23 | 24 | t.Run("run inherititng context_id", func(t *testing.T) { 25 | ctx := context.Background() 26 | 27 | expected := uuid.New().String() 28 | t.Setenv("SHUTTLE_CONTEXT_ID", expected) 29 | 30 | ctx = WithContextID(ctx) 31 | 32 | value := ctx.Value(telemetryContextID).(string) 33 | 34 | assert.Equal(t, expected, value) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/config/planargument.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | 8 | "github.com/lunarway/shuttle/pkg/git" 9 | ) 10 | 11 | func isPlanArgumentAFilePlan(planArgument string) bool { 12 | return strings.HasPrefix(planArgument, "/") || strings.HasPrefix(planArgument, "./") || 13 | strings.HasPrefix(planArgument, "../") 14 | } 15 | 16 | func isPlanArgumentAPlan(planArgument string) bool { 17 | return planArgument != "" && (git.IsPlan(planArgument) || isPlanArgumentAFilePlan(planArgument)) 18 | } 19 | 20 | func getPlanFromPlanArgument(planArgument string) string { 21 | switch { 22 | case isPlanArgumentAFilePlan(planArgument) && isFilePath(planArgument, false): 23 | wd, err := os.Getwd() 24 | if err != nil { 25 | panic(err) 26 | } 27 | return path.Join(wd, planArgument) 28 | default: 29 | return planArgument 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/list_test.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIsActionsEnabled(t *testing.T) { 10 | t.Run("default is enabled", func(t *testing.T) { 11 | t.Setenv("SHUTTLE_GOLANG_ACTIONS", "") 12 | 13 | assert.False(t, isActionsEnabled()) 14 | }) 15 | 16 | t.Run("set false is not enabled", func(t *testing.T) { 17 | t.Setenv("SHUTTLE_GOLANG_ACTIONS", "false") 18 | 19 | assert.False(t, isActionsEnabled()) 20 | }) 21 | 22 | t.Run("set true is enabled", func(t *testing.T) { 23 | t.Setenv("SHUTTLE_GOLANG_ACTIONS", "true") 24 | 25 | assert.True(t, isActionsEnabled()) 26 | }) 27 | 28 | t.Run("set any other value is enabled", func(t *testing.T) { 29 | t.Setenv("SHUTTLE_GOLANG_ACTIONS", "blabla") 30 | 31 | assert.False(t, isActionsEnabled()) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/executors/golang/compile/compile_test.go: -------------------------------------------------------------------------------- 1 | package compile_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lunarway/shuttle/pkg/config" 9 | "github.com/lunarway/shuttle/pkg/executors/golang/compile" 10 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 11 | "github.com/lunarway/shuttle/pkg/ui" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestCompile(t *testing.T) { 16 | ctx := context.Background() 17 | discovered, err := discover.Discover( 18 | ctx, 19 | "testdata/simple/shuttle.yaml", 20 | &config.ShuttleProjectContext{}, 21 | ) 22 | assert.NoError(t, err) 23 | 24 | uiout := ui.Create(os.Stdout, os.Stderr) 25 | 26 | path, err := compile.Compile(ctx, uiout, discovered) 27 | assert.NoError(t, err) 28 | 29 | assert.Contains(t, path.Local.Path, "testdata/simple/.shuttle/actions/binaries/actions-") 30 | } 31 | -------------------------------------------------------------------------------- /examples/station-plan/plan.yaml: -------------------------------------------------------------------------------- 1 | documentation: https://github.com/lunarway/shuttle 2 | scripts: 3 | build: 4 | description: Build the docker image 5 | args: 6 | - name: tag 7 | required: true 8 | actions: 9 | - shell: shuttle template -o Dockerfile Dockerfile.tmpl GO_VERSION=1.10.3 10 | - shell: docker build -f $tmp/Dockerfile -t $(shuttle get docker.image):$tag . 11 | test: 12 | description: Run test for the project 13 | actions: 14 | - shell: go test 15 | say-hi: 16 | description: just say hi 17 | args: [] 18 | actions: 19 | - shell: echo "test" 20 | crash: 21 | description: I will fail you 22 | args: [] 23 | actions: 24 | - shell: exit 1 25 | required-arg: 26 | description: Provide arg and succeed 27 | args: 28 | - name: a 29 | required: true 30 | actions: 31 | - shell: echo "Arg provided" 32 | -------------------------------------------------------------------------------- /cmd/template_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTemplate(t *testing.T) { 8 | testCases := []testCase{ 9 | { 10 | name: "local path", 11 | input: args( 12 | "-p", 13 | "testdata/project", 14 | "template", 15 | "../custom-template.tmpl", 16 | "GO_VERSION=1.17", 17 | ), 18 | stdoutput: `# Custom docker file template not located inside a project 19 | FROM golang:1.17-alpine 20 | `, 21 | erroutput: "", 22 | err: nil, 23 | }, 24 | { 25 | name: "alternative delimiters", 26 | input: args( 27 | "-p", 28 | "testdata/project", 29 | "template", 30 | "../custom-template-alternative-delims.tmpl", 31 | "--delims", 32 | ">>,<<", 33 | ), 34 | stdoutput: `FROM golang:1.17-alpine 35 | LABEL svc=shuttle 36 | `, 37 | erroutput: "", 38 | err: nil, 39 | }, 40 | } 41 | executeTestCases(t, testCases) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "io/fs" 6 | "os" 7 | ) 8 | 9 | type writeFileFunc = func(name string, contents []byte, permissions fs.FileMode) error 10 | 11 | type Patcher struct { 12 | patchFinder *chainedPackageFinder 13 | patcher *goModPatcher 14 | } 15 | 16 | func NewPatcher() *Patcher { 17 | return &Patcher{ 18 | patchFinder: newChainedPatchFinder( 19 | newWorkspaceFinder(), 20 | newGoModuleFinder(), 21 | newDefaultFinder(), 22 | ), 23 | patcher: newGoModPatcher(os.WriteFile), 24 | } 25 | } 26 | 27 | func (p *Patcher) Patch(ctx context.Context, rootDir string, shuttleLocalDir string) error { 28 | packages, err := p.patchFinder.findPackages(ctx, rootDir) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if err := p.patcher.patch(rootDir, shuttleLocalDir, packages); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import "context" 4 | 5 | func Trace(ctx context.Context, label string, options ...TelemetryOption) { 6 | properties := setProperties(append(options, WithLabel(label))...) 7 | properties = includeContext(ctx, properties) 8 | client.Trace(ctx, properties) 9 | } 10 | 11 | func TraceError(ctx context.Context, label string, err error, options ...TelemetryOption) { 12 | properties := setProperties(append(options, WithLabel(label))...) 13 | properties = includeContext(ctx, properties) 14 | 15 | // TODO: consider enum for error (const list) 16 | properties["phase"] = "error" 17 | if err != nil { 18 | properties["error"] = err.Error() 19 | } 20 | 21 | client.Trace(ctx, properties) 22 | } 23 | 24 | func setProperties(options ...TelemetryOption) map[string]string { 25 | properties := make(map[string]string) 26 | for _, o := range options { 27 | o(properties) 28 | } 29 | 30 | return properties 31 | } 32 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/run.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/lunarway/shuttle/pkg/config" 8 | golangerrors "github.com/lunarway/shuttle/pkg/executors/golang/errors" 9 | "github.com/lunarway/shuttle/pkg/ui" 10 | ) 11 | 12 | func Run( 13 | ctx context.Context, 14 | ui *ui.UI, 15 | c *config.ShuttleProjectContext, 16 | path string, 17 | args ...string, 18 | ) error { 19 | if !isActionsEnabled() { 20 | ui.Verboseln("shuttle golang actions disabled") 21 | return nil 22 | } 23 | 24 | binaries, err := prepare(ctx, ui, path, c) 25 | if err != nil { 26 | if errors.Is(err, golangerrors.ErrGolangActionNoBuilder) { 27 | return nil 28 | } 29 | 30 | ui.Errorln("failed to run command: %v", err) 31 | return err 32 | } 33 | 34 | ui.Verboseln("executing shuttle golang actions") 35 | if err := executeAction(ctx, binaries, args...); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version: 1.25.5 22 | - name: Cache Go modules 23 | uses: actions/cache@v4 24 | with: 25 | path: ~/go/pkg/mod 26 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-go- 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v6 31 | with: 32 | distribution: goreleaser 33 | version: latest 34 | args: release --clean 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/codegen.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "html/template" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 12 | "github.com/lunarway/shuttle/pkg/executors/golang/parser" 13 | ) 14 | 15 | //go:embed templates/mainFile.tmpl 16 | var mainFileTmpl embed.FS 17 | 18 | func GenerateMainFile( 19 | ctx context.Context, 20 | shuttlelocaldir string, 21 | actions *discover.ActionsDiscovered, 22 | functions []*parser.Function, 23 | ) error { 24 | tmpmainfile := path.Join(shuttlelocaldir, "tmp/main.go") 25 | 26 | file, err := os.Create(tmpmainfile) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | tmpl := template. 32 | Must( 33 | template. 34 | New("mainFile.tmpl"). 35 | Funcs(map[string]any{ 36 | "lower": strings.ToLower, 37 | }). 38 | ParseFS(mainFileTmpl, "templates/mainFile.tmpl"), 39 | ) 40 | 41 | err = tmpl.Execute(file, functions) 42 | 43 | return err 44 | } 45 | -------------------------------------------------------------------------------- /cmd/ls_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestLs(t *testing.T) { 9 | testCases := []testCase{ 10 | { 11 | name: "invalid shuttle.yaml file", 12 | input: args("-p", "testdata/invalid-yaml", "ls"), 13 | stdoutput: "", 14 | erroutput: "Error: exit code 2 - Failed to parse shuttle configuration: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `plan` into config.ShuttleConfig\n\nMake sure your 'shuttle.yaml' is valid.\n", 15 | initErr: errors.New( 16 | "exit code 2 - Failed to parse shuttle configuration: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `plan` into config.ShuttleConfig\n\nMake sure your 'shuttle.yaml' is valid.", 17 | ), 18 | }, 19 | { 20 | name: "list one action", 21 | input: args("-p", "testdata/project", "ls"), 22 | stdoutput: "Available Scripts:\n exit_0 \n exit_1 \n hello_stderr \n hello_stdout \n required_arg \n", 23 | erroutput: "", 24 | err: nil, 25 | }, 26 | } 27 | executeTestCases(t, testCases) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/telemetry/context.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | const envContextID = "SHUTTLE_CONTEXT_ID" 11 | 12 | func WithContextID(ctx context.Context) context.Context { 13 | if context_id := os.Getenv(envContextID); context_id != "" { 14 | return context.WithValue(ctx, telemetryContextID, context_id) 15 | } 16 | 17 | return context.WithValue(ctx, telemetryContextID, uuid.New().String()) 18 | } 19 | 20 | func ContextIDFrom(ctx context.Context) string { 21 | if contextID, ok := ctx.Value(telemetryContextID).(string); ok { 22 | return contextID 23 | } 24 | return "" 25 | } 26 | 27 | func WithContextValue(ctx context.Context, key, value string) context.Context { 28 | return context.WithValue(ctx, key, value) 29 | } 30 | 31 | func WithRunID(ctx context.Context) context.Context { 32 | return context.WithValue(ctx, telemetryRunID, uuid.New().String()) 33 | } 34 | 35 | func RunIDFrom(ctx context.Context) string { 36 | if contextID, ok := ctx.Value(telemetryRunID).(string); ok { 37 | return contextID 38 | } 39 | return "" 40 | } 41 | -------------------------------------------------------------------------------- /cmd/plan_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestPlan(t *testing.T) { 9 | testCases := []testCase{ 10 | { 11 | name: "no plan", 12 | input: args("-p", "testdata/project", "plan"), 13 | stdoutput: "", 14 | erroutput: "", 15 | err: nil, 16 | }, 17 | { 18 | name: "git plan", 19 | input: args("-p", "testdata/project-git", "plan"), 20 | stdoutput: "https://github.com/lunarway/shuttle-example-go-plan.git", 21 | erroutput: "Cloning plan https://github.com/lunarway/shuttle-example-go-plan.git\n", 22 | err: nil, 23 | }, 24 | { 25 | name: "git plan invalid checkout", 26 | input: args("-p", "testdata/project-git", "--plan", "something-invalid", "plan"), 27 | initErr: errors.New("Plan argument wasn't valid for a git plan (#): something-invalid"), 28 | }, 29 | { 30 | name: "no plan with template", 31 | input: args("-p", "testdata/project", "plan", "--template", "{{.PlanRaw}}"), 32 | stdoutput: "false", 33 | erroutput: "", 34 | err: nil, 35 | }, 36 | } 37 | executeTestCases(t, testCases) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/executors/golang/shuttlefolder/prepare.go: -------------------------------------------------------------------------------- 1 | package shuttlefolder 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path" 7 | 8 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 9 | cp "github.com/otiai10/copy" 10 | ) 11 | 12 | func CopyFiles( 13 | ctx context.Context, 14 | shuttlelocaldir string, 15 | actions *discover.ActionsDiscovered, 16 | ) error { 17 | tmpdir := path.Join(shuttlelocaldir, "tmp") 18 | 19 | return cp.Copy(actions.DirPath, tmpdir) 20 | } 21 | 22 | func Move(src, dest string) error { 23 | return os.Rename(src, dest) 24 | } 25 | 26 | func GenerateTmpDir(ctx context.Context, shuttlelocaldir string) error { 27 | if err := os.MkdirAll(shuttlelocaldir, 0o755); err != nil { 28 | return err 29 | } 30 | 31 | binarydir := path.Join(shuttlelocaldir, "binaries") 32 | if err := os.RemoveAll(binarydir); err != nil { 33 | return nil 34 | } 35 | if err := os.MkdirAll(binarydir, 0o755); err != nil { 36 | return err 37 | } 38 | 39 | tmpdir := path.Join(shuttlelocaldir, "tmp") 40 | if err := os.RemoveAll(tmpdir); err != nil { 41 | return nil 42 | } 43 | if err := os.MkdirAll(tmpdir, 0o755); err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /cmd/get_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGet(t *testing.T) { 8 | testCases := []testCase{ 9 | { 10 | name: "local plan", 11 | input: args("-p", "testdata/project", "get", "service"), 12 | stdoutput: "shuttle", 13 | erroutput: "", 14 | err: nil, 15 | }, 16 | { 17 | name: "local plan with templating", 18 | input: args( 19 | "-p", 20 | "testdata/project", 21 | "get", 22 | "nested", 23 | "--template", 24 | "{{ range $k, $v := . }}{{ $k }}{{ end }}", 25 | ), 26 | stdoutput: "subvar", 27 | erroutput: "", 28 | err: nil, 29 | }, 30 | { 31 | name: "local plan with templating function", 32 | input: args( 33 | "-p", 34 | "testdata/project", 35 | "get", 36 | "nested", 37 | "--template", 38 | `{{ range objectArray "sub" . }}{{ .Key }}{{ end }}`, 39 | ), 40 | stdoutput: "field", 41 | erroutput: "", 42 | err: nil, 43 | }, 44 | { 45 | name: "bool", 46 | input: args("-p", "testdata/project", "get", "boolVar"), 47 | stdoutput: "false", 48 | erroutput: "", 49 | err: nil, 50 | }, 51 | } 52 | executeTestCases(t, testCases) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/prepare.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/lunarway/shuttle/pkg/config" 11 | "github.com/lunarway/shuttle/pkg/executors/golang/compile" 12 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 13 | golangerrors "github.com/lunarway/shuttle/pkg/executors/golang/errors" 14 | "github.com/lunarway/shuttle/pkg/ui" 15 | ) 16 | 17 | func prepare( 18 | ctx context.Context, 19 | ui *ui.UI, 20 | path string, 21 | c *config.ShuttleProjectContext, 22 | ) (*compile.Binaries, error) { 23 | ui.Verboseln("preparing shuttle golang actions") 24 | start := time.Now() 25 | log.SetFlags(log.LstdFlags | log.Lshortfile) 26 | 27 | disc, err := discover.Discover(ctx, path, c) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to discover actions: %v", err) 30 | } 31 | 32 | binaries, err := compile.Compile(ctx, ui, disc) 33 | if err != nil { 34 | if errors.Is(err, golangerrors.ErrGolangActionNoBuilder) { 35 | return nil, err 36 | } 37 | return nil, fmt.Errorf("failed to compile binaries: %v", err) 38 | } 39 | 40 | elapsed := time.Since(start) 41 | ui.Verboseln("preparing shuttle golang actions took: %d ms", elapsed.Milliseconds()) 42 | 43 | return binaries, nil 44 | } 45 | -------------------------------------------------------------------------------- /cmd/telemetry.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | stdcontext "context" 5 | "strings" 6 | 7 | "github.com/lunarway/shuttle/pkg/telemetry" 8 | ) 9 | 10 | func trace( 11 | ctx stdcontext.Context, 12 | name string, 13 | args []string, 14 | ) (stdcontext.Context, func(options ...telemetry.TelemetryOption), func(err error, options ...telemetry.TelemetryOption), func()) { 15 | ctx = telemetry.WithContextID(ctx) 16 | ctx = telemetry.WithRunID(ctx) 17 | ctx = WithRunTelemetry(ctx, name, args) 18 | 19 | traceInfo := func(options ...telemetry.TelemetryOption) { 20 | telemetry.Trace(ctx, name, telemetry.WithPhase("start")) 21 | } 22 | traceInfo(telemetry.WithPhase("start")) 23 | traceErr := func(err error, options ...telemetry.TelemetryOption) { 24 | telemetry.TraceError(ctx, name, err) 25 | } 26 | 27 | return ctx, 28 | traceInfo, 29 | traceErr, 30 | func() { 31 | telemetry.Trace(ctx, name, telemetry.WithPhase("end")) 32 | } 33 | } 34 | 35 | func WithRunTelemetry( 36 | ctx stdcontext.Context, 37 | commandName string, 38 | args []string, 39 | ) stdcontext.Context { 40 | ctx = stdcontext.WithValue(ctx, telemetry.TelemetryCommand, commandName) 41 | if len(args) != 0 { 42 | ctx = stdcontext.WithValue(ctx, telemetry.TelemetryCommandArgs, strings.Join(args[1:], " ")) 43 | } 44 | return ctx 45 | } 46 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch_patcher.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | ) 8 | 9 | type goModPatcher struct { 10 | writeFileFunc writeFileFunc 11 | } 12 | 13 | func newGoModPatcher(writeFileFunc writeFileFunc) *goModPatcher { 14 | return &goModPatcher{writeFileFunc: writeFileFunc} 15 | } 16 | 17 | func (p *goModPatcher) patch(rootDir string, shuttleLocalDir string, packages map[string]string) error { 18 | actionsModFile, err := p.readActionsMod(shuttleLocalDir) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | for _, module := range modulesFromMap(packages) { 24 | if !actionsModFile.containsModule(module.name) { 25 | continue 26 | } 27 | 28 | actionsModFile.replaceModulePath(rootDir, module) 29 | } 30 | 31 | return actionsModFile.commit() 32 | } 33 | 34 | func (g *goModPatcher) readActionsMod(shuttleLocalDir string) (*actionsModFile, error) { 35 | path := path.Join(shuttleLocalDir, "tmp/go.mod") 36 | 37 | content, err := os.ReadFile(path) 38 | if err != nil { 39 | return nil, err 40 | } 41 | info, err := os.Stat(path) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | lines := strings.Split(string(content), "\n") 47 | 48 | return &actionsModFile{ 49 | info: info, 50 | content: lines, 51 | path: path, 52 | 53 | writeFileFunc: g.writeFileFunc, 54 | }, nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch_finder.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // packageFinder exists to find whatever patches are required for a given shuttle golang action to function 9 | type packageFinder interface { 10 | // Find should return how many packages are required to function 11 | Find(ctx context.Context, rootDir string) (packages map[string]string, ok bool, err error) 12 | } 13 | 14 | type chainedPackageFinder struct { 15 | finders []packageFinder 16 | } 17 | 18 | func newChainedPatchFinder(finders ...packageFinder) *chainedPackageFinder { 19 | return &chainedPackageFinder{ 20 | finders: finders, 21 | } 22 | } 23 | 24 | // FindPackages is setup as a chain of responsibility, which means that from most significant it will attempt to find packages 25 | // to be used. However, each finder needs to return how many packages it needs to function, as returning ok means that the finder has exclusive access to the packages 26 | func (p *chainedPackageFinder) findPackages(ctx context.Context, rootDir string) (packages map[string]string, err error) { 27 | for _, finder := range p.finders { 28 | packages, ok, err := finder.Find(ctx, rootDir) 29 | if err != nil { 30 | return nil, err 31 | } 32 | if ok { 33 | return packages, nil 34 | } 35 | } 36 | 37 | return nil, errors.New("failed to find a valid patcher") 38 | } 39 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Install Shuttle" 2 | description: "Installs Lunar Shuttle" 3 | runs: 4 | using: "composite" 5 | steps: 6 | - run: | 7 | set -e 8 | 9 | VERSION=$(curl -fLso /dev/null -w '%{url_effective}' https://github.com/lunarway/shuttle/releases/latest) || { echo "Failed to fetch latest release version" >&2; exit 1; } 10 | VERSION=${VERSION##*/} 11 | 12 | if [ "$(uname)" == "Darwin" ]; then 13 | mkdir -p ~/bin 14 | 15 | if [[ $(uname -m) == 'arm64' ]]; then 16 | curl -fLO https://github.com/lunarway/shuttle/releases/download/${VERSION}/shuttle-darwin-arm64 17 | chmod +x shuttle-darwin-arm64 18 | mv shuttle-darwin-arm64 ~/bin/shuttle 19 | else 20 | curl -fLO https://github.com/lunarway/shuttle/releases/download/${VERSION}/shuttle-darwin-amd64 21 | chmod +x shuttle-darwin-amd64 22 | mv shuttle-darwin-amd64 ~/bin/shuttle 23 | fi 24 | 25 | echo "~/bin/" >> $GITHUB_PATH 26 | export PATH=$PATH:~/bin 27 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 28 | curl -fLO https://github.com/lunarway/shuttle/releases/download/${VERSION}/shuttle-linux-amd64 29 | chmod +x shuttle-linux-amd64 30 | mv shuttle-linux-amd64 /usr/local/bin/shuttle 31 | fi 32 | shuttle version 33 | shell: bash 34 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/run_test.go: -------------------------------------------------------------------------------- 1 | package executer_test 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "testing" 9 | 10 | "github.com/lunarway/shuttle/pkg/config" 11 | "github.com/lunarway/shuttle/pkg/executors/golang/executer" 12 | "github.com/lunarway/shuttle/pkg/ui" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestRunVersion(t *testing.T) { 17 | updateShuttle(t, "testdata/child") 18 | ctx := context.Background() 19 | 20 | t.Setenv("SHUTTLE_GOLANG_ACTIONS", "true") 21 | 22 | c := &config.ShuttleProjectContext{Config: config.ShuttleConfig{Plan: "something"}} 23 | 24 | ui := ui.Create(os.Stdout, os.Stderr) 25 | 26 | err := executer.Run(ctx, ui, c, "testdata/child/shuttle.yaml", "version") 27 | assert.NoError(t, err) 28 | 29 | err = executer.Run(ctx, ui, c, "testdata/child/shuttle.yaml", "build") 30 | assert.NoError(t, err) 31 | 32 | err = executer.Run( 33 | ctx, 34 | ui, 35 | c, 36 | "testdata/child/shuttle.yaml", 37 | "build", 38 | "--some-unexisting-arg", 39 | "something", 40 | ) 41 | assert.Error(t, err) 42 | } 43 | 44 | func updateShuttle(t *testing.T, path string) { 45 | err := os.RemoveAll("testdata/child/.shuttle/") 46 | assert.NoError(t, err) 47 | 48 | shuttleCmd := exec.Command("shuttle", "ls") 49 | shuttleCmd.Dir = path 50 | if output, err := shuttleCmd.CombinedOutput(); err != nil { 51 | log.Printf("%s\n", string(output)) 52 | assert.Error(t, err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/actions.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | type ( 4 | // Actions represents all the possible commands to be sent to the golang actions binaries. 5 | // Such as `shuttle run daggerbuild --arg something`, in this daggerbuild would be the name, and arg being an Arg for said action 6 | Actions struct { 7 | Actions map[string]Action `json:"actions"` 8 | } 9 | 10 | Action struct { 11 | Args []ActionArg `json:"args"` 12 | } 13 | 14 | ActionArg struct { 15 | Name string `json:"name"` 16 | } 17 | ) 18 | 19 | func NewActions() *Actions { 20 | return &Actions{ 21 | Actions: make(map[string]Action, 0), 22 | } 23 | } 24 | 25 | // Merge exists to combine multiple Actions from a variety of binaries into one. 26 | // This allows a single set of actions to represent all possible actions by shuttle in a given context 27 | func (a *Actions) Merge(other ...*Actions) *Actions { 28 | for _, actions := range other { 29 | if actions == nil { 30 | continue 31 | } 32 | for name, action := range actions.Actions { 33 | a.Actions[name] = action 34 | } 35 | } 36 | 37 | return a 38 | } 39 | 40 | // Execute can execute a single action given a name, and a closure to handle any actual execution 41 | func (a *Actions) Execute(action string, fn func() error) (ran bool, err error) { 42 | if a == nil { 43 | return false, nil 44 | } 45 | 46 | if _, ok := a.Actions[action]; ok { 47 | return true, fn() 48 | } 49 | 50 | return false, nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/list.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/lunarway/shuttle/pkg/config" 10 | golangerrors "github.com/lunarway/shuttle/pkg/executors/golang/errors" 11 | "github.com/lunarway/shuttle/pkg/ui" 12 | ) 13 | 14 | func List( 15 | ctx context.Context, 16 | ui *ui.UI, 17 | path string, 18 | c *config.ShuttleProjectContext, 19 | ) (*Actions, error) { 20 | if !isActionsEnabled() { 21 | ui.Verboseln("shuttle golang actions disabled") 22 | return NewActions(), nil 23 | } 24 | 25 | binaries, err := prepare(ctx, ui, path, c) 26 | if err != nil { 27 | if errors.Is(err, golangerrors.ErrGolangActionNoBuilder) { 28 | return NewActions(), nil 29 | } 30 | return nil, err 31 | } 32 | 33 | localInquire, err := inquire(ctx, &binaries.Local) 34 | if err != nil { 35 | return nil, err 36 | } 37 | planInquire, err := inquire(ctx, &binaries.Plan) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | actions := NewActions(). 43 | Merge(localInquire). 44 | Merge(planInquire) 45 | 46 | return actions, nil 47 | } 48 | 49 | const defaultGolangActions = false 50 | 51 | func isActionsEnabled() bool { 52 | enabledRaw := os.Getenv("SHUTTLE_GOLANG_ACTIONS") 53 | if enabledRaw == "" { 54 | return defaultGolangActions 55 | } 56 | 57 | enabled, err := strconv.ParseBool(enabledRaw) 58 | if err != nil { 59 | return defaultGolangActions 60 | } 61 | 62 | return enabled 63 | 64 | } 65 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "gopkg.in/yaml.v2" 8 | 9 | "github.com/lunarway/shuttle/pkg/errors" 10 | "github.com/lunarway/shuttle/pkg/templates" 11 | "github.com/lunarway/shuttle/pkg/ui" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func newGet(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 17 | var getFlagTemplate string 18 | getCmd := &cobra.Command{ 19 | Use: "get [variable]", 20 | Short: "Get a variable value", 21 | Args: cobra.ExactArgs(1), 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | uii.SetContext(ui.LevelError) 24 | 25 | context, err := contextProvider() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | path := args[0] 31 | var templ string 32 | if getFlagTemplate != "" { 33 | templ = getFlagTemplate 34 | } 35 | value := templates.TmplGet(path, context.Config.Variables) 36 | if templ != "" { 37 | err := ui.Template(cmd.OutOrStdout(), "get", templ, value) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | switch value.(type) { 45 | case nil: 46 | // print nothing 47 | default: 48 | x, err := yaml.Marshal(value) 49 | if err != nil { 50 | return errors.NewExitCode(9, "Could not yaml encode value '%s'\nError: %s", value, err) 51 | } 52 | fmt.Fprint(cmd.OutOrStdout(), strings.TrimRight(string(x), "\n")) 53 | } 54 | 55 | return nil 56 | }, 57 | } 58 | 59 | getCmd.Flags(). 60 | StringVar(&getFlagTemplate, "template", "", "Template string to use. See --help for details.") 61 | 62 | return getCmd 63 | } 64 | -------------------------------------------------------------------------------- /cmd/documentation.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lunarway/shuttle/pkg/browser" 5 | "github.com/lunarway/shuttle/pkg/errors" 6 | "github.com/lunarway/shuttle/pkg/ui" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func newDocumentation(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 11 | documentationCommand := &cobra.Command{ 12 | Use: "documentation", 13 | Aliases: []string{"docs"}, 14 | Short: "Open documentation for the configured shuttle plan", 15 | Long: `Open documentation for the configured shuttle plan. 16 | By default shuttle will try to open the plan's documentation in a web browser. 17 | 18 | If no docs are explicitly configured in the plan, the plan it self is opened. 19 | Usually this will target a hosted git repository, eg. GitHub README. 20 | 21 | The application to open the documentation is inferred from the operating system 22 | and respects the BROWSER environment variable.`, 23 | Args: cobra.ExactArgs(0), 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | context, err := contextProvider() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | url, err := context.DocumentationURL() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | uii.Infoln("Documentation available at: %s", url) 36 | 37 | browseCmd, err := browser.Command(url, cmd.ErrOrStderr()) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | err = browseCmd.Run() 43 | if err != nil { 44 | return errors.NewExitCode(1, "Failed to open document reference: %v", err) 45 | } 46 | 47 | return nil 48 | }, 49 | } 50 | 51 | return documentationCommand 52 | } 53 | -------------------------------------------------------------------------------- /pkg/config/documentation.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/lunarway/shuttle/pkg/errors" 9 | "github.com/lunarway/shuttle/pkg/git" 10 | ) 11 | 12 | // DocumentationURL returns a URL pointing to plan documentation if any is 13 | // available. Plan reference and plan documentation field is inspected and 14 | // parsed. 15 | func (p *ShuttleProjectContext) DocumentationURL() (string, error) { 16 | var ref string 17 | switch { 18 | case p.Plan.Documentation != "": 19 | ref = p.Plan.Documentation 20 | case p.Config.Plan != "": 21 | ref = p.Config.Plan 22 | default: 23 | return "", errors.NewExitCode(1, "Could not find any plan documentation") 24 | } 25 | 26 | switch { 27 | case git.IsPlan(ref): 28 | return normalizeGitPlan(git.ParsePlan(ref)) 29 | case isHTTPSPlan(ref): 30 | return ref, nil 31 | case filepath.IsAbs(ref), strings.HasPrefix(ref, "./"), strings.HasPrefix(ref, "../"): 32 | return "", errors.NewExitCode(2, "Local plan has no documentation") 33 | default: 34 | return "", errors.NewExitCode(1, "Could not detect protocol for plan '%s'", ref) 35 | } 36 | } 37 | 38 | func normalizeGitPlan(p git.Plan) (string, error) { 39 | switch p.Protocol { 40 | case "https": 41 | return fmt.Sprintf("%s://%s", p.Protocol, p.Repository), nil 42 | case "ssh": 43 | repoSlug := strings.TrimPrefix(p.Repository, fmt.Sprintf("%s:", p.Host)) 44 | return fmt.Sprintf("https://%s/%s", p.Host, repoSlug), nil 45 | default: 46 | // this should never happen as parsed git plans always has a protocol of ssh 47 | // or https 48 | return "", errors.NewExitCode(1, "Could not parse git plan reference") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/has_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestHas(t *testing.T) { 10 | testCases := []testCase{ 11 | { 12 | name: "bool variable", 13 | input: args("-p", "testdata/project", "has", "boolVar"), 14 | stdoutput: "", 15 | erroutput: "", 16 | err: nil, 17 | }, 18 | { 19 | name: "has wrong argument switch", 20 | input: args("-j", "testdata/project", "has", "docker.baseImage"), 21 | stdoutput: "", 22 | erroutput: "", 23 | err: fmt.Errorf("unknown shorthand flag: 'j' in -j"), 24 | }, 25 | { 26 | name: "stdout", 27 | input: args("-p", "testdata/project", "has", "--stdout", "boolVar"), 28 | stdoutput: "true", 29 | erroutput: "", 30 | err: nil, 31 | }, 32 | { 33 | name: "not existing", 34 | input: args("-p", "testdata/project", "has", "unknown"), 35 | stdoutput: "", 36 | erroutput: "", 37 | err: errors.New("exit code 1 - "), 38 | }, 39 | { 40 | name: "not existing stdout", 41 | input: args("-p", "testdata/project", "has", "--stdout", "unknown"), 42 | stdoutput: "false", 43 | erroutput: "", 44 | err: nil, 45 | }, 46 | { 47 | name: "script", 48 | input: args("-p", "testdata/project", "has", "--script", "hello_stdout"), 49 | stdoutput: "", 50 | erroutput: "", 51 | err: nil, 52 | }, 53 | { 54 | name: "script", 55 | input: args("-p", "testdata/project", "has", "--script", "unknown"), 56 | stdoutput: "", 57 | erroutput: "", 58 | err: errors.New("exit code 1 - "), 59 | }, 60 | } 61 | executeTestCases(t, testCases) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/mod_file.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "path" 7 | "strings" 8 | 9 | "golang.org/x/exp/slices" 10 | ) 11 | 12 | type actionsModFile struct { 13 | info fs.FileInfo 14 | content []string 15 | path string 16 | 17 | writeFileFunc writeFileFunc 18 | } 19 | 20 | func (a *actionsModFile) containsModule(moduleName string) bool { 21 | return slices.ContainsFunc(a.content, func(s string) bool { 22 | return strings.Contains(s, moduleName) 23 | }) 24 | } 25 | 26 | func (a *actionsModFile) replaceModulePath(rootDir string, module module) { 27 | relativeToActionsModulePath := path.Join(strings.Repeat("../", a.segmentsTo(rootDir)), module.path) 28 | 29 | foundReplace := false 30 | for i, line := range a.content { 31 | lineTrim := strings.TrimSpace(line) 32 | 33 | if strings.Contains(lineTrim, fmt.Sprintf("replace %s", module.name)) { 34 | a.content[i] = fmt.Sprintf("replace %s => %s", module.name, relativeToActionsModulePath) 35 | foundReplace = true 36 | break 37 | } 38 | 39 | } 40 | 41 | if !foundReplace { 42 | a.content = append( 43 | a.content, 44 | fmt.Sprintf("\nreplace %s => %s", module.name, relativeToActionsModulePath), 45 | ) 46 | 47 | } 48 | } 49 | 50 | func (a *actionsModFile) segmentsTo(dirPath string) int { 51 | relativeActionsModFilePath := strings.TrimPrefix( 52 | strings.TrimPrefix( 53 | a.path, 54 | dirPath, 55 | ), 56 | "/", 57 | ) 58 | 59 | return strings.Count(relativeActionsModFilePath, "/") 60 | } 61 | 62 | func (a *actionsModFile) commit() error { 63 | return a.writeFileFunc( 64 | a.path, 65 | []byte(strings.Join(a.content, "\n")), 66 | a.info.Mode(), 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /cmd/has.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lunarway/shuttle/pkg/errors" 7 | "github.com/lunarway/shuttle/pkg/templates" 8 | "github.com/lunarway/shuttle/pkg/ui" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func newHas(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 13 | var ( 14 | lookupInScripts bool 15 | outputAsStdout bool 16 | ) 17 | 18 | hasCmd := &cobra.Command{ 19 | Use: "has [variable]", 20 | Short: "Check if a variable (or script) is defined", 21 | Args: cobra.ExactArgs(1), 22 | SilenceUsage: true, 23 | SilenceErrors: true, 24 | // Long: ``, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | uii.SetContext(ui.LevelSilent) 27 | 28 | context, err := contextProvider() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | variable := args[0] 34 | 35 | var found bool 36 | 37 | if lookupInScripts { 38 | _, found = context.Scripts[variable] 39 | } else { 40 | found = templates.TmplGet(variable, context.Config.Variables) != nil 41 | } 42 | 43 | if outputAsStdout { 44 | if found { 45 | fmt.Fprint(cmd.OutOrStdout(), "true") 46 | } else { 47 | fmt.Fprint(cmd.OutOrStderr(), "false") 48 | } 49 | return nil 50 | } else { 51 | if found { 52 | return nil 53 | } else { 54 | return errors.NewExitCode(1, "") 55 | } 56 | } 57 | }, 58 | } 59 | 60 | hasCmd.Flags(). 61 | BoolVar(&lookupInScripts, "script", false, "Lookup existence in scripts instead of vars") 62 | hasCmd.Flags(). 63 | BoolVarP(&outputAsStdout, "stdout", "o", false, "Print result to stdout instead of exit code as `true` or `false`") 64 | 65 | return hasCmd 66 | } 67 | -------------------------------------------------------------------------------- /pkg/executors/docker.go: -------------------------------------------------------------------------------- 1 | package executors 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path" 10 | ) 11 | 12 | // Build builds the docker image from a shuttle plan 13 | func executeDocker(ctx context.Context, context ActionExecutionContext) error { 14 | dockerFilePath := path.Join( 15 | context.ScriptContext.Project.LocalPlanPath, 16 | context.Action.Dockerfile, 17 | ) 18 | projectPath := context.ScriptContext.Project.ProjectPath 19 | execCmd := exec.Command("docker", "build", "-f", dockerFilePath, projectPath) 20 | 21 | var errStdout, errStderr error 22 | stdoutIn, _ := execCmd.StdoutPipe() 23 | stderrIn, _ := execCmd.StderrPipe() 24 | execCmd.Start() 25 | 26 | go func() { 27 | _, errStdout = copyAndCapture(os.Stdout, stdoutIn) 28 | }() 29 | 30 | go func() { 31 | _, errStderr = copyAndCapture(os.Stderr, stderrIn) 32 | }() 33 | 34 | err := execCmd.Wait() 35 | if err != nil { 36 | log.Fatalf("cmd.Run() failed with %s\n", err) 37 | } 38 | if errStdout != nil || errStderr != nil { 39 | log.Fatalf("failed to capture stdout or stderr\n") 40 | } 41 | return nil 42 | } 43 | 44 | func copyAndCapture(w io.Writer, r io.Reader) ([]byte, error) { 45 | var out []byte 46 | buf := make([]byte, 1024, 1024) 47 | for { 48 | n, err := r.Read(buf[:]) 49 | if n > 0 { 50 | d := buf[:n] 51 | out = append(out, d...) 52 | _, err := w.Write(d) 53 | if err != nil { 54 | return out, err 55 | } 56 | } 57 | if err != nil { 58 | // Read returns io.EOF at the end of file, which is not an error for us 59 | if err == io.EOF { 60 | err = nil 61 | } 62 | return out, err 63 | } 64 | } 65 | // never reached 66 | panic(true) 67 | return nil, nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/ls.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lunarway/shuttle/pkg/config" 5 | "github.com/lunarway/shuttle/pkg/ui" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | const lsDefaultTempl = ` 10 | {{- $max := .Max -}} 11 | Available Scripts: 12 | {{- range $key, $value := .Scripts}} 13 | {{rightPad $key $max }} {{upperFirst $value.Description}} 14 | {{- end}} 15 | ` 16 | 17 | type templData struct { 18 | Scripts map[string]config.ShuttlePlanScript 19 | Max int 20 | } 21 | 22 | func newLs(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 23 | var lsFlagTemplate string 24 | 25 | lsCmd := &cobra.Command{ 26 | Use: "ls [command]", 27 | Short: "List possible commands", 28 | SilenceUsage: true, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | context, err := contextProvider() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | var templ string 36 | if lsFlagTemplate != "" { 37 | templ = lsFlagTemplate 38 | } else { 39 | templ = lsDefaultTempl 40 | } 41 | err = ui.Template(cmd.OutOrStdout(), "ls", templ, templData{ 42 | Scripts: context.Scripts, 43 | Max: calculateRightPadForKeys(context.Scripts), 44 | }) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | }, 51 | } 52 | 53 | lsCmd.Flags(). 54 | StringVar(&lsFlagTemplate, "template", "", "Template string to use. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview].") 55 | 56 | return lsCmd 57 | } 58 | 59 | func calculateRightPadForKeys(m map[string]config.ShuttlePlanScript) int { 60 | max := 10 61 | for k := range m { 62 | if max < len(k) { 63 | max = len(k) 64 | } 65 | } 66 | return max + 2 67 | } 68 | -------------------------------------------------------------------------------- /pkg/copy/copy.go: -------------------------------------------------------------------------------- 1 | package copy 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | 10 | "golang.org/x/exp/slices" 11 | ) 12 | 13 | // File copies a single file from src to dst 14 | func File(src, dst string) error { 15 | var err error 16 | var srcfd *os.File 17 | var dstfd *os.File 18 | var srcinfo os.FileInfo 19 | 20 | srcfd, err = os.Open(src) 21 | if err != nil { 22 | return err 23 | } 24 | defer srcfd.Close() 25 | 26 | dstfd, err = os.Create(dst) 27 | if err != nil { 28 | return err 29 | } 30 | defer dstfd.Close() 31 | 32 | _, err = io.Copy(dstfd, srcfd) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | srcinfo, err = os.Stat(src) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return os.Chmod(dst, srcinfo.Mode()) 43 | } 44 | 45 | // Dir copies a whole directory recursively 46 | func Dir(src string, dst string, ignorelist []string) error { 47 | var err error 48 | var fds []os.FileInfo 49 | var srcinfo os.FileInfo 50 | 51 | srcinfo, err = os.Stat(src) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | err = os.MkdirAll(dst, srcinfo.Mode()) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | fds, err = ioutil.ReadDir(src) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | for _, fd := range fds { 67 | srcfp := path.Join(src, fd.Name()) 68 | dstfp := path.Join(dst, fd.Name()) 69 | 70 | shouldIgnore := slices.Contains(ignorelist, fd.Name()) 71 | if shouldIgnore { 72 | continue 73 | } 74 | 75 | if fd.IsDir() { 76 | if err = Dir(srcfp, dstfp, ignorelist); err != nil { 77 | fmt.Println(err) 78 | } 79 | } else { 80 | if err = File(srcfp, dstfp); err != nil { 81 | fmt.Println(err) 82 | } 83 | } 84 | } 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/git/parse_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParsePlan(t *testing.T) { 11 | tt := []struct { 12 | input string 13 | output Plan 14 | }{ 15 | { 16 | input: "git://git@github.com:lunarway/some-plan.git#some-branch", 17 | output: Plan{ 18 | IsGitPlan: true, 19 | Protocol: "ssh", 20 | User: "git", 21 | Host: "github.com", 22 | Repository: "github.com:lunarway/some-plan.git", 23 | Head: "some-branch", 24 | }, 25 | }, 26 | { 27 | input: "git://git@github.com:lunarway/some-plan.git", 28 | output: Plan{ 29 | IsGitPlan: true, 30 | Protocol: "ssh", 31 | User: "git", 32 | Host: "github.com", 33 | Repository: "github.com:lunarway/some-plan.git", 34 | Head: "master", 35 | }, 36 | }, 37 | { 38 | input: "https://github.com/lunarway/some-plan.git#some-branch", 39 | output: Plan{ 40 | IsGitPlan: true, 41 | Protocol: "https", 42 | User: "", 43 | Host: "", 44 | Repository: "github.com/lunarway/some-plan.git", 45 | Head: "some-branch", 46 | }, 47 | }, 48 | { 49 | input: "https://github.com/lunarway/some-plan.git", 50 | output: Plan{ 51 | IsGitPlan: true, 52 | Protocol: "https", 53 | User: "", 54 | Host: "", 55 | Repository: "github.com/lunarway/some-plan.git", 56 | Head: "master", 57 | }, 58 | }, 59 | } 60 | for _, tc := range tt { 61 | t.Run(fmt.Sprintf("can parse %s", tc.input), func(t *testing.T) { 62 | output := ParsePlan(tc.input) 63 | 64 | assert.Equal(t, tc.output, output, "output does not match the expected") 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/config_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestConfig(t *testing.T) { 11 | variables := map[string]string{ 12 | "VAR1": "TEST1", 13 | "VAR2": "TEST2", 14 | "VAR3": "TEST3", 15 | } 16 | 17 | for n, v := range variables { 18 | t.Setenv(n, v) 19 | } 20 | 21 | testCases := []testCase{ 22 | { 23 | name: "No exclude should display VAR1, VAR2 and VAR3 for Environment", 24 | input: args("config"), 25 | stdoutput: "Version \nPlan:\nfalse\nEnvironment:\nVAR1=TEST1\nVAR2=TEST2\nVAR3=TEST3\n", 26 | erroutput: "", 27 | err: nil, 28 | }, 29 | { 30 | name: "with exlcude VAR2 and VAR3 should only display VAR1 for Environment", 31 | input: args("config", "--exclude-env-vars", "VAR2,VAR3"), 32 | stdoutput: "Version \nPlan:\nfalse\nEnvironment:\nVAR1=TEST1\n", 33 | erroutput: "", 34 | err: nil, 35 | }, 36 | { 37 | name: "git plan", 38 | input: args("-p", "testdata/project-git", "config"), 39 | stdoutput: "Version \nPlan:\nhttps://github.com/lunarway/shuttle-example-go-plan.git master\nEnvironment:\nVAR1=TEST1\nVAR2=TEST2\nVAR3=TEST3\n", 40 | erroutput: "Cloning plan https://github.com/lunarway/shuttle-example-go-plan.git\n", 41 | err: nil, 42 | }, 43 | } 44 | 45 | executeTestCasesWithCustomAssertion( 46 | t, 47 | testCases, 48 | func(t *testing.T, tc testCase, stdout, stderr string) { 49 | t.Helper() 50 | 51 | for _, outputLine := range strings.Split(tc.stdoutput, "\n") { 52 | assert.Contains(t, stdout, outputLine, "one std output not as expected") 53 | } 54 | 55 | assert.Equal(t, tc.erroutput, stderr, "err output not as expected") 56 | }, 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/sdk/context.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "path" 7 | 8 | "github.com/lunarway/shuttle/pkg/config" 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | type ShuttleContext struct { 13 | Variables config.DynamicYaml `yaml:"vars"` // temporarily include a dynamic representation of the variables here so the go-based plans can use this for templating so we're backwards compatible with the existing templates (for the time being) 14 | ProjectPath string `yaml:"-"` 15 | LocalPlanPath string `yaml:"-"` 16 | LocalShuttleDirectoryPath string `yaml:"-"` 17 | TempDirectoryPath string `yaml:"-"` 18 | } 19 | 20 | func LoadShuttleContext(projectPath, localPlanPath string) (ShuttleContext, error) { 21 | yamlFile, err := LoadShuttleYaml(projectPath) 22 | if err != nil { 23 | return ShuttleContext{}, err 24 | } 25 | var result ShuttleContext 26 | err = yaml.Unmarshal(yamlFile, &result) 27 | if err != nil { 28 | return ShuttleContext{}, fmt.Errorf( 29 | "Failed to parse shuttle configuration. \n\nMake sure your 'shuttle.yaml' is valid. %w", 30 | err, 31 | ) 32 | } 33 | 34 | result.ProjectPath = projectPath 35 | result.LocalShuttleDirectoryPath = path.Join(result.ProjectPath, ".shuttle") 36 | result.TempDirectoryPath = path.Join(result.LocalShuttleDirectoryPath, "temp") 37 | result.LocalPlanPath = localPlanPath 38 | 39 | return result, nil 40 | } 41 | 42 | func LoadShuttleYaml(projectPath string) ([]byte, error) { 43 | file, err := ioutil.ReadFile(path.Join(projectPath, "shuttle.yaml")) 44 | if err != nil { 45 | return nil, fmt.Errorf( 46 | "Failed to load shuttle configuration. \n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available. %w", 47 | err, 48 | ) 49 | } 50 | return file, nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/discover_test.go: -------------------------------------------------------------------------------- 1 | package discover_test 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "testing" 7 | 8 | "github.com/lunarway/shuttle/pkg/config" 9 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestDiscover(t *testing.T) { 14 | discovered, err := discover.Discover( 15 | context.Background(), 16 | "testdata/simple/shuttle.yaml", 17 | &config.ShuttleProjectContext{}, 18 | ) 19 | assert.NoError(t, err) 20 | 21 | assert.Equal(t, discover.Discovered{ 22 | Local: &discover.ActionsDiscovered{ 23 | Files: []string{ 24 | "build.go", 25 | "download.go", 26 | }, 27 | DirPath: "testdata/simple/actions", 28 | ParentDir: "testdata/simple", 29 | }, 30 | }, *discovered) 31 | } 32 | 33 | func TestDiscoverComplex(t *testing.T) { 34 | shuttleCmd := exec.Command("shuttle", "ls", "--verbose") 35 | shuttleCmd.Dir = "testdata/child/" 36 | 37 | output, err := shuttleCmd.CombinedOutput() 38 | if err != nil { 39 | t.Fatalf("shuttle ls: %s", string(output)) 40 | } 41 | 42 | discovered, err := discover.Discover( 43 | context.Background(), 44 | "testdata/child/shuttle.yaml", 45 | &config.ShuttleProjectContext{ 46 | Config: config.ShuttleConfig{ 47 | Plan: ".shuttle/plan", 48 | }, 49 | }, 50 | ) 51 | assert.NoError(t, err) 52 | 53 | assert.Equal(t, discover.Discovered{ 54 | Local: &discover.ActionsDiscovered{ 55 | Files: []string{ 56 | "build.go", 57 | "download.go", 58 | }, 59 | DirPath: "testdata/child/actions", 60 | ParentDir: "testdata/child", 61 | }, 62 | Plan: &discover.ActionsDiscovered{ 63 | Files: []string{ 64 | "build.go", 65 | "download.go", 66 | }, 67 | DirPath: "testdata/child/.shuttle/plan/actions", 68 | ParentDir: "testdata/child/.shuttle/plan", 69 | }, 70 | }, *discovered) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/sdk/template_test.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRenderTemplate(t *testing.T) { 12 | tt := []struct { 13 | name string 14 | templatePath string 15 | output string 16 | templateCtx TemplateContext 17 | err error 18 | }{ 19 | { 20 | name: "valid", 21 | templatePath: "testdata/valid.yaml", 22 | templateCtx: TemplateContext{ 23 | Vars: map[string]string{ 24 | "bar": "bar", 25 | }, 26 | }, 27 | output: `foo: bar 28 | `, 29 | err: nil, 30 | }, 31 | { 32 | name: "read content of unknown file", 33 | templatePath: "testdata/get_file_content.yaml", 34 | templateCtx: TemplateContext{ 35 | Vars: map[string]string{ 36 | "path": "unknown.yaml", 37 | }, 38 | }, 39 | output: ``, 40 | err: errors.New( 41 | "template: get_file_content.yaml:2:3: executing \"get_file_content.yaml\" at : error calling getFileContent: open unknown.yaml: no such file or directory", 42 | ), 43 | }, 44 | { 45 | name: "read content of file", 46 | templatePath: "testdata/get_file_content.yaml", 47 | templateCtx: TemplateContext{ 48 | Vars: map[string]string{ 49 | "path": "testdata/valid.yaml", 50 | }, 51 | }, 52 | output: `bar: baz 53 | foo: {{ get "bar" .Vars }} 54 | 55 | `, 56 | err: nil, 57 | }, 58 | } 59 | for _, tc := range tt { 60 | t.Run(tc.name, func(t *testing.T) { 61 | var output bytes.Buffer 62 | err := renderTemplate(tc.templatePath, tc.name, &output, tc.templateCtx, "{{", "}}") 63 | 64 | if tc.err != nil { 65 | assert.EqualError(t, err, tc.err.Error()) 66 | return 67 | } 68 | 69 | assert.NoError(t, err) 70 | assert.Equal(t, tc.output, output.String()) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/browser/browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/cli/safeexec" 11 | "github.com/google/shlex" 12 | ) 13 | 14 | // This package is copied from github.com/cli/cli 15 | 16 | // Command returns an exec.Cmd instance respecting runtime.GOOS and $BROWSER 17 | // environment variable. 18 | func Command(url string, errOutput io.Writer) (*exec.Cmd, error) { 19 | launcher := os.Getenv("BROWSER") 20 | if launcher != "" { 21 | return fromBrowserEnv(launcher, url, errOutput) 22 | } 23 | return forOS(runtime.GOOS, url, errOutput), nil 24 | } 25 | 26 | func forOS(goos, url string, errOutput io.Writer) *exec.Cmd { 27 | exe := "open" 28 | var args []string 29 | switch goos { 30 | case "darwin": 31 | args = append(args, url) 32 | case "windows": 33 | exe, _ = lookPath("cmd") 34 | r := strings.NewReplacer("&", "^&") 35 | args = append(args, "/c", "start", r.Replace(url)) 36 | default: 37 | exe = linuxExe() 38 | args = append(args, url) 39 | } 40 | 41 | cmd := exec.Command(exe, args...) 42 | cmd.Stderr = errOutput 43 | return cmd 44 | } 45 | 46 | // fromBrowserEnv parses the BROWSER string based on shell splitting rules. 47 | func fromBrowserEnv(launcher, url string, errOutput io.Writer) (*exec.Cmd, error) { 48 | args, err := shlex.Split(launcher) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | exe, err := lookPath(args[0]) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | args = append(args, url) 59 | cmd := exec.Command(exe, args[1:]...) 60 | cmd.Stderr = errOutput 61 | return cmd, nil 62 | } 63 | 64 | func linuxExe() string { 65 | exe := "xdg-open" 66 | 67 | _, err := lookPath(exe) 68 | if err != nil { 69 | _, err := lookPath("wslview") 70 | if err == nil { 71 | exe = "wslview" 72 | } 73 | } 74 | 75 | return exe 76 | } 77 | 78 | var lookPath = safeexec.LookPath 79 | -------------------------------------------------------------------------------- /pkg/browser/browser_test.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | // This package is copied from github.com/cli/cli 11 | 12 | func TestForOS(t *testing.T) { 13 | type args struct { 14 | goos string 15 | url string 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | exe string 21 | want []string 22 | }{ 23 | { 24 | name: "macOS", 25 | args: args{ 26 | goos: "darwin", 27 | url: "https://example.com/path?a=1&b=2", 28 | }, 29 | want: []string{"open", "https://example.com/path?a=1&b=2"}, 30 | }, 31 | { 32 | name: "Linux", 33 | args: args{ 34 | goos: "linux", 35 | url: "https://example.com/path?a=1&b=2", 36 | }, 37 | exe: "xdg-open", 38 | want: []string{"xdg-open", "https://example.com/path?a=1&b=2"}, 39 | }, 40 | { 41 | name: "WSL", 42 | args: args{ 43 | goos: "linux", 44 | url: "https://example.com/path?a=1&b=2", 45 | }, 46 | exe: "wslview", 47 | want: []string{"wslview", "https://example.com/path?a=1&b=2"}, 48 | }, 49 | { 50 | name: "Windows", 51 | args: args{ 52 | goos: "windows", 53 | url: "https://example.com/path?a=1&b=2&c=3", 54 | }, 55 | exe: "cmd", 56 | want: []string{"cmd", "/c", "start", "https://example.com/path?a=1^&b=2^&c=3"}, 57 | }, 58 | } 59 | for _, tt := range tests { 60 | origLookPath := lookPath 61 | lookPath = func(file string) (string, error) { 62 | if file == tt.exe { 63 | return file, nil 64 | } else { 65 | return "", errors.New("not found") 66 | } 67 | } 68 | defer func() { 69 | lookPath = origLookPath 70 | }() 71 | 72 | t.Run(tt.name, func(t *testing.T) { 73 | cmd := forOS(tt.args.goos, tt.args.url, io.Discard) 74 | if !reflect.DeepEqual(cmd.Args, tt.want) { 75 | t.Errorf("ForOS() = %v, want %v", cmd.Args, tt.want) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/telemetry/setup.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/user" 9 | "path" 10 | "strings" 11 | ) 12 | 13 | const appKey = "shuttle" 14 | 15 | type TelemetryClient interface { 16 | Trace(ctx context.Context, properties map[string]string) 17 | } 18 | 19 | type TelemetryOption func(properties map[string]string) 20 | 21 | var ( 22 | noopClient NoopTelemetryClient = NoopTelemetryClient{} 23 | client TelemetryClient = &noopClient 24 | ) 25 | 26 | // Initializes the telemetry setup, if not called, NoopTelemetryClient will be used 27 | func Setup() { 28 | if remoteTracing := os.Getenv("SHUTTLE_REMOTE_TRACING"); remoteTracing != "" { 29 | properties := make(map[string]string, 0) 30 | sysinfo := WithGoInfo() 31 | sysinfo(properties) 32 | 33 | logLocation := getRemoteLogLocation() 34 | if logLocation != "" { 35 | if err := os.MkdirAll(logLocation, 0o755); err != nil { 36 | log.Fatal(err) 37 | } 38 | } 39 | 40 | client = &JsonLinesTelemetryClient{ 41 | labelPrefix: appKey, 42 | properties: properties, 43 | logLocation: logLocation, 44 | Client: http.DefaultClient, 45 | } 46 | 47 | return 48 | } 49 | 50 | if logging_telemetry := os.Getenv("SHUTTLE_LOG_TRACING"); strings.ToLower( 51 | logging_telemetry, 52 | ) == "true" { 53 | properties := make(map[string]string, 0) 54 | sysinfo := WithGoInfo() 55 | sysinfo(properties) 56 | client = &LoggingTelemetryClient{ 57 | labelPrefix: appKey, 58 | properties: properties, 59 | } 60 | 61 | return 62 | } 63 | } 64 | 65 | func getRemoteLogLocation() string { 66 | logLocation := os.Getenv("SHUTTLE_REMOTE_LOG_LOCATION") 67 | if logLocation == "default" || logLocation == "" { 68 | usr, _ := user.Current() 69 | homeDir := usr.HomeDir 70 | logLocation = path.Join( 71 | homeDir, 72 | ".local", 73 | "share", 74 | "shuttle", 75 | "telemetry", 76 | ) 77 | } 78 | 79 | return logLocation 80 | } 81 | -------------------------------------------------------------------------------- /pkg/executors/golang/cmder/cmder_test.go: -------------------------------------------------------------------------------- 1 | package cmder_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/lunarway/shuttle/pkg/executors/golang/cmder" 11 | ) 12 | 13 | func TestCmderWithError(t *testing.T) { 14 | testFunc := cmder.NewCmd("test", func(ctx context.Context) error { 15 | return errors.New("some-error") 16 | }) 17 | 18 | args := []string{ 19 | "test", 20 | } 21 | 22 | err := cmder.NewRoot().AddCmds(testFunc).TryExecute(args) 23 | 24 | assert.ErrorIs(t, err, cmder.ErrNoHelp) 25 | } 26 | 27 | func TestCmderWithNoError(t *testing.T) { 28 | testFunc := cmder.NewCmd("test", func(ctx context.Context) error { 29 | return nil 30 | }) 31 | 32 | args := []string{ 33 | "test", 34 | } 35 | 36 | err := cmder.NewRoot().AddCmds(testFunc).TryExecute(args) 37 | 38 | assert.NoError(t, err) 39 | } 40 | 41 | func TestCmderWithMultipeReturns(t *testing.T) { 42 | testFunc := cmder.NewCmd("test", func(ctx context.Context) (string, error) { 43 | return "something", nil 44 | }) 45 | 46 | args := []string{ 47 | "test", 48 | } 49 | 50 | err := cmder.NewRoot().AddCmds(testFunc).TryExecute(args) 51 | 52 | assert.NoError(t, err) 53 | } 54 | 55 | func TestCmderWithMultipeReturnsErroring(t *testing.T) { 56 | testFunc := cmder.NewCmd("test", func(ctx context.Context) (string, error) { 57 | return "something", errors.New("some-error") 58 | }) 59 | 60 | args := []string{ 61 | "test", 62 | } 63 | 64 | err := cmder.NewRoot().AddCmds(testFunc).TryExecute(args) 65 | 66 | assert.ErrorIs(t, err, cmder.ErrNoHelp) 67 | } 68 | 69 | func TestCmderWithMultipeReturnsErroringInAnyPlace(t *testing.T) { 70 | testFunc := cmder.NewCmd("test", func(ctx context.Context) (error, string) { 71 | return errors.New("some-error"), "something" 72 | }) 73 | 74 | args := []string{ 75 | "test", 76 | } 77 | 78 | err := cmder.NewRoot().AddCmds(testFunc).TryExecute(args) 79 | 80 | assert.ErrorIs(t, err, cmder.ErrNoHelp) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/config/shuttleplan_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestShuttlePlanConfiguration_Load(t *testing.T) { 11 | tt := []struct { 12 | name string 13 | input string 14 | err error 15 | config ShuttlePlanConfiguration 16 | }{ 17 | { 18 | name: "empty path", 19 | input: "", 20 | }, 21 | { 22 | name: "unknown field", 23 | input: "testdata/unknown_field", 24 | err: errors.New( 25 | "exit code 1 - Failed to load plan configuration from 'testdata/unknown_field/plan.yaml': yaml: unmarshal errors:\n line 1: field unknown not found in type config.ShuttlePlanConfiguration\n\nThis is likely an issue with the referenced plan. Please, contact the plan maintainers.", 26 | ), 27 | }, 28 | { 29 | name: "unknown file", 30 | input: "testdata/unknown_file", 31 | err: errors.New( 32 | "exit code 2 - Failed to open plan configuration: open testdata/unknown_file/plan.yaml: no such file or directory\n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available.", 33 | ), 34 | }, 35 | { 36 | name: "valid", 37 | input: "testdata/valid", 38 | err: nil, 39 | config: ShuttlePlanConfiguration{ 40 | Vars: map[string]interface{}{ 41 | "shared": "var", 42 | }, 43 | Scripts: map[string]ShuttlePlanScript{ 44 | "hello": { 45 | Description: "Say hello", 46 | Actions: []ShuttleAction{ 47 | { 48 | Shell: `echo "Hello world"`, 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | } 56 | for _, tc := range tt { 57 | t.Run(tc.name, func(t *testing.T) { 58 | c := &ShuttlePlanConfiguration{} 59 | var err error 60 | c, err = c.Load(tc.input) 61 | 62 | if tc.err != nil { 63 | assert.EqualError(t, err, tc.err.Error(), "error not as expected") 64 | } else { 65 | assert.NoError(t, err, "unexpected error") 66 | } 67 | assert.Equal(t, tc.config, *c, "config not as expected") 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/telemetry_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/lunarway/shuttle/pkg/telemetry" 10 | "github.com/lunarway/shuttle/pkg/ui" 11 | ) 12 | 13 | func newTelemetry(uii *ui.UI) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "telemetry", 16 | Short: "Shuttle telemetry", 17 | } 18 | 19 | cmd.AddCommand(newTelemetryUploadCmd(uii)) 20 | 21 | return cmd 22 | } 23 | 24 | func newTelemetryUploadCmd(uii *ui.UI) *cobra.Command { 25 | var ( 26 | availabilityUrl string 27 | cleanUp bool 28 | uploadUrl string 29 | ) 30 | 31 | cmd := &cobra.Command{ 32 | Use: "upload", 33 | Short: "Upload shuttle telemetry", 34 | Run: func(cmd *cobra.Command, args []string) { 35 | uii.SetContext(ui.LevelSilent) 36 | 37 | url := os.Getenv("SHUTTLE_REMOTE_TRACING_URL") 38 | if url == "" && uploadUrl == "" { 39 | log.Fatalln("SHUTTLE_REMOTE_TRACING_URL or upload-url is not set") 40 | } 41 | 42 | if uploadUrl != "" { 43 | url = uploadUrl 44 | } 45 | 46 | options := make([]telemetry.UploadOptions, 0) 47 | if availabilityUrl != "" { 48 | options = append(options, telemetry.WithAvailabilityCheck(availabilityUrl)) 49 | } 50 | 51 | options = append(options, telemetry.WithCleanUp(cleanUp)) 52 | 53 | uploader := telemetry.NewTelemetryUploader(url, options...) 54 | 55 | if err := uploader.Upload(cmd.Context()); err != nil { 56 | log.Fatalf("failed to upload traces: %s", err) 57 | } 58 | }, 59 | } 60 | 61 | cmd.PersistentFlags(). 62 | StringVar(&uploadUrl, "upload-url", "", "upload url is the url to which all the trace events will be uploaded to") 63 | cmd.PersistentFlags(). 64 | StringVar(&availabilityUrl, "availability-url", "", "availability url is an address that needs to return a 200 http OK before continuing to upload, if anything else is returned, this command exits early") 65 | cmd.PersistentFlags(). 66 | BoolVar(&cleanUp, "clean-up", true, "removes shuttle-telemetry files after upload") 67 | 68 | return cmd 69 | } 70 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch_gomodule.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | type goModuleFinder struct{} 13 | 14 | func newGoModuleFinder() *goModuleFinder { 15 | return &goModuleFinder{} 16 | } 17 | 18 | func (s *goModuleFinder) Find(ctx context.Context, rootDir string) (packages map[string]string, ok bool, err error) { 19 | contents, ok, err := s.getGoModFile(rootDir) 20 | if err != nil { 21 | return nil, true, err 22 | } 23 | if !ok { 24 | return nil, false, nil 25 | } 26 | 27 | moduleName, err := s.getModuleFromModFile(contents) 28 | if err != nil { 29 | return nil, true, fmt.Errorf("failed to parse go.mod in root of project: %w", err) 30 | } 31 | 32 | packages = make(map[string]string, 0) 33 | packages[moduleName] = "" 34 | 35 | return packages, true, nil 36 | } 37 | 38 | func (g *goModuleFinder) getGoModFile(rootDir string) (contents []string, ok bool, err error) { 39 | goMod := path.Join(rootDir, "go.mod") 40 | if _, err := os.Stat(goMod); err != nil { 41 | if errors.Is(err, os.ErrNotExist) { 42 | return nil, false, nil 43 | } 44 | 45 | return nil, true, err 46 | } 47 | 48 | modFile, err := os.ReadFile(path.Join(rootDir, "go.mod")) 49 | if err != nil { 50 | return nil, true, err 51 | } 52 | 53 | lines := strings.Split(string(modFile), "\n") 54 | 55 | if len(lines) == 0 { 56 | return nil, true, errors.New("go mod is empty") 57 | } 58 | 59 | return lines, true, nil 60 | } 61 | 62 | func (g *goModuleFinder) getModuleFromModFile(contents []string) (moduleName string, err error) { 63 | for _, line := range contents { 64 | modFileLine := strings.TrimSpace(line) 65 | if strings.HasPrefix(modFileLine, "module") { 66 | sections := strings.Split(modFileLine, " ") 67 | if len(sections) < 2 { 68 | return "", fmt.Errorf("invalid module line: %s", modFileLine) 69 | } 70 | 71 | moduleName := sections[1] 72 | 73 | return moduleName, nil 74 | } 75 | } 76 | 77 | return "", errors.New("failed to find a valid go.mod file") 78 | } 79 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/lunarway/shuttle/pkg/git" 9 | "github.com/lunarway/shuttle/pkg/ui" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func newConfig(uii *ui.UI, contextProvder contextProvider) *cobra.Command { 14 | var envVarsToExclude []string 15 | 16 | envCmd := &cobra.Command{ 17 | Use: "config", 18 | Short: "Display shuttle context information", 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | uii.SetContext(ui.LevelSilent) 21 | environmentVariables := os.Environ() 22 | shouldExclude := make(map[string]bool) 23 | 24 | for _, envVarToExclude := range envVarsToExclude { 25 | shouldExclude[envVarToExclude] = true 26 | } 27 | 28 | context, err := contextProvder() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | fmt.Fprintf(cmd.OutOrStdout(), "Version %v\n", version) 34 | breakLine(cmd) 35 | 36 | parsedPlan := git.ParsePlan(context.Config.Plan) 37 | if parsedPlan.IsGitPlan { 38 | fmt.Fprintf(cmd.OutOrStdout(), "Plan:\n%v %v", context.Config.Plan, parsedPlan.Head) 39 | } else { 40 | plan := context.Config.Plan 41 | if plan == "" { 42 | plan = "false" 43 | } 44 | fmt.Fprintf(cmd.OutOrStdout(), "Plan:\n%v", plan) 45 | } 46 | breakLine(cmd) 47 | breakLine(cmd) 48 | 49 | fmt.Fprintf(cmd.OutOrStdout(), "Environment:\n") 50 | for _, envVar := range environmentVariables { 51 | if shouldExclude[extractEnvirontmentVariableName(envVar)] { 52 | continue 53 | } 54 | 55 | fmt.Fprintf(cmd.OutOrStdout(), "%v\n", envVar) 56 | } 57 | 58 | return nil 59 | }, 60 | } 61 | 62 | envCmd.Flags(). 63 | StringSliceVar(&envVarsToExclude, "exclude-env-vars", make([]string, 0), "Exclude environment variables from being displayed. Example: shuttle config --exclude-env-vars VAR1,VAR2,VAR3") 64 | return envCmd 65 | } 66 | 67 | func breakLine(cmd *cobra.Command) { 68 | fmt.Fprintln(cmd.OutOrStdout(), "") 69 | } 70 | 71 | func extractEnvirontmentVariableName(s string) string { 72 | return strings.Split(s, "=")[0] 73 | } 74 | -------------------------------------------------------------------------------- /pkg/executors/golang/compile/matcher/matcher.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path" 12 | 13 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 14 | "github.com/lunarway/shuttle/pkg/ui" 15 | "golang.org/x/exp/slices" 16 | "golang.org/x/mod/sumdb/dirhash" 17 | ) 18 | 19 | func BinaryMatches( 20 | ctx context.Context, 21 | ui *ui.UI, 22 | hash string, 23 | actions *discover.ActionsDiscovered, 24 | ) (string, bool, error) { 25 | shuttlebindir := path.Join(actions.ParentDir, ".shuttle/actions/binaries") 26 | 27 | if _, err := os.Stat(shuttlebindir); errors.Is(err, os.ErrNotExist) { 28 | ui.Verboseln("package doesn't exist continueing") 29 | return "", false, nil 30 | } 31 | 32 | entries, err := os.ReadDir(shuttlebindir) 33 | if err != nil { 34 | return "", false, err 35 | } 36 | 37 | if len(entries) == 0 { 38 | return "", false, err 39 | } 40 | 41 | // We only expect a single binary in the folder, so we just take the first entry if it exists 42 | binary := entries[0] 43 | 44 | expectedPath := fmt.Sprintf("actions-%s", hex.EncodeToString([]byte(hash)[:16])) 45 | actualName := binary.Name() 46 | if actualName == expectedPath { 47 | return path.Join(shuttlebindir, binary.Name()), true, nil 48 | } else { 49 | ui.Verboseln("binary does not match, rebuilding... (actual=%s, expected=%s)", actualName, expectedPath) 50 | return "", false, nil 51 | } 52 | } 53 | 54 | func GetHash(ctx context.Context, actions *discover.ActionsDiscovered) (string, error) { 55 | entries := make([]string, len(actions.Files)) 56 | 57 | for i, task := range actions.Files { 58 | entries[i] = path.Join(actions.DirPath, task) 59 | } 60 | 61 | open := func(name string) (io.ReadCloser, error) { 62 | b, err := os.ReadFile(name) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return io.NopCloser(bytes.NewReader(b)), nil 68 | } 69 | 70 | slices.Sort(entries) 71 | hash, err := dirhash.Hash1(entries, open) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | return hash, nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/executors/help.go: -------------------------------------------------------------------------------- 1 | package executors 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | 7 | "github.com/lunarway/shuttle/pkg/config" 8 | "github.com/lunarway/shuttle/pkg/ui" 9 | ) 10 | 11 | const scriptHelpTemplate = ` 12 | {{- $max := .Max -}} 13 | {{ .Description -}} 14 | {{ if not .Description -}} No description available {{- end }} 15 | {{ if .Args }} 16 | Available arguments: 17 | {{- range $i, $arg := .Args}} 18 | {{ rightPad (print $arg.Name " " $arg.Required) $max -}} {{- $arg.Description }} 19 | {{- end}} 20 | {{- end}} 21 | ` 22 | 23 | type scriptHelpTemplateData struct { 24 | Description string 25 | Args []scriptHelpTemplateArg 26 | Max int 27 | } 28 | 29 | type scriptHelpTemplateArg struct { 30 | Name string 31 | Required string 32 | Description string 33 | } 34 | 35 | func Help( 36 | scripts map[string]config.ShuttlePlanScript, 37 | script string, 38 | output io.Writer, 39 | template string, 40 | ) error { 41 | s, ok := scripts[script] 42 | if !ok { 43 | return errors.New("unrecognized script") 44 | } 45 | if template == "" { 46 | template = scriptHelpTemplate 47 | } 48 | err := ui.Template(output, "runHelp", template, scriptHelpTemplateData{ 49 | Description: s.Description, 50 | Args: templateArgs(s.Args), 51 | Max: maxLength(s.Args), 52 | }) 53 | if err != nil { 54 | return err 55 | } 56 | return nil 57 | } 58 | 59 | func maxLength(values []config.ShuttleScriptArgs) int { 60 | max := 10 61 | for _, value := range values { 62 | if max < len(value.Name) { 63 | max = len(value.Name) 64 | } 65 | if value.Required { 66 | max += len(required(true)) 67 | } 68 | } 69 | return max + 2 70 | } 71 | 72 | func templateArgs(values []config.ShuttleScriptArgs) []scriptHelpTemplateArg { 73 | scriptArgs := make([]scriptHelpTemplateArg, len(values)) 74 | for i := range values { 75 | scriptArgs[i] = scriptHelpTemplateArg{ 76 | Name: values[i].Name, 77 | Required: required(values[i].Required), 78 | Description: values[i].Description, 79 | } 80 | } 81 | return scriptArgs 82 | } 83 | 84 | func required(b bool) string { 85 | if b { 86 | return "(required)" 87 | } 88 | return "" 89 | } 90 | -------------------------------------------------------------------------------- /pkg/telemetry/jsonlines.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type JsonLinesTelemetryClient struct { 16 | url string 17 | labelPrefix string 18 | properties map[string]string 19 | *http.Client 20 | logLocation string 21 | writeMutex sync.Mutex 22 | } 23 | 24 | func (t *JsonLinesTelemetryClient) Trace( 25 | ctx context.Context, 26 | properties map[string]string, 27 | ) { 28 | copyHostMap(t.properties, properties) 29 | 30 | event := &UploadTraceEvent{ 31 | App: appKey, 32 | Timestamp: time.Now().UTC(), 33 | Properties: includeContext(ctx, properties), 34 | } 35 | 36 | content, err := json.Marshal(event) 37 | if err != nil { 38 | log.Printf("failed to marshal trace event: %s", err) 39 | return 40 | } 41 | 42 | if err = t.writeLogLine(ctx, content); err != nil { 43 | log.Printf("failed to write to file: %s", err) 44 | return 45 | } 46 | } 47 | 48 | var _ TelemetryClient = &JsonLinesTelemetryClient{} 49 | 50 | type UploadTraceEvent struct { 51 | App string `json:"app"` 52 | Timestamp time.Time `json:"timestamp"` 53 | Properties map[string]string `json:"properties"` 54 | } 55 | 56 | // filename 57 | const fileNameShuttleJsonLines = "shuttle-telemetry" 58 | 59 | // extensions 60 | const extensionShuttleJsonLines = ".jsonl" 61 | 62 | func (t *JsonLinesTelemetryClient) writeLogLine(ctx context.Context, content []byte) error { 63 | // Lock the mutex so multiple writers don't write at the same time 64 | t.writeMutex.Lock() 65 | defer t.writeMutex.Unlock() 66 | 67 | runID := RunIDFrom(ctx) 68 | 69 | file, err := os.OpenFile( 70 | path.Join( 71 | t.logLocation, 72 | fmt.Sprintf("%s-%s%s", fileNameShuttleJsonLines, runID, extensionShuttleJsonLines), 73 | ), 74 | os.O_APPEND|os.O_WRONLY|os.O_CREATE, 75 | 0o644, 76 | ) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | _, err = file.Write(content) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | _, err = file.Write([]byte("\n")) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/executors/task.go: -------------------------------------------------------------------------------- 1 | package executors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/go-cmd/cmd" 10 | "github.com/lunarway/shuttle/pkg/config" 11 | "github.com/lunarway/shuttle/pkg/executors/golang/executer" 12 | "github.com/lunarway/shuttle/pkg/ui" 13 | ) 14 | 15 | func TaskExecutor(action config.ShuttleAction) (Executor, bool) { 16 | return executeTask, action.Task != "" 17 | } 18 | 19 | func executeTask(ctx context.Context, ui *ui.UI, context ActionExecutionContext) error { 20 | context.ScriptContext.Project.UI.Verboseln("Starting task command: %s", context.Action.Task) 21 | 22 | args := make([]string, 0) 23 | args = append(args, context.Action.Task) 24 | for name, value := range context.ScriptContext.Args { 25 | args = append(args, fmt.Sprintf("--%s", name)) 26 | args = append(args, value) 27 | } 28 | 29 | err := executer.Run(ctx, ui, &context.ScriptContext.Project, fmt.Sprintf("%s/shuttle.yaml", context.ScriptContext.Project.ProjectPath), args...) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func setupTaskCommandEnvironmentVariables(execCmd *cmd.Cmd, context ActionExecutionContext) { 38 | shuttlePath, _ := filepath.Abs(filepath.Dir(os.Args[0])) 39 | 40 | execCmd.Env = os.Environ() 41 | for name, value := range context.ScriptContext.Args { 42 | execCmd.Env = append(execCmd.Env, fmt.Sprintf("%s=%s", name, value)) 43 | } 44 | execCmd.Env = append( 45 | execCmd.Env, 46 | fmt.Sprintf("plan=%s", context.ScriptContext.Project.LocalPlanPath), 47 | ) 48 | execCmd.Env = append( 49 | execCmd.Env, 50 | fmt.Sprintf("tmp=%s", context.ScriptContext.Project.TempDirectoryPath), 51 | ) 52 | execCmd.Env = append( 53 | execCmd.Env, 54 | fmt.Sprintf("project=%s", context.ScriptContext.Project.ProjectPath), 55 | ) 56 | // TODO: Add project path as a shuttle specific ENV 57 | execCmd.Env = append( 58 | execCmd.Env, 59 | fmt.Sprintf("PATH=%s", shuttlePath+string(os.PathListSeparator)+os.Getenv("PATH")), 60 | ) 61 | execCmd.Env = append( 62 | execCmd.Env, 63 | fmt.Sprintf( 64 | "SHUTTLE_PLANS_ALREADY_VALIDATED=%s", 65 | context.ScriptContext.Project.LocalPlanPath, 66 | ), 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/sdk/template.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "text/template" 9 | 10 | "github.com/lunarway/shuttle/pkg/templates" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type TemplateContext struct { 15 | Vars interface{} 16 | Args map[string]string 17 | PlanPath string 18 | ProjectPath string 19 | } 20 | 21 | func resolveFirstPath(paths []string) string { 22 | for _, templatePath := range paths { 23 | if fileAvailable(templatePath) { 24 | return templatePath 25 | } 26 | } 27 | return "" 28 | } 29 | 30 | func fileAvailable(name string) bool { 31 | if _, err := os.Stat(name); err != nil { 32 | if os.IsNotExist(err) { 33 | return false 34 | } 35 | } 36 | return true 37 | } 38 | 39 | func ResolveTemplatePath(project ShuttleContext, templateName string) (string, error) { 40 | templatePath := resolveFirstPath([]string{ 41 | path.Join(project.ProjectPath, "templates", templateName), 42 | path.Join(project.ProjectPath, templateName), 43 | path.Join(project.LocalPlanPath, "templates", templateName), 44 | path.Join(project.LocalPlanPath, templateName), 45 | }) 46 | if templatePath == "" { 47 | return "", fmt.Errorf("template `%s` not found", templateName) 48 | } 49 | return templatePath, nil 50 | } 51 | 52 | func Generate( 53 | templatePath, templateName, outputFilepath string, 54 | context TemplateContext, 55 | leftDelim, rightDelim string, 56 | ) error { 57 | file, err := os.Create(outputFilepath) 58 | if err != nil { 59 | return errors.WithMessagef(err, "create template output file '%s'", outputFilepath) 60 | } 61 | 62 | err = renderTemplate(templatePath, templateName, file, context, leftDelim, rightDelim) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func renderTemplate( 71 | templatePath, templateName string, 72 | output io.Writer, 73 | context TemplateContext, 74 | leftDelim, rightDelim string, 75 | ) error { 76 | tmpl, err := template.New(templateName). 77 | Delims(leftDelim, rightDelim). 78 | Funcs(templates.GetFuncMap()). 79 | ParseFiles(templatePath) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | err = tmpl.ExecuteTemplate(output, path.Base(templatePath), context) 85 | if err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | // UI is the abstraction of handling terminal output for shuttle 9 | type UI struct { 10 | EffectiveLevel Level 11 | DefaultLevel Level 12 | UserLevel Level 13 | UserLevelSet bool 14 | Out io.Writer 15 | Err io.Writer 16 | } 17 | 18 | // Create doc 19 | func Create(out, err io.Writer) *UI { 20 | return &UI{ 21 | EffectiveLevel: LevelInfo, 22 | DefaultLevel: LevelInfo, 23 | UserLevelSet: false, 24 | Out: out, 25 | Err: err, 26 | } 27 | } 28 | 29 | // SetUserLevel doc 30 | func (ui *UI) SetUserLevel(level Level) *UI { 31 | ui.EffectiveLevel = level 32 | ui.UserLevel = level 33 | ui.UserLevelSet = true 34 | return ui 35 | } 36 | 37 | // SetContext doc 38 | func (ui *UI) SetContext(level Level) *UI { 39 | if ui.UserLevelSet { 40 | ui.EffectiveLevel = ui.UserLevel 41 | } else { 42 | ui.EffectiveLevel = level 43 | } 44 | ui.DefaultLevel = level 45 | 46 | return ui 47 | } 48 | 49 | // Output. 50 | func (ui *UI) Output(format string, args ...interface{}) { 51 | fmt.Fprintln(ui.Out, fmt.Sprintf(format, args...)) 52 | } 53 | 54 | // Verboseln prints a formatted verbose message line. 55 | func (ui *UI) Verboseln(format string, args ...interface{}) { 56 | if ui.EffectiveLevel.OutputIsIncluded(LevelVerbose) { 57 | fmt.Fprintln(ui.Err, fmt.Sprintf(format, args...)) 58 | } 59 | } 60 | 61 | // Infoln prints a formatted info message line. 62 | func (ui *UI) Infoln(format string, args ...interface{}) { 63 | if ui.EffectiveLevel.OutputIsIncluded(LevelInfo) { 64 | fmt.Fprintln(ui.Err, fmt.Sprintf(format, args...)) 65 | } 66 | } 67 | 68 | func (ui *UI) EmphasizeInfoln(format string, args ...interface{}) { 69 | if ui.EffectiveLevel.OutputIsIncluded(LevelInfo) { 70 | fmt.Fprintf(ui.Err, "\x1b[032;1m%s\x1b[0m\n", fmt.Sprintf(format, args...)) 71 | } 72 | } 73 | 74 | // Titleln doc 75 | func (ui *UI) Titleln(format string, args ...interface{}) { 76 | ui.Infoln("\x1b[1m%s\x1b[0m", fmt.Sprintf(format, args...)) 77 | } 78 | 79 | // Errorln doc 80 | func (ui *UI) Errorln(format string, args ...interface{}) { 81 | if ui.EffectiveLevel.OutputIsIncluded(LevelError) { 82 | fmt.Fprintf(ui.Err, "\x1b[31;1m%s\x1b[0m\n", fmt.Sprintf(format, args...)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /pkg/executors/golang/parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "go/ast" 8 | "go/parser" 9 | "go/token" 10 | "path" 11 | "strings" 12 | 13 | "github.com/lunarway/shuttle/pkg/executors/golang/discover" 14 | ) 15 | 16 | type Function struct { 17 | Name string 18 | Input []Arg 19 | Output Output 20 | } 21 | 22 | type Arg struct { 23 | Name string 24 | } 25 | 26 | type Output struct { 27 | Error bool 28 | } 29 | 30 | func GenerateAst( 31 | ctx context.Context, 32 | shuttlelocaldir string, 33 | actions *discover.ActionsDiscovered, 34 | ) ([]*Function, error) { 35 | funcs := make([]*Function, 0) 36 | 37 | for _, taskfile := range actions.Files { 38 | tknSet := token.NewFileSet() 39 | astfile, err := parser.ParseFile( 40 | tknSet, 41 | path.Join(shuttlelocaldir, "tmp", taskfile), 42 | nil, 43 | parser.ParseComments, 44 | ) 45 | if err != nil { 46 | return nil, err 47 | } 48 | if ast.FileExports(astfile) { 49 | decls := astfile.Decls 50 | for _, decl := range decls { 51 | funcdecl, ok := decl.(*ast.FuncDecl) 52 | if ok { 53 | f := Function{} 54 | f.Name = funcdecl.Name.Name 55 | param := funcdecl.Type 56 | paramList := param.Params.List 57 | for _, param := range paramList { 58 | for _, name := range param.Names { 59 | if name != nil && 60 | !strings.Contains(fmt.Sprintf("%s", param.Type), "Context") { 61 | f.Input = append(f.Input, Arg{ 62 | Name: name.Name, 63 | }) 64 | } 65 | } 66 | } 67 | outputParam := param.Results 68 | if outputParam != nil { 69 | if len(outputParam.List) > 1 { 70 | return nil, errors.New("only error is supported as an output param") 71 | } 72 | if len(outputParam.List) == 0 { 73 | return nil, errors.New( 74 | "output params are required, only error is supported", 75 | ) 76 | } 77 | 78 | for _, param := range outputParam.List { 79 | if fmt.Sprintf("%s", param.Type) != "error" { 80 | return nil, errors.New("output was not error") 81 | } 82 | } 83 | 84 | f.Output = Output{Error: true} 85 | } 86 | 87 | funcs = append(funcs, &f) 88 | } 89 | } 90 | } 91 | } 92 | 93 | return funcs, nil 94 | } 95 | -------------------------------------------------------------------------------- /cmd/plan.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lunarway/shuttle/pkg/ui" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | const planDefaultTempl = `{{.Plan}}` 9 | 10 | var planFlagTemplate string 11 | 12 | func newPlan(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 13 | planCmd := &cobra.Command{ 14 | Use: "plan", 15 | Short: "Output plan information to stdout", 16 | Long: `Output plan information to stdout. 17 | By default the plan name is output. For projects without a plan (plan: false) an 18 | empty string is written. 19 | 20 | Configure the output with a template variable. The format is Go templates. 21 | See http://golang.org/pkg/text/template/#pkg-overview for more details. 22 | 23 | Available fields are: 24 | 25 | .LocalPlanPath Path to the plan on the local file system. 26 | .Plan Pretty plan string. Empty if no plan is set. 27 | .PlanRaw Raw plan string as read from the configuration. 28 | .ProjectPath Path to the current project. 29 | .TempDirectoryPath Path to the temporary files of the plan on the local file 30 | system. 31 | `, 32 | Example: `Get the raw plan string as it is written in the shuttle.yaml file: 33 | shuttle plan --template '{{.PlanRaw}}'`, 34 | Args: cobra.ExactArgs(0), 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | type templData struct { 37 | LocalPlanPath string 38 | Plan string 39 | PlanRaw interface{} 40 | ProjectPath string 41 | TempDirectoryPath string 42 | } 43 | uii.SetUserLevel(ui.LevelError) 44 | context, err := contextProvider() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | var templ string 50 | if planFlagTemplate != "" { 51 | templ = planFlagTemplate 52 | } else { 53 | templ = planDefaultTempl 54 | } 55 | err = ui.Template(cmd.OutOrStdout(), "plan", templ, templData{ 56 | Plan: context.Config.Plan, 57 | PlanRaw: context.Config.PlanRaw, 58 | LocalPlanPath: context.LocalPlanPath, 59 | ProjectPath: context.ProjectPath, 60 | TempDirectoryPath: context.TempDirectoryPath, 61 | }) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | return nil 67 | }, 68 | } 69 | 70 | planCmd.Flags(). 71 | StringVar(&planFlagTemplate, "template", "", "Template string to use. See --help for details.") 72 | 73 | return planCmd 74 | } 75 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch_test.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "io/fs" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestPatchGoMod(t *testing.T) { 13 | t.Parallel() 14 | 15 | t.Run("finds root module adds to actions plan", func(t *testing.T) { 16 | sut := NewPatcher() 17 | sut.patcher = newGoModPatcher(func(name string, contents []byte, permissions fs.FileMode) error { 18 | assert.Equal(t, "testdata/patch/root_module/.shuttle/actions/tmp/go.mod", name) 19 | assert.Equal(t, `module actions 20 | 21 | require ( 22 | root_module 23 | ) 24 | 25 | go 1.21.4 26 | 27 | 28 | replace root_module => ../../..`, string(contents)) 29 | 30 | return nil 31 | }) 32 | 33 | err := sut.Patch(context.Background(), "testdata/patch/root_module/", "testdata/patch/root_module/.shuttle/actions") 34 | require.NoError(t, err) 35 | }) 36 | 37 | t.Run("finds root module replaces existing", func(t *testing.T) { 38 | sut := NewPatcher() 39 | sut.patcher = newGoModPatcher(func(name string, contents []byte, permissions fs.FileMode) error { 40 | assert.Equal(t, "testdata/patch/replace_existing/.shuttle/actions/tmp/go.mod", name) 41 | assert.Equal(t, `module actions 42 | 43 | require ( 44 | replace_existing v0.0.0 45 | ) 46 | 47 | go 1.21.4 48 | 49 | replace replace_existing => ../../.. 50 | `, string(contents)) 51 | 52 | return nil 53 | }) 54 | 55 | err := sut.Patch(context.Background(), "testdata/patch/replace_existing/", "testdata/patch/replace_existing/.shuttle/actions") 56 | require.NoError(t, err) 57 | }) 58 | 59 | t.Run("finds root workspace adds entries", func(t *testing.T) { 60 | sut := NewPatcher() 61 | sut.patcher = newGoModPatcher(func(name string, contents []byte, permissions fs.FileMode) error { 62 | assert.Equal(t, "testdata/patch/root_workspace/.shuttle/actions/tmp/go.mod", name) 63 | assert.Equal(t, `module actions 64 | 65 | require ( 66 | root_workspace v0.0.0 67 | subpackage v0.0.0 68 | othersubpackage v0.0.0 69 | ) 70 | 71 | go 1.21.4 72 | 73 | 74 | replace othersubpackage => ../../../other/subpackage 75 | 76 | replace root_workspace => ../../.. 77 | 78 | replace subpackage => ../../../subpackage`, string(contents)) 79 | 80 | return nil 81 | }) 82 | 83 | err := sut.Patch(context.Background(), "testdata/patch/root_workspace/", "testdata/patch/root_workspace/.shuttle/actions") 84 | require.NoError(t, err) 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lunarway/shuttle 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.4 6 | 7 | require ( 8 | github.com/Masterminds/sprig/v3 v3.2.3 9 | github.com/cli/safeexec v1.0.1 10 | github.com/go-cmd/cmd v1.4.2 11 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 12 | github.com/mitchellh/copystructure v1.1.1 // indirect 13 | github.com/pkg/errors v0.9.1 14 | github.com/spf13/cobra v1.8.0 15 | github.com/stretchr/testify v1.9.0 16 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 17 | gopkg.in/yaml.v3 v3.0.1 // indirect 18 | ) 19 | 20 | require ( 21 | dagger.io/dagger v0.11.6 22 | github.com/AlecAivazis/survey/v2 v2.3.7 23 | github.com/google/uuid v1.6.0 24 | github.com/iancoleman/strcase v0.3.0 25 | github.com/matishsiao/goInfo v0.0.0-20210923090445-da2e3fa8d45f 26 | github.com/otiai10/copy v1.14.0 27 | golang.org/x/mod v0.18.0 28 | golang.org/x/sync v0.7.0 29 | gopkg.in/yaml.v2 v2.4.0 30 | ) 31 | 32 | require ( 33 | github.com/99designs/gqlgen v0.17.44 // indirect 34 | github.com/Khan/genqlient v0.7.0 // indirect 35 | github.com/Masterminds/goutils v1.1.1 // indirect 36 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 37 | github.com/adrg/xdg v0.4.0 // indirect 38 | github.com/davecgh/go-spew v1.1.1 // indirect 39 | github.com/go-logr/logr v1.4.1 // indirect 40 | github.com/go-logr/stdr v1.2.2 // indirect 41 | github.com/huandu/xstrings v1.3.3 // indirect 42 | github.com/imdario/mergo v0.3.11 // indirect 43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 44 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 45 | github.com/mattn/go-colorable v0.1.13 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 48 | github.com/mitchellh/go-homedir v1.1.0 // indirect 49 | github.com/mitchellh/reflectwalk v1.0.1 // indirect 50 | github.com/pmezard/go-difflib v1.0.0 // indirect 51 | github.com/shopspring/decimal v1.2.0 // indirect 52 | github.com/sosodev/duration v1.2.0 // indirect 53 | github.com/spf13/cast v1.4.1 // indirect 54 | github.com/spf13/pflag v1.0.5 // indirect 55 | github.com/vektah/gqlparser/v2 v2.5.11 // indirect 56 | go.opentelemetry.io/otel v1.26.0 // indirect 57 | go.opentelemetry.io/otel/metric v1.26.0 // indirect 58 | go.opentelemetry.io/otel/trace v1.26.0 // indirect 59 | golang.org/x/crypto v0.9.0 // indirect 60 | golang.org/x/sys v0.21.0 // indirect 61 | golang.org/x/term v0.8.0 // indirect 62 | golang.org/x/text v0.14.0 // indirect 63 | ) 64 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/actions_test.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestActionsMerge(t *testing.T) { 10 | t.Parallel() 11 | 12 | t.Run("can merge multiple other actions", func(t *testing.T) { 13 | sut := NewActions() 14 | 15 | actual := sut.Merge( 16 | &Actions{ 17 | Actions: map[string]Action{ 18 | "someAction": {}, 19 | }, 20 | }, 21 | &Actions{ 22 | Actions: map[string]Action{ 23 | "someOtherAction": {}, 24 | }, 25 | }, 26 | ) 27 | 28 | assert.Len(t, actual.Actions, 2) 29 | }) 30 | 31 | t.Run("can override", func(t *testing.T) { 32 | sut := NewActions() 33 | 34 | sut.Actions["someAction"] = Action{ 35 | Args: []ActionArg{ 36 | { 37 | Name: "someArg", 38 | }, 39 | }, 40 | } 41 | 42 | actual := sut.Merge( 43 | &Actions{ 44 | Actions: map[string]Action{ 45 | "someAction": {}, 46 | }, 47 | }, 48 | ) 49 | 50 | assert.Len(t, actual.Actions["someAction"].Args, 0) 51 | }) 52 | 53 | t.Run("ignores nil", func(t *testing.T) { 54 | sut := NewActions() 55 | 56 | actual := sut.Merge( 57 | nil, 58 | nil, 59 | ) 60 | 61 | assert.Len(t, actual.Actions, 0) 62 | }) 63 | 64 | } 65 | 66 | func TestActionsExecute(t *testing.T) { 67 | t.Parallel() 68 | 69 | t.Run("finds action executes closure", func(t *testing.T) { 70 | sut := NewActions().Merge(&Actions{ 71 | Actions: map[string]Action{ 72 | "action": {}, 73 | }, 74 | }) 75 | 76 | called := false 77 | 78 | ran, err := sut.Execute("action", func() error { 79 | called = true 80 | return nil 81 | }) 82 | assert.NoError(t, err) 83 | 84 | assert.True(t, ran) 85 | assert.True(t, called) 86 | }) 87 | 88 | t.Run("does not find an action does not execute", func(t *testing.T) { 89 | sut := NewActions().Merge(&Actions{ 90 | Actions: map[string]Action{}, 91 | }) 92 | 93 | called := false 94 | 95 | ran, err := sut.Execute("action", func() error { 96 | called = true 97 | return nil 98 | }) 99 | assert.NoError(t, err) 100 | 101 | assert.False(t, ran) 102 | assert.False(t, called) 103 | }) 104 | 105 | t.Run("action is null", func(t *testing.T) { 106 | var action *Actions 107 | action = nil 108 | 109 | ran, err := action.Execute("something", func() error { return nil }) 110 | 111 | assert.False(t, ran) 112 | assert.NoError(t, err) 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /pkg/executors/golang/discover/discover.go: -------------------------------------------------------------------------------- 1 | package discover 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/lunarway/shuttle/pkg/config" 11 | ) 12 | 13 | var InvalidShuttlePathFile = errors.New("shuttle path did not point ot a shuttle.yaml file") 14 | 15 | const ( 16 | actionsdir = "actions" 17 | shuttlefilename = "shuttle.yaml" 18 | ) 19 | 20 | type ActionsDiscovered struct { 21 | Files []string 22 | DirPath string 23 | ParentDir string 24 | } 25 | 26 | type Discovered struct { 27 | Local *ActionsDiscovered 28 | Plan *ActionsDiscovered 29 | } 30 | 31 | // path: is a path to the shuttle.yaml file 32 | // It will always look for the actions directory relative to the shuttle.yaml file 33 | // 34 | // 1. Traverse actionsdir 35 | // 36 | // 2. Traverse plan if exists (only 1 layer for now) 37 | // 38 | // 3. Collect file names 39 | // 40 | // 4. Return list of files to move to tmp dir 41 | func Discover( 42 | ctx context.Context, 43 | shuttlepath string, 44 | c *config.ShuttleProjectContext, 45 | ) (*Discovered, error) { 46 | if !strings.HasSuffix(shuttlepath, shuttlefilename) { 47 | return nil, InvalidShuttlePathFile 48 | } 49 | if _, err := os.Stat(shuttlepath); errors.Is(err, os.ErrNotExist) { 50 | return nil, InvalidShuttlePathFile 51 | } 52 | 53 | localdir := path.Dir(shuttlepath) 54 | localPlan, err := discoverPlan(localdir) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | discovered := Discovered{ 60 | Local: localPlan, 61 | } 62 | 63 | if c.Config.Plan != "" { 64 | planShuttleFile := path.Join(localdir, ".shuttle/plan") 65 | parentPlan, err := discoverPlan(planShuttleFile) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | discovered.Plan = parentPlan 71 | } 72 | 73 | return &discovered, nil 74 | } 75 | 76 | func discoverPlan(localdir string) (*ActionsDiscovered, error) { 77 | localshuttledirentries := make([]string, 0) 78 | 79 | actionspath := path.Join(localdir, actionsdir) 80 | if fs, err := os.Stat(actionspath); err == nil { 81 | // list all local files 82 | if fs.IsDir() { 83 | entries, err := os.ReadDir(actionspath) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | for _, entry := range entries { 89 | // skip dirs 90 | if entry.IsDir() { 91 | continue 92 | } 93 | 94 | // skip non go files 95 | if !strings.HasSuffix(entry.Name(), ".go") { 96 | continue 97 | } 98 | 99 | // skip test files 100 | if strings.HasSuffix(entry.Name(), "_test.go") { 101 | continue 102 | } 103 | 104 | localshuttledirentries = append(localshuttledirentries, entry.Name()) 105 | } 106 | } 107 | 108 | return &ActionsDiscovered{ 109 | DirPath: actionspath, 110 | Files: localshuttledirentries, 111 | ParentDir: localdir, 112 | }, nil 113 | 114 | } 115 | return nil, nil 116 | } 117 | -------------------------------------------------------------------------------- /pkg/executors/golang/executer/executer.go: -------------------------------------------------------------------------------- 1 | package executer 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/lunarway/shuttle/pkg/executors/golang/compile" 11 | "github.com/lunarway/shuttle/pkg/telemetry" 12 | ) 13 | 14 | // Executes an action based on which plan is used 15 | // Get a list of actions for each binary if they exist 16 | // Take child if available otherwise pick plan, else error 17 | func executeAction(ctx context.Context, binaries *compile.Binaries, args ...string) error { 18 | localInquire, err := inquire(ctx, &binaries.Local) 19 | if err != nil { 20 | return err 21 | } 22 | planInquire, err := inquire(ctx, &binaries.Plan) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | cmdToExecute := args[0] 28 | 29 | ran, err := localInquire.Execute(cmdToExecute, func() error { 30 | return executeBinaryAction(ctx, &binaries.Local, args...) 31 | }) 32 | if err != nil { 33 | return err 34 | } 35 | if ran { 36 | return nil 37 | } 38 | 39 | ran, err = planInquire.Execute(cmdToExecute, func() error { 40 | return executeBinaryAction(ctx, &binaries.Plan, args...) 41 | }) 42 | if err != nil { 43 | return err 44 | } 45 | if ran { 46 | return nil 47 | } 48 | 49 | return fmt.Errorf("no action available in commands, available options are available through shuttle run -h") 50 | } 51 | 52 | func executeBinaryAction(ctx context.Context, binary *compile.Binary, args ...string) error { 53 | execmd := exec.Command(binary.Path, args...) 54 | execmd.Stdout = os.Stdout 55 | execmd.Stderr = os.Stderr 56 | 57 | workdir, err := os.Getwd() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | execmd.Env = os.Environ() 63 | execmd.Env = append(execmd.Env, fmt.Sprintf("TASK_CONTEXT_DIR=%s", workdir)) 64 | execmd.Env = append(execmd.Env, "SHUTTLE_INTERACTIVE=default") 65 | execmd.Env = append( 66 | execmd.Env, 67 | fmt.Sprintf("%s=%s", 68 | "SHUTTLE_CONTEXT_ID", 69 | telemetry.ContextIDFrom(ctx), 70 | ), 71 | ) 72 | 73 | err = execmd.Run() 74 | 75 | os.Stdout.Sync() 76 | os.Stderr.Sync() 77 | 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func inquire(ctx context.Context, binary *compile.Binary) (actions *Actions, err error) { 86 | if binary == nil { 87 | return nil, nil 88 | } 89 | 90 | if binary.Path == "" { 91 | return nil, nil 92 | } 93 | 94 | cmd := exec.Command(binary.Path, "lsjson") 95 | output, err := cmd.Output() 96 | if err != nil { 97 | if exitErr, ok := err.(*exec.ExitError); ok { 98 | fmt.Printf("shuttle_actions: err: %s", string(exitErr.Stderr)) 99 | } 100 | 101 | fmt.Printf("shuttle_actions: %s", string(output)) 102 | return nil, fmt.Errorf("inquire failed and could not get a list of commands: %v", err) 103 | 104 | } 105 | 106 | actions = &Actions{} 107 | if err := json.Unmarshal(output, actions); err != nil { 108 | return nil, fmt.Errorf("inquire failed with json unmarshal: %v", err) 109 | } 110 | 111 | return actions, nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/telemetry/utility.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/matishsiao/goInfo" 11 | ) 12 | 13 | const ( 14 | telemetryContextID string = "shuttle.contextID" 15 | telemetryRunID string = "shuttle.runID" 16 | TelemetryCommand string = "shuttle.command" 17 | TelemetryCommandArgs string = "shuttle.command.args" 18 | ) 19 | 20 | func WithPhase(phase string) TelemetryOption { 21 | return WithEntry("phase", phase) 22 | } 23 | 24 | func WithLabel(label string) TelemetryOption { 25 | return WithEntry("label", label) 26 | } 27 | 28 | func WithEntry(key, value string) TelemetryOption { 29 | return func(properties map[string]string) { 30 | properties[key] = value 31 | } 32 | } 33 | 34 | func WithGoInfo() TelemetryOption { 35 | return func(properties map[string]string) { 36 | gi, err := goInfo.GetInfo() 37 | if err != nil { 38 | properties["system.goinfo.error"] = err.Error() 39 | return 40 | } 41 | if gi.OS != "" { 42 | properties["system.os"] = gi.OS 43 | } 44 | if gi.Kernel != "" { 45 | properties["system.kernel"] = gi.Kernel 46 | } 47 | if gi.Core != "" { 48 | properties["system.core"] = gi.Core 49 | } 50 | if gi.Platform != "" { 51 | properties["system.platform"] = gi.Platform 52 | } 53 | if gi.Hostname != "" { 54 | properties["system.hostname"] = gi.Hostname 55 | } 56 | if gi.CPUs != 0 { 57 | properties["system.cpus"] = fmt.Sprintf("%d", gi.CPUs) 58 | } 59 | if gi.GoOS != "" { 60 | properties["system.goos"] = gi.GoOS 61 | } 62 | } 63 | } 64 | 65 | func includeContext(ctx context.Context, properties map[string]string) map[string]string { 66 | getFromContext(ctx, telemetryContextID, properties) 67 | getFromContext(ctx, telemetryRunID, properties) 68 | getFromContext(ctx, TelemetryCommand, properties) 69 | getFromContextHashValue(ctx, TelemetryCommandArgs, properties) 70 | 71 | return properties 72 | } 73 | 74 | func getFromContext(ctx context.Context, key string, properties map[string]string) { 75 | if val, ok := ctx.Value(key).(string); ok && val != "" { 76 | properties[key] = val 77 | } 78 | } 79 | 80 | func getFromContextHashValue(ctx context.Context, key string, properties map[string]string) { 81 | hasher := sha256.New() 82 | 83 | if val, ok := ctx.Value(key).(string); ok && val != "" { 84 | for _, arg := range strings.Split(val, " ") { 85 | keyvaluepair := strings.Split(arg, "=") 86 | if len(keyvaluepair) != 2 { 87 | return 88 | } 89 | 90 | properties[fmt.Sprintf("%s.%s", key, keyvaluepair[0])] = fmt.Sprintf( 91 | "sha256(16)=%s", 92 | hex.EncodeToString(hasher.Sum([]byte(keyvaluepair[1])))[0:16], 93 | ) 94 | } 95 | } 96 | } 97 | 98 | func copyHostMap(original map[string]string, flowProperties map[string]string) map[string]string { 99 | properties := make(map[string]string, len(flowProperties)+len(original)) 100 | for k, v := range original { 101 | properties[k] = v 102 | } 103 | 104 | for k, v := range flowProperties { 105 | properties[k] = v 106 | } 107 | 108 | return properties 109 | } 110 | -------------------------------------------------------------------------------- /docs/features/telemetry.md: -------------------------------------------------------------------------------- 1 | # Shuttle Telemetry 2 | 3 | ## How to 4 | 5 | To enable shuttle telemetry 6 | 7 | ```bash 8 | export SHUTTLE_REMOTE_TRACING=default 9 | shuttle run build 10 | 11 | export SHUTTLE_REMOTE_TRACING_URL=https://your-tracing-server.com/publish/batch 12 | shuttle telemetry upload 13 | ``` 14 | 15 | Extra options are available for `shuttle telemetry upload -h`. 16 | 17 | The schema of the tracing server should be like so: 18 | 19 | ```json 20 | [ 21 | { 22 | "app": "shuttle", 23 | "timestamp": "2023-07-17-15:21:27Z", 24 | "properties": { 25 | "shuttle.contextID": "", 26 | "shuttle.runID": "", 27 | "shuttle.command": "build" 28 | } 29 | }, 30 | ... 31 | ] 32 | ``` 33 | 34 | ## Theory 35 | 36 | This feature introduces telemetry to shuttle, it is a bit different than what 37 | you might be used to. This is not for shuttle to send telemetry to us (Lunar), 38 | but rather an option for you (the user/org) to collect telemetry, for your own 39 | needs. This is an opt-in feature, and you will need to build some tooling around 40 | it. 41 | 42 | The goal of the telemetry feature is to collect certain information from shuttle 43 | runs which may be useful for analytics purposes. The collected data has 44 | intentionally been anonymized. 45 | 46 | To enable telemetry you can either enable local or remote tracing tracing. 47 | 48 | - Local tracing just outputs what we would trace to standard out. You can enable 49 | it so: `export SHUTTLE_LOG_TRACING=true`, and then run a shuttle command 50 | `shuttle run build`. You should now see extra log statements in your console 51 | output. 52 | - Remote does a few more things, by itself it doesn't actually upload files, but 53 | it puts them in a ready to use format (json lines, .jsonl) in your 54 | `~/.local/share/shuttle/telemetry` folder. Each process will have a unique log 55 | file in there. To enable this feature simply 56 | `export SHUTTLE_REMOTE_TRACING=true`, you can set a custom telemetry folder 57 | directory using `SHUTTLE_REMOTE_LOG_LOCATION` as well. 58 | 59 | Finally you can choose to upload the telemetry: simply 60 | `shuttle upload --url https:///publish`. The backend 61 | implementation is intentionally left blank, and for now no reference 62 | implementation is provided. 63 | 64 | However, the schema is super straightforward, you just need an endpoint that 65 | accepts the following body: 66 | 67 | ```json 68 | { 69 | "app": "string", 70 | "timestamp": "string", // follows RFC3339 71 | "properties": { 72 | "keys": "values" 73 | // ... 74 | } 75 | } 76 | ``` 77 | 78 | You can now choose to ingest the logs however, you want, maybe you'd like to 79 | trace it with opentelemetry, or log it to your preferred logging solution, or 80 | put it in a datalake, the choice is yours. 81 | 82 | To see what values you can expect, simply 83 | `SHUTTLE_LOG_TRACING=true shuttle run build` and capture some of the output, to 84 | see some of the values you may expect. 85 | 86 | Each run will have a `shuttle.contextID` field, this is used to tie a run 87 | together, so that if a build.sh file calls another shuttle command internally 88 | that will be logged under the same contextID as well. 89 | -------------------------------------------------------------------------------- /pkg/config/shuttleconfig_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestShuttleConfig_getConf(t *testing.T) { 11 | tt := []struct { 12 | name string 13 | input string 14 | strictMode bool 15 | err error 16 | config ShuttleConfig 17 | foundPath string 18 | }{ 19 | { 20 | name: "empty path", 21 | input: "", 22 | }, 23 | { 24 | name: "unknown field", 25 | input: "testdata/unknown_field", 26 | err: errors.New( 27 | "exit code 2 - Failed to parse shuttle configuration: yaml: unmarshal errors:\n line 1: field nothing not found in type config.ShuttleConfig\n\nMake sure your 'shuttle.yaml' is valid.", 28 | ), 29 | }, 30 | { 31 | name: "unknown file", 32 | input: "testdata/unknown_file", 33 | err: errors.New( 34 | "exit code 2 - Failed to load shuttle configuration: shuttle.yaml file not found\n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available.", 35 | ), 36 | }, 37 | { 38 | name: "absolute path to unknown file", 39 | input: "/tmp/shuttle-test/unknown", 40 | err: errors.New( 41 | "exit code 2 - Failed to load shuttle configuration: shuttle.yaml file not found\n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available.", 42 | ), 43 | }, 44 | { 45 | name: "valid", 46 | input: "testdata/valid", 47 | err: nil, 48 | foundPath: "testdata/valid", 49 | config: ShuttleConfig{ 50 | Plan: ".", 51 | PlanRaw: ".", 52 | Variables: map[string]interface{}{ 53 | "squad": "name", 54 | }, 55 | Scripts: map[string]ShuttlePlanScript{ 56 | "shout": { 57 | Description: "Shout hello", 58 | Actions: []ShuttleAction{ 59 | { 60 | Shell: `echo "HELLO WORLD"`, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | { 68 | name: "subdir of shuttle.yaml file", 69 | input: "testdata/valid/subdir", 70 | err: nil, 71 | foundPath: "testdata/valid", 72 | config: ShuttleConfig{ 73 | Plan: ".", 74 | PlanRaw: ".", 75 | Variables: map[string]interface{}{ 76 | "squad": "name", 77 | }, 78 | Scripts: map[string]ShuttlePlanScript{ 79 | "shout": { 80 | Description: "Shout hello", 81 | Actions: []ShuttleAction{ 82 | { 83 | Shell: `echo "HELLO WORLD"`, 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | { 91 | name: "subdir of shuttle.yaml file in strict mode", 92 | input: "testdata/valid/subdir", 93 | strictMode: true, 94 | err: errors.New( 95 | "exit code 2 - Failed to load shuttle configuration: shuttle.yaml file not found\n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available.", 96 | ), 97 | }, 98 | } 99 | for _, tc := range tt { 100 | t.Run(tc.name, func(t *testing.T) { 101 | c := &ShuttleConfig{} 102 | 103 | path, err := c.getConf(tc.input, tc.strictMode) 104 | 105 | if tc.err != nil { 106 | assert.EqualError(t, err, tc.err.Error(), "error not as expected") 107 | } else { 108 | assert.NoError(t, err, "unexpected error") 109 | } 110 | assert.Equal(t, tc.config, *c, "config not as expected") 111 | assert.Equal(t, tc.foundPath, path, "shuttle.yaml path not as expected") 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/patch_workspace.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | type workspaceFinder struct{} 13 | 14 | func newWorkspaceFinder() *workspaceFinder { 15 | return &workspaceFinder{} 16 | } 17 | 18 | func (w *workspaceFinder) rootWorkspaceExists(rootDir string) bool { 19 | goWork := path.Join(rootDir, "go.work") 20 | if _, err := os.Stat(goWork); errors.Is(err, os.ErrNotExist) { 21 | return false 22 | } 23 | 24 | return true 25 | } 26 | 27 | func (s *workspaceFinder) Find(ctx context.Context, rootDir string) (packages map[string]string, ok bool, err error) { 28 | if !s.rootWorkspaceExists(rootDir) { 29 | return nil, false, nil 30 | } 31 | 32 | modules, err := s.getWorkspaceModules(rootDir) 33 | if err != nil { 34 | return nil, true, err 35 | } 36 | 37 | packages = make(map[string]string, 0) 38 | for _, module := range modules { 39 | moduleName, modulePath, err := s.getWorkspaceModule(rootDir, module) 40 | if err != nil { 41 | return nil, true, err 42 | } 43 | packages[moduleName] = modulePath 44 | } 45 | 46 | return packages, true, nil 47 | } 48 | 49 | func (w *workspaceFinder) getWorkspaceModules(rootDir string) (modules []string, err error) { 50 | workFile, err := os.ReadFile(path.Join(rootDir, "go.work")) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | workFileContent := string(workFile) 56 | lines := strings.Split(workFileContent, "\n") 57 | if len(lines) == 0 { 58 | return nil, errors.New("go work is empty") 59 | } 60 | 61 | modules = make([]string, 0) 62 | for _, line := range lines { 63 | modFileLine := strings.Trim(strings.TrimSpace(line), "\t") 64 | if strings.HasPrefix(modFileLine, ".") && modFileLine != "./actions" { 65 | modules = append( 66 | modules, 67 | strings.TrimPrefix( 68 | strings.TrimPrefix(modFileLine, "."), 69 | "/", 70 | ), 71 | ) 72 | } 73 | } 74 | 75 | return modules, nil 76 | } 77 | 78 | func (w *workspaceFinder) getWorkspaceModule(rootDir string, absoluteModulePath string) (moduleName string, modulePath string, err error) { 79 | modFile, err := os.ReadFile(path.Join(rootDir, absoluteModulePath, "go.mod")) 80 | if err != nil { 81 | return "", "", fmt.Errorf("failed to find go.mod at: %s: %w", absoluteModulePath, err) 82 | } 83 | 84 | modFileContent := string(modFile) 85 | lines := strings.Split(modFileContent, "\n") 86 | if len(lines) == 0 { 87 | return "", "", errors.New("go mod is empty") 88 | } 89 | 90 | for _, line := range lines { 91 | modFileLine := strings.TrimSpace(line) 92 | if strings.HasPrefix(modFileLine, "module") { 93 | sections := strings.Split(modFileLine, " ") 94 | if len(sections) < 2 { 95 | return "", "", fmt.Errorf("invalid module line: %s", modFileLine) 96 | } 97 | 98 | moduleName := sections[1] 99 | modulePath = strings.TrimPrefix(absoluteModulePath, rootDir) 100 | 101 | return moduleName, modulePath, nil 102 | } else if strings.HasPrefix(modFileLine, "use") && strings.Contains(modFileLine, ".") { 103 | sections := strings.Split(modFileLine, " ") 104 | if len(sections) == 2 { 105 | return "", "", fmt.Errorf("invalid module line: %s", modFileLine) 106 | } 107 | 108 | moduleName := sections[1] 109 | modulePath = strings.TrimPrefix(absoluteModulePath, rootDir) 110 | 111 | return moduleName, modulePath, nil 112 | 113 | } 114 | } 115 | 116 | return "", "", errors.New("failed to find a valid go.mod file") 117 | } 118 | -------------------------------------------------------------------------------- /cmd/test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "io/fs" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func args(s ...string) []string { 14 | return s 15 | } 16 | 17 | type testCase struct { 18 | name string 19 | input []string 20 | initErr error 21 | stdoutput string 22 | erroutput string 23 | err error 24 | } 25 | 26 | func executeTestCasesWithCustomAssertion( 27 | t *testing.T, 28 | testCases []testCase, 29 | assertion func(t *testing.T, tc testCase, stdout, stderr string), 30 | ) { 31 | for _, tc := range testCases { 32 | t.Run(tc.name, func(t *testing.T) { 33 | // remove any .shuttle files up front and after each test to make sure the 34 | // runs are deterministic 35 | t.Cleanup(func() { 36 | removeShuttleDirectories(t) 37 | }) 38 | removeShuttleDirectories(t) 39 | 40 | stdBuf := new(bytes.Buffer) 41 | errBuf := new(bytes.Buffer) 42 | 43 | rootCmd, _, err := initializedRootFromArgs(stdBuf, errBuf, tc.input) 44 | if err != nil { 45 | require.Error(t, err) 46 | require.Error(t, tc.initErr) 47 | require.Equal(t, tc.initErr.Error(), err.Error()) 48 | return 49 | } 50 | rootCmd.SetArgs(tc.input) 51 | 52 | err = rootCmd.Execute() 53 | if tc.err == nil { 54 | assert.NoError(t, err) 55 | } else { 56 | assert.EqualError(t, err, tc.err.Error()) 57 | } 58 | assertion(t, tc, stdBuf.String(), errBuf.String()) 59 | }) 60 | } 61 | } 62 | 63 | func executeTestCases(t *testing.T, testCases []testCase) { 64 | executeTestCasesWithCustomAssertion( 65 | t, 66 | testCases, 67 | func(t *testing.T, tc testCase, stdout, stderr string) { 68 | assert.Equal(t, tc.stdoutput, stdout, "std output not as expected") 69 | assert.Equal(t, tc.erroutput, stderr, "err output not as expected") 70 | }, 71 | ) 72 | } 73 | 74 | func executeTestContainsCases(t *testing.T, testCases []testCase) { 75 | for _, tc := range testCases { 76 | t.Run(tc.name, func(t *testing.T) { 77 | // remove any .shuttle files up front and after each test to make sure the 78 | // runs are deterministic 79 | t.Cleanup(func() { 80 | removeShuttleDirectories(t) 81 | }) 82 | removeShuttleDirectories(t) 83 | 84 | stdBuf := new(bytes.Buffer) 85 | errBuf := new(bytes.Buffer) 86 | rootCmd, _, err := initializedRootFromArgs(stdBuf, errBuf, tc.input) 87 | if err != nil { 88 | if tc.initErr == nil { 89 | require.NoError(t, err) 90 | } else { 91 | require.Error(t, err) 92 | require.Error(t, tc.initErr) 93 | require.Equal(t, tc.initErr.Error(), err.Error()) 94 | return 95 | } 96 | } 97 | rootCmd.SetArgs(tc.input) 98 | 99 | err = rootCmd.Execute() 100 | if tc.err == nil { 101 | assert.NoError(t, err) 102 | } else { 103 | assert.ErrorContains(t, err, tc.err.Error()) 104 | } 105 | }) 106 | } 107 | } 108 | 109 | func removeShuttleDirectories(t *testing.T) { 110 | t.Helper() 111 | 112 | pwd, err := os.Getwd() 113 | if err != nil { 114 | t.Fatalf("Failed to get working directory: %v", err) 115 | } 116 | 117 | var directoriesToRemove []string 118 | err = fs.WalkDir(os.DirFS(pwd), "testdata", func(path string, d fs.DirEntry, err error) error { 119 | if err != nil { 120 | return err 121 | } 122 | if d.IsDir() && d.Name() == ".shuttle" { 123 | directoriesToRemove = append(directoriesToRemove, path) 124 | } 125 | return nil 126 | }) 127 | if err != nil { 128 | t.Errorf("Failed to cleanup .shuttle files: %v", err) 129 | } 130 | 131 | for _, d := range directoriesToRemove { 132 | err := os.RemoveAll(d) 133 | if err != nil { 134 | t.Errorf("Failed to cleanup '%s': %v", d, err) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /pkg/config/documentation_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/lunarway/shuttle/pkg/config" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestShuttleProjectContext_Documentation(t *testing.T) { 12 | tt := []struct { 13 | name string 14 | planRef string 15 | docsRef string 16 | 17 | result string 18 | err error 19 | }{ 20 | { 21 | name: "empty context", 22 | planRef: "", 23 | docsRef: "", 24 | result: "", 25 | err: errors.New("exit code 1 - Could not find any plan documentation"), 26 | }, 27 | { 28 | name: "unknown plan protocol", 29 | planRef: "something-odd", 30 | docsRef: "", 31 | result: "", 32 | err: errors.New("exit code 1 - Could not detect protocol for plan 'something-odd'"), 33 | }, 34 | { 35 | name: "unknown plan reference protocol", 36 | planRef: "something-odd", 37 | docsRef: "", 38 | result: "", 39 | err: errors.New("exit code 1 - Could not detect protocol for plan 'something-odd'"), 40 | }, 41 | { 42 | name: "explicit HTTP docs", 43 | planRef: "", 44 | docsRef: "http://github.com/lunarway/shuttle", 45 | result: "http://github.com/lunarway/shuttle", 46 | err: nil, 47 | }, 48 | { 49 | name: "explicit HTTPS docs", 50 | planRef: "", 51 | docsRef: "https://github.com/lunarway/shuttle", 52 | result: "https://github.com/lunarway/shuttle", 53 | err: nil, 54 | }, 55 | { 56 | name: "no explicit docs and git plan ssh reference", 57 | planRef: "git://git@github.com:lunarway/shuttle-example-go-plan.git", 58 | docsRef: "", 59 | result: "https://github.com/lunarway/shuttle-example-go-plan.git", 60 | err: nil, 61 | }, 62 | { 63 | name: "no explicit docs and git plan http reference", 64 | planRef: "http://github.com/lunarway/shuttle-example-go-plan.git", 65 | docsRef: "", 66 | result: "http://github.com/lunarway/shuttle-example-go-plan.git", 67 | err: nil, 68 | }, 69 | { 70 | name: "no explicit docs and git plan https reference", 71 | planRef: "https://github.com/lunarway/shuttle-example-go-plan.git", 72 | docsRef: "", 73 | result: "https://github.com/lunarway/shuttle-example-go-plan.git", 74 | err: nil, 75 | }, 76 | { 77 | name: "no explicit docs and git plan has branch reference", 78 | planRef: "https://github.com/lunarway/shuttle-example-go-plan.git#branch", 79 | docsRef: "", 80 | result: "https://github.com/lunarway/shuttle-example-go-plan.git", 81 | err: nil, 82 | }, 83 | { 84 | name: "absolute local file path", 85 | planRef: "/plan", 86 | docsRef: "", 87 | result: "", 88 | err: errors.New("exit code 2 - Local plan has no documentation"), 89 | }, 90 | { 91 | name: "local file path", 92 | planRef: "./plan", 93 | docsRef: "", 94 | result: "", 95 | err: errors.New("exit code 2 - Local plan has no documentation"), 96 | }, 97 | { 98 | name: "local file path in parent path", 99 | planRef: "../plan", 100 | docsRef: "", 101 | result: "", 102 | err: errors.New("exit code 2 - Local plan has no documentation"), 103 | }, 104 | } 105 | for _, tc := range tt { 106 | t.Run(tc.name, func(t *testing.T) { 107 | p := config.ShuttleProjectContext{ 108 | Config: config.ShuttleConfig{ 109 | Plan: tc.planRef, 110 | }, 111 | Plan: config.ShuttlePlanConfiguration{ 112 | Documentation: tc.docsRef, 113 | }, 114 | } 115 | result, err := p.DocumentationURL() 116 | 117 | if tc.err != nil { 118 | assert.EqualError(t, err, tc.err.Error(), "error not as expected") 119 | } else { 120 | assert.NoError(t, err, "unexpected error") 121 | } 122 | assert.Equal(t, tc.result, result, "result not as expected") 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/executors/shell.go: -------------------------------------------------------------------------------- 1 | package executors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/go-cmd/cmd" 11 | 12 | "github.com/lunarway/shuttle/pkg/config" 13 | "github.com/lunarway/shuttle/pkg/errors" 14 | "github.com/lunarway/shuttle/pkg/telemetry" 15 | "github.com/lunarway/shuttle/pkg/ui" 16 | ) 17 | 18 | func ShellExecutor(action config.ShuttleAction) (Executor, bool) { 19 | return executeShell, action.Shell != "" 20 | } 21 | 22 | // Build builds the docker image from a shuttle plan 23 | func executeShell(ctx context.Context, ui *ui.UI, context ActionExecutionContext) error { 24 | cmdOptions := cmd.Options{ 25 | Buffered: false, 26 | Streaming: true, 27 | // support large outputs from scripts 28 | LineBufferSize: 512e3, 29 | } 30 | 31 | cmdArgs := []string{ 32 | "-c", 33 | fmt.Sprintf("cd '%s'; %s", context.ScriptContext.Project.ProjectPath, context.Action.Shell), 34 | } 35 | execCmd := cmd.NewCmdOptions(cmdOptions, "sh", cmdArgs...) 36 | 37 | context.ScriptContext.Project.UI.Verboseln( 38 | "Starting shell command: %s %s", 39 | execCmd.Name, 40 | strings.Join(cmdArgs, " "), 41 | ) 42 | 43 | setupCommandEnvironmentVariables(execCmd, context) 44 | 45 | execCmd.Env = append( 46 | execCmd.Env, 47 | fmt.Sprintf("SHUTTLE_CONTEXT_ID=%s", telemetry.ContextIDFrom(ctx)), 48 | ) 49 | 50 | outputReadCompleted := make(chan struct{}) 51 | 52 | go func() { 53 | defer close(outputReadCompleted) 54 | 55 | for execCmd.Stdout != nil || execCmd.Stderr != nil { 56 | select { 57 | case line, open := <-execCmd.Stdout: 58 | if !open { 59 | execCmd.Stdout = nil 60 | continue 61 | } 62 | context.ScriptContext.Project.UI.Output("%s", line) 63 | case line, open := <-execCmd.Stderr: 64 | if !open { 65 | execCmd.Stderr = nil 66 | continue 67 | } 68 | context.ScriptContext.Project.UI.Infoln("%s", line) 69 | } 70 | } 71 | }() 72 | 73 | // stop cmd if context is cancelled 74 | go func() { 75 | select { 76 | case <-ctx.Done(): 77 | err := execCmd.Stop() 78 | if err != nil { 79 | context.ScriptContext.Project.UI.Errorln( 80 | "Failed to stop script '%s': %v", 81 | context.Action.Shell, 82 | err, 83 | ) 84 | } 85 | case <-outputReadCompleted: 86 | } 87 | }() 88 | 89 | select { 90 | case status := <-execCmd.Start(): 91 | <-outputReadCompleted 92 | if status.Exit > 0 { 93 | return errors.NewExitCode( 94 | 4, 95 | "Failed executing script `%s`: shell script `%s`\nExit code: %v", 96 | context.ScriptContext.ScriptName, 97 | context.Action.Shell, 98 | status.Exit, 99 | ) 100 | } 101 | return nil 102 | case <-ctx.Done(): 103 | return ctx.Err() 104 | } 105 | } 106 | 107 | func setupCommandEnvironmentVariables(execCmd *cmd.Cmd, context ActionExecutionContext) { 108 | shuttlePath, _ := filepath.Abs(filepath.Dir(os.Args[0])) 109 | 110 | execCmd.Env = os.Environ() 111 | for name, value := range context.ScriptContext.Args { 112 | execCmd.Env = append(execCmd.Env, fmt.Sprintf("%s=%s", name, value)) 113 | } 114 | execCmd.Env = append( 115 | execCmd.Env, 116 | fmt.Sprintf("plan=%s", context.ScriptContext.Project.LocalPlanPath), 117 | ) 118 | execCmd.Env = append( 119 | execCmd.Env, 120 | fmt.Sprintf("tmp=%s", context.ScriptContext.Project.TempDirectoryPath), 121 | ) 122 | execCmd.Env = append( 123 | execCmd.Env, 124 | fmt.Sprintf("project=%s", context.ScriptContext.Project.ProjectPath), 125 | ) 126 | // TODO: Add project path as a shuttle specific ENV 127 | execCmd.Env = append( 128 | execCmd.Env, 129 | fmt.Sprintf("PATH=%s", shuttlePath+string(os.PathListSeparator)+os.Getenv("PATH")), 130 | ) 131 | execCmd.Env = append( 132 | execCmd.Env, 133 | fmt.Sprintf( 134 | "SHUTTLE_PLANS_ALREADY_VALIDATED=%s", 135 | context.ScriptContext.Project.LocalPlanPath, 136 | ), 137 | ) 138 | execCmd.Env = append( 139 | execCmd.Env, 140 | "SHUTTLE_INTERACTIVE=default", 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /pkg/executors/golang/cmder/cmder.go: -------------------------------------------------------------------------------- 1 | package cmder 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "os" 10 | "reflect" 11 | 12 | "github.com/lunarway/shuttle/pkg/executors/golang/executer" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | ErrNoHelp = errors.New("cmd failed with exit 1") 18 | ) 19 | 20 | type RootCmd struct { 21 | Cmds []*Cmd 22 | } 23 | 24 | func NewRoot() *RootCmd { 25 | cmd := &RootCmd{} 26 | 27 | return cmd 28 | } 29 | 30 | func (rc *RootCmd) AddCmds(cmd ...*Cmd) *RootCmd { 31 | rc.Cmds = append(rc.Cmds, cmd...) 32 | 33 | return rc 34 | } 35 | 36 | func (rc *RootCmd) Execute() { 37 | if err := rc.TryExecute(os.Args[1:]); err != nil { 38 | if errors.Is(err, ErrNoHelp) { 39 | os.Exit(1) 40 | } else { 41 | log.Fatalf("%v\n", err) 42 | } 43 | } 44 | } 45 | 46 | func (rc *RootCmd) TryExecute(args []string) error { 47 | rootcmd := &cobra.Command{Use: "actions"} 48 | 49 | rootcmd.AddCommand( 50 | &cobra.Command{Use: "ls", RunE: func(cmd *cobra.Command, args []string) error { 51 | return cmd.Parent().Help() 52 | }}, 53 | ) 54 | 55 | rootcmd.AddCommand( 56 | &cobra.Command{ 57 | Hidden: true, 58 | Use: "lsjson", 59 | Run: func(cmd *cobra.Command, args []string) { 60 | actions := executer.NewActions() 61 | for _, cmd := range rc.Cmds { 62 | args := make([]executer.ActionArg, 0) 63 | 64 | for _, arg := range cmd.Args { 65 | args = append(args, executer.ActionArg{ 66 | Name: arg.Name, 67 | }) 68 | } 69 | 70 | actions.Actions[cmd.Name] = executer.Action{ 71 | Args: args, 72 | } 73 | } 74 | 75 | rawJson, err := json.Marshal(actions) 76 | if err != nil { 77 | log.Fatal(err) 78 | } 79 | 80 | // Prints the commands and args as json to stdout 81 | _, err = fmt.Printf("%s", string(rawJson)) 82 | if err != nil { 83 | log.Fatal(err) 84 | } 85 | 86 | return 87 | }, 88 | }, 89 | ) 90 | 91 | for _, cmd := range rc.Cmds { 92 | cmd := cmd 93 | parameters := make([]string, len(cmd.Args)) 94 | 95 | cobracmd := &cobra.Command{ 96 | Use: cmd.Name, 97 | 98 | // We don't want to show the full usage, instead just show the error 99 | SilenceUsage: true, 100 | 101 | RunE: func(cobracmd *cobra.Command, args []string) error { 102 | if err := cobracmd.ParseFlags(args); err != nil { 103 | log.Println(err) 104 | return ErrNoHelp 105 | } 106 | 107 | inputs := make([]reflect.Value, 0, len(cmd.Args)+1) 108 | inputs = append(inputs, reflect.ValueOf(context.Background())) 109 | for _, arg := range parameters { 110 | inputs = append(inputs, reflect.ValueOf(arg)) 111 | } 112 | 113 | returnValues := reflect. 114 | ValueOf(cmd.Func). 115 | Call(inputs) 116 | 117 | if len(returnValues) == 0 { 118 | return nil 119 | } 120 | 121 | for _, val := range returnValues { 122 | if val.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { 123 | err, ok := val.Interface().(error) 124 | if ok && err != nil { 125 | fmt.Fprintln(cobracmd.ErrOrStderr(), err) 126 | return ErrNoHelp 127 | } 128 | } 129 | } 130 | 131 | return nil 132 | }, 133 | } 134 | for i, arg := range cmd.Args { 135 | cobracmd.Flags().StringVar(¶meters[i], arg.Name, "", "") 136 | _ = cobracmd.MarkFlagRequired(arg.Name) 137 | } 138 | 139 | rootcmd.AddCommand(cobracmd) 140 | } 141 | 142 | rootcmd.SetArgs(args) 143 | if err := rootcmd.Execute(); err != nil { 144 | return err 145 | } 146 | return nil 147 | } 148 | 149 | type Arg struct { 150 | Name string 151 | } 152 | 153 | type Cmd struct { 154 | Name string 155 | Func any 156 | Args []Arg 157 | } 158 | 159 | func NewCmd(name string, f any) *Cmd { 160 | return &Cmd{ 161 | Name: name, 162 | Func: f, 163 | Args: []Arg{}, 164 | } 165 | } 166 | 167 | func WithArgs(cmd *Cmd, argName string) *Cmd { 168 | cmd.Args = append(cmd.Args, Arg{Name: argName}) 169 | return cmd 170 | } 171 | -------------------------------------------------------------------------------- /docs/features/golang-actions.md: -------------------------------------------------------------------------------- 1 | # Golang Actions 2 | 3 | Shuttle supports multiple types of actions. Actions are the things command you 4 | do on the command line via. shuttle which does stuff. 5 | 6 | That is `shuttle run build`, `shuttle run test` etc. 7 | 8 | These actions can be defined in multiple ways, and in multiple formats. As the 9 | `examples` show they can be `docker`, `shell` or `golang`. 10 | 11 | As defined in either the `shuttle.yaml` or `plan.yaml` when building a plan. 12 | 13 | A script section can be defined. 14 | 15 | ```yaml 16 | scripts: 17 | build: 18 | actions: 19 | - shell: echo "build... 20 | ``` 21 | 22 | That of course is a shell action, it can also call a file via: 23 | `- shell: $scripts/build.sh` 24 | 25 | However, we can also use golang. To use golang you need a few prerequisites: 26 | 27 | - golang 1.19+ 28 | - or docker 29 | 30 | To create an action in your plan, you don't need either the `plan.yaml` or the 31 | `shuttle.yaml` script sections. You just create a folder: 32 | `mkdir -p actions && cd actions` 33 | 34 | The actions folder is the place our golang code actions will live. Each regular 35 | golang file in this folder will be treated as an action, this doesn't include 36 | tests or folders. As such if you need files that aren't actions, create a folder 37 | and put the code in there. 38 | 39 | ```bash 40 | go mod init actions 41 | go get github.com/lunarway/shuttle 42 | ``` 43 | 44 | Create a golang submodule, and add shuttle to it. 45 | 46 | ```bash 47 | echo <--GOEOF 48 | package main 49 | 50 | import ( 51 | "context", 52 | _ "github.com/lunarway/shuttle" // default the base shuttle so that it doesn't disappear from go.mod 53 | ) 54 | 55 | func Build(ctx context.Context) error { 56 | println("build") 57 | 58 | return nil 59 | } 60 | GOEOF > build.go 61 | ``` 62 | 63 | This will create a file and a function with the same name. Note, the file name 64 | and the function name have to match. golang file names support snake_case, and 65 | golang func names support PascalCase. As such they have to match. Otherwise the 66 | build will fail. 67 | 68 | 1. file: "build_production.go" -> func BuildProduction 69 | 2. file: "build.go" -> func Build 70 | 71 | Lower case functions are ignored, only 1 public function is allowed pr actions 72 | file. 73 | 74 | 1. func handleBuild -> ignored 75 | 2. func Build -> allowed 76 | 3. func build, func Build -> allowed 77 | 4. func Build, func TestBuild -> error (only 1 public function pr file is 78 | allowed) 79 | 80 | Now you can run the command via. shuttle. 81 | 82 | ``` 83 | shuttle run build 84 | stdout: build 85 | ``` 86 | 87 | ## Why 88 | 89 | Why would you want such a feature? 90 | 91 | - First of all software engineering. Using golang over bash, enables a few 92 | things. 93 | - It is easier to build solid tools in golang than bash. Golang code can be 94 | shared and distributed. 95 | - You can include packages, snippets and whatnot in golang. 96 | - Golang can more easily be tested and benchmarked. 97 | - Binaries can be saved and reused to improve performance and startup time. 98 | - A more thorough user experience can be designed. 99 | - No longer bound to what is installed on a client machine, you don't need curl, 100 | wget, uname, grep, yq, jq etc. installed 101 | 102 | ## Configuration 103 | 104 | ### SHUTTLE_GOLANG_ACTIONS 105 | 106 | This variable controls whether or not to enable or disable the golang actions. This may be wanted in environments where golang or docker isn't available, or wanted. 107 | 108 | The variable is default `true`, `false` will disable golang, and any other value will be considered `true` 109 | 110 | ### SHUTTLE_GOLANG_ACTIONS_IMAGE 111 | 112 | This variable controls an override for proving different image for building the golang actions. The image is automatically kept up-to-date, but it may be needed to set this in the place of use, such that a race condition doesn't occur. 113 | 114 | ### SHUTTLE_GOLANG_ACTIONS_DAGGER_FALLBACK 115 | 116 | default: `false`, meaning we don't use dagger as a fallback 117 | `true` is enabled, and will use a dagger pipeline to build the actions if go isn't installed 118 | anything is false and will be disabled 119 | 120 | -------------------------------------------------------------------------------- /cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShuttleFileExists(t *testing.T) { 10 | t.Parallel() 11 | 12 | t.Run("full path, with file", func(t *testing.T) { 13 | actual := shuttleFileExists("/some/long/path", func(filePath string) bool { 14 | switch filePath { 15 | case "/some/long/path/shuttle.yaml": 16 | return true 17 | default: 18 | pathNotExpected(t, filePath) 19 | return false 20 | } 21 | }) 22 | 23 | assert.True(t, actual) 24 | }) 25 | 26 | t.Run("full path, no file", func(t *testing.T) { 27 | actual := shuttleFileExists("/some/long/path", func(filePath string) bool { 28 | switch filePath { 29 | case "/some/long/path/shuttle.yaml": 30 | return false 31 | default: 32 | pathNotExpected(t, filePath) 33 | return true 34 | } 35 | }) 36 | 37 | assert.False(t, actual) 38 | }) 39 | 40 | t.Run("current path, with file", func(t *testing.T) { 41 | actual := shuttleFileExists(".", func(filePath string) bool { 42 | switch filePath { 43 | case "shuttle.yaml": 44 | return true 45 | default: 46 | pathNotExpected(t, filePath) 47 | return false 48 | } 49 | }) 50 | 51 | assert.True(t, actual) 52 | }) 53 | 54 | t.Run("current path, no file", func(t *testing.T) { 55 | actual := shuttleFileExists(".", func(filePath string) bool { 56 | switch filePath { 57 | case "shuttle.yaml": 58 | return false 59 | default: 60 | pathNotExpected(t, filePath) 61 | return true 62 | } 63 | }) 64 | 65 | assert.False(t, actual) 66 | }) 67 | } 68 | 69 | func TestShuttleFileExistsRecursive(t *testing.T) { 70 | t.Parallel() 71 | 72 | t.Run("full path, file in given path", func(t *testing.T) { 73 | actual := shuttleFileExistsRecursive("/some/long/path", func(filePath string) bool { 74 | switch filePath { 75 | case "/some/long/path/shuttle.yaml": 76 | return true 77 | default: 78 | pathNotExpected(t, filePath) 79 | return false 80 | } 81 | }) 82 | 83 | assert.True(t, actual) 84 | }) 85 | 86 | t.Run("full path, file in sub directory", func(t *testing.T) { 87 | actual := shuttleFileExistsRecursive("/some/long/path", func(filePath string) bool { 88 | switch filePath { 89 | case "/some/long/path/shuttle.yaml": 90 | return false 91 | case "/some/long/shuttle.yaml": 92 | return true 93 | default: 94 | pathNotExpected(t, filePath) 95 | return false 96 | } 97 | }) 98 | 99 | assert.True(t, actual) 100 | }) 101 | 102 | t.Run("full path, file in root", func(t *testing.T) { 103 | actual := shuttleFileExistsRecursive("/some/long/path", func(filePath string) bool { 104 | switch filePath { 105 | case "/some/long/path/shuttle.yaml": 106 | return false 107 | case "/some/long/shuttle.yaml": 108 | return false 109 | case "/some/shuttle.yaml": 110 | return false 111 | case "/shuttle.yaml": 112 | return true 113 | default: 114 | pathNotExpected(t, filePath) 115 | return false 116 | } 117 | }) 118 | 119 | assert.True(t, actual) 120 | }) 121 | 122 | t.Run("full path, file not found", func(t *testing.T) { 123 | actual := shuttleFileExistsRecursive("/some/long/path", func(filePath string) bool { 124 | switch filePath { 125 | case "/some/long/path/shuttle.yaml": 126 | return false 127 | case "/some/long/shuttle.yaml": 128 | return false 129 | case "/some/shuttle.yaml": 130 | return false 131 | case "/shuttle.yaml": 132 | return false 133 | case "/": 134 | return false 135 | default: 136 | pathNotExpected(t, filePath) 137 | return true 138 | } 139 | }) 140 | 141 | assert.False(t, actual) 142 | }) 143 | 144 | t.Run("empty path, file false", func(t *testing.T) { 145 | actual := shuttleFileExistsRecursive("", func(filePath string) bool { 146 | switch filePath { 147 | case "shuttle.yaml": 148 | return false 149 | default: 150 | pathNotExpected(t, filePath) 151 | return false 152 | } 153 | }) 154 | 155 | assert.False(t, actual) 156 | }) 157 | 158 | t.Run("current dir, file found", func(t *testing.T) { 159 | actual := shuttleFileExistsRecursive(".", func(filePath string) bool { 160 | switch filePath { 161 | case "shuttle.yaml": 162 | return true 163 | default: 164 | pathNotExpected(t, filePath) 165 | return false 166 | } 167 | }) 168 | 169 | assert.True(t, actual) 170 | }) 171 | } 172 | 173 | func pathNotExpected(t *testing.T, filePath string) { 174 | t.Helper() 175 | 176 | assert.Fail(t, "path was not expected", "the path %s was not expected in matcher", filePath) 177 | } 178 | -------------------------------------------------------------------------------- /pkg/config/shuttleplan.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/lunarway/shuttle/pkg/copy" 12 | "github.com/lunarway/shuttle/pkg/errors" 13 | "github.com/lunarway/shuttle/pkg/git" 14 | "github.com/lunarway/shuttle/pkg/ui" 15 | "gopkg.in/yaml.v2" 16 | ) 17 | 18 | // ShuttlePlanScript is a ShuttlePlan sub-element 19 | type ShuttlePlanScript struct { 20 | Description string `yaml:"description"` 21 | Actions []ShuttleAction `yaml:"actions"` 22 | Args []ShuttleScriptArgs `yaml:"args"` 23 | } 24 | 25 | // ShuttleScriptArgs describes an arguments that a script accepts 26 | type ShuttleScriptArgs struct { 27 | Name string `yaml:"name"` 28 | Required bool `yaml:"required"` 29 | Description string `yaml:"description"` 30 | } 31 | 32 | func (a ShuttleScriptArgs) String() string { 33 | var s strings.Builder 34 | s.WriteString(a.Name) 35 | if a.Required { 36 | s.WriteString(" (required)") 37 | } 38 | if len(a.Description) != 0 { 39 | fmt.Fprintf(&s, " %s", a.Description) 40 | } 41 | return s.String() 42 | } 43 | 44 | // ShuttleAction describes an action done by a shuttle script 45 | type ShuttleAction struct { 46 | Shell string `yaml:"shell"` 47 | Dockerfile string `yaml:"dockerfile"` 48 | Task string `yaml:"task"` 49 | } 50 | 51 | // ShuttlePlanConfiguration is a ShuttlePlan sub-element 52 | type ShuttlePlanConfiguration struct { 53 | Vars map[string]interface{} `yaml:"vars"` 54 | Documentation string `yaml:"documentation"` 55 | Scripts map[string]ShuttlePlanScript `yaml:"scripts"` 56 | } 57 | 58 | // ShuttlePlan struct describes a plan 59 | type ShuttlePlan struct { 60 | ProjectPath string 61 | LocalPlanPath string 62 | Configuration ShuttlePlanConfiguration 63 | } 64 | 65 | // Load loads a plan from project path and shuttle config 66 | func (p *ShuttlePlanConfiguration) Load(planPath string) (*ShuttlePlanConfiguration, error) { 67 | if planPath == "" { 68 | return p, nil 69 | } 70 | 71 | configPath := path.Join(planPath, "plan.yaml") 72 | 73 | file, err := os.Open(configPath) 74 | if err != nil { 75 | return p, errors.NewExitCode( 76 | 2, 77 | "Failed to open plan configuration: %s\n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available.", 78 | err, 79 | ) 80 | } 81 | defer file.Close() 82 | 83 | decoder := yaml.NewDecoder(file) 84 | decoder.SetStrict(true) 85 | err = decoder.Decode(p) 86 | if err != nil { 87 | return p, errors.NewExitCode( 88 | 1, 89 | "Failed to load plan configuration from '%s': %s\n\nThis is likely an issue with the referenced plan. Please, contact the plan maintainers.", 90 | configPath, 91 | err, 92 | ) 93 | } 94 | 95 | return p, nil 96 | } 97 | 98 | // FetchPlan so it exists locally and return path to that plan 99 | func FetchPlan( 100 | plan string, 101 | projectPath string, 102 | localShuttleDirectoryPath string, 103 | uii *ui.UI, 104 | skipGitPlanPulling bool, 105 | planArgument string, 106 | ) (string, error) { 107 | if isPlanArgumentAPlan(planArgument) { 108 | uii.Infoln("Using overloaded plan %v", planArgument) 109 | return FetchPlan( 110 | getPlanFromPlanArgument(planArgument), 111 | projectPath, 112 | localShuttleDirectoryPath, 113 | uii, 114 | skipGitPlanPulling, 115 | "", 116 | ) 117 | } 118 | 119 | switch { 120 | case plan == "": 121 | uii.Verboseln("Using no plan") 122 | return "", nil 123 | case git.IsPlan(plan): 124 | uii.Verboseln("Using git plan at '%s'", plan) 125 | return git.GetGitPlan( 126 | plan, 127 | localShuttleDirectoryPath, 128 | uii, 129 | skipGitPlanPulling, 130 | planArgument, 131 | ) 132 | case isHTTPSPlan(plan): 133 | panic(fmt.Sprintf("Plan '%v' is not valid: non-git http/https is not supported yet", plan)) 134 | case isFilePath(plan, true): 135 | uii.Verboseln("Using local plan at '%s'", plan) 136 | plan, err := handleFilePath(plan, projectPath) 137 | if err != nil { 138 | return "", err 139 | } 140 | return plan, nil 141 | case isFilePath(plan, false): 142 | uii.Verboseln("Using local plan at '%s'", plan) 143 | plan := path.Join(projectPath, plan) 144 | plan, err := handleFilePath(plan, projectPath) 145 | if err != nil { 146 | return "", err 147 | } 148 | return plan, nil 149 | default: 150 | return "", errors.NewExitCode(2, "Unknown plan path '%s'", plan) 151 | } 152 | } 153 | 154 | func handleFilePath(plan string, projectPath string) (string, error) { 155 | toPath := path.Join(projectPath, "/.shuttle/plan") 156 | ignorelist := []string{".git", ".shuttle"} 157 | err := copy.Dir(plan, toPath, ignorelist) 158 | if err != nil { 159 | return "", fmt.Errorf("failed to copy plan to .shuttle/plan, make sure the upstream plan exists") 160 | } 161 | return toPath, nil 162 | } 163 | 164 | func isFilePath(path string, matchOnlyAbs bool) bool { 165 | return filepath.IsAbs(path) == matchOnlyAbs 166 | } 167 | 168 | var httpsRegexp = regexp.MustCompile("^(http|https)://") 169 | 170 | func isHTTPSPlan(plan string) bool { 171 | return httpsRegexp.MatchString(plan) 172 | } 173 | -------------------------------------------------------------------------------- /pkg/config/shuttleconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | shuttleerrors "github.com/lunarway/shuttle/pkg/errors" 11 | "github.com/lunarway/shuttle/pkg/ui" 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | // DynamicYaml are any yaml document 16 | type DynamicYaml = map[string]interface{} 17 | 18 | // ShuttleConfig describes the actual config for each project 19 | type ShuttleConfig struct { 20 | Plan string `yaml:"-"` 21 | PlanRaw interface{} `yaml:"plan"` 22 | Variables DynamicYaml `yaml:"vars"` 23 | Scripts map[string]ShuttlePlanScript `yaml:"scripts"` 24 | } 25 | 26 | // ShuttleProjectContext describes the context of the project using shuttle 27 | type ShuttleProjectContext struct { 28 | ProjectPath string 29 | LocalShuttleDirectoryPath string 30 | TempDirectoryPath string 31 | Config ShuttleConfig 32 | LocalPlanPath string 33 | Plan ShuttlePlanConfiguration 34 | Scripts map[string]ShuttlePlanScript 35 | UI *ui.UI 36 | } 37 | 38 | // Setup the ShuttleProjectContext for a specific path 39 | func (c *ShuttleProjectContext) Setup( 40 | projectPath string, 41 | uii *ui.UI, 42 | clean bool, 43 | skipGitPlanPulling bool, 44 | planArgument string, 45 | strictConfigLookup bool, 46 | ) (*ShuttleProjectContext, error) { 47 | projectPath, err := c.Config.getConf(projectPath, strictConfigLookup) 48 | if err != nil { 49 | return nil, err 50 | } 51 | c.UI = uii 52 | c.ProjectPath = projectPath 53 | c.LocalShuttleDirectoryPath = path.Join(c.ProjectPath, ".shuttle") 54 | 55 | if clean { 56 | uii.Infoln("Cleaning %s", c.LocalShuttleDirectoryPath) 57 | err := os.RemoveAll(c.LocalShuttleDirectoryPath) 58 | if err != nil { 59 | return nil, fmt.Errorf("remove '%s': %w", c.LocalShuttleDirectoryPath, err) 60 | } 61 | } 62 | err = os.MkdirAll(c.LocalShuttleDirectoryPath, os.ModePerm) 63 | if err != nil { 64 | return nil, fmt.Errorf("create '%s' directory: %w", c.LocalShuttleDirectoryPath, err) 65 | } 66 | 67 | c.TempDirectoryPath = path.Join(c.LocalShuttleDirectoryPath, "temp") 68 | c.LocalPlanPath, err = FetchPlan( 69 | c.Config.Plan, 70 | projectPath, 71 | c.LocalShuttleDirectoryPath, 72 | uii, 73 | skipGitPlanPulling, 74 | planArgument, 75 | ) 76 | if err != nil { 77 | return nil, err 78 | } 79 | _, err = c.Plan.Load(c.LocalPlanPath) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | c.Scripts = make(map[string]ShuttlePlanScript) 85 | for scriptName, script := range c.Plan.Scripts { 86 | c.Scripts[scriptName] = script 87 | } 88 | for scriptName, script := range c.Config.Scripts { 89 | c.Scripts[scriptName] = script 90 | } 91 | return c, nil 92 | } 93 | 94 | // getConf loads the ShuttleConfig from yaml file in the project path 95 | func (c *ShuttleConfig) getConf(projectPath string, strictConfigLookup bool) (string, error) { 96 | if projectPath == "" { 97 | return projectPath, nil 98 | } 99 | 100 | file, err := locateShuttleConfigurationFile(projectPath, strictConfigLookup) 101 | if err != nil { 102 | return "", shuttleerrors.NewExitCode( 103 | 2, 104 | "Failed to load shuttle configuration: %s\n\nMake sure you are in a project using shuttle and that a 'shuttle.yaml' file is available.", 105 | err, 106 | ) 107 | } 108 | defer file.Close() 109 | 110 | decoder := yaml.NewDecoder(file) 111 | decoder.SetStrict(true) 112 | err = decoder.Decode(c) 113 | if err != nil { 114 | return "", shuttleerrors.NewExitCode( 115 | 2, 116 | "Failed to parse shuttle configuration: %s\n\nMake sure your 'shuttle.yaml' is valid.", 117 | err, 118 | ) 119 | } 120 | 121 | if c.PlanRaw == nil { 122 | return "", shuttleerrors.NewExitCode( 123 | 2, 124 | "Failed to parse shuttle configuration: %s\n\nFailed to find a `plan`. Make sure your 'shuttle.yaml' is valid.", 125 | err, 126 | ) 127 | } 128 | 129 | switch c.PlanRaw { 130 | case false: 131 | // no plan 132 | default: 133 | c.Plan = c.PlanRaw.(string) 134 | } 135 | 136 | // return the path where the shuttle.yaml file was found 137 | return path.Dir(file.Name()), nil 138 | } 139 | 140 | var errShuttleFileNotFound = errors.New("shuttle.yaml file not found") 141 | 142 | func locateShuttleConfigurationFile(startPath string, strictConfigLookup bool) (*os.File, error) { 143 | var err error 144 | for { 145 | configPath := path.Join(startPath, "shuttle.yaml") 146 | 147 | var file *os.File 148 | file, err = os.Open(configPath) 149 | if err != nil { 150 | if os.IsNotExist(err) { 151 | if startPath == "" || startPath == "/" { 152 | err = errShuttleFileNotFound 153 | break 154 | } 155 | 156 | if strictConfigLookup { 157 | err = errShuttleFileNotFound 158 | break 159 | } 160 | 161 | startPath = removeLastDirectory(startPath) 162 | continue 163 | } 164 | break 165 | } 166 | 167 | return file, nil 168 | } 169 | 170 | return nil, err 171 | } 172 | 173 | func removeLastDirectory(projectPath string) string { 174 | parts := strings.Split(projectPath, "/") 175 | 176 | newProjectPath := strings.Join(parts[0:len(parts)-1], "/") 177 | 178 | // when handling the root path / the split and join will produce an empty 179 | // string so to keep the absolute path set the root path directly. 180 | if path.IsAbs(projectPath) && newProjectPath == "" { 181 | return "/" 182 | } 183 | 184 | return newProjectPath 185 | } 186 | -------------------------------------------------------------------------------- /cmd/template.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/spf13/cobra" 13 | 14 | tmplFuncs "github.com/lunarway/shuttle/pkg/templates" 15 | "github.com/lunarway/shuttle/pkg/ui" 16 | ) 17 | 18 | type context struct { 19 | Vars interface{} 20 | Args map[string]string 21 | PlanPath string 22 | ProjectPath string 23 | } 24 | 25 | func newTemplate(uii *ui.UI, contextProvider contextProvider) *cobra.Command { 26 | var templateOutput, leftDelimArg, rightDelimArg, delimsArg string 27 | var ignoreProjectOverrides bool 28 | 29 | templateCmd := &cobra.Command{ 30 | Use: "template [template]", 31 | Short: "Execute a template", 32 | Args: cobra.MinimumNArgs(1), 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | templateName := args[0] 35 | ctx := cmd.Context() 36 | ctx, _, _, traceEnd := trace(ctx, "template", args) 37 | defer traceEnd() 38 | 39 | projectContext, err := contextProvider() 40 | if err != nil { 41 | return err 42 | } 43 | 44 | namedArgs := map[string]string{} 45 | for _, arg := range args[1:] { 46 | parts := strings.SplitN(arg, "=", 2) 47 | namedArgs[parts[0]] = parts[1] 48 | } 49 | 50 | planPaths := []string{ 51 | path.Join(projectContext.LocalPlanPath, "templates", templateName), 52 | path.Join(projectContext.LocalPlanPath, templateName), 53 | } 54 | 55 | projectPaths := []string{ 56 | path.Join(projectContext.ProjectPath, "templates", templateName), 57 | path.Join(projectContext.ProjectPath, templateName), 58 | } 59 | 60 | var paths []string 61 | if ignoreProjectOverrides { 62 | paths = planPaths 63 | } else { 64 | paths = append(projectPaths, planPaths...) 65 | } 66 | 67 | templatePath := resolveFirstPath(paths) 68 | if templatePath == "" { 69 | return fmt.Errorf("template `%s` not found", templateName) 70 | } 71 | 72 | leftDelim, rightDelim, err := parseDelims(leftDelimArg, rightDelimArg, delimsArg) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | tmpl, err := template.New(templateName). 78 | Delims(leftDelim, rightDelim). 79 | Funcs(tmplFuncs.GetFuncMap()). 80 | ParseFiles(templatePath) 81 | if err != nil { 82 | uii.Errorln("Parse template file failed\nFile: %s", templatePath) 83 | return err 84 | } 85 | 86 | context := context{ 87 | Args: namedArgs, 88 | Vars: projectContext.Config.Variables, 89 | PlanPath: projectContext.LocalPlanPath, 90 | ProjectPath: projectContext.ProjectPath, 91 | } 92 | var output io.Writer 93 | if templateOutput == "" { 94 | output = cmd.OutOrStdout() 95 | } else { 96 | // TODO: This is probably not the right place to initialize the temp dir? 97 | os.MkdirAll(projectContext.TempDirectoryPath, os.ModePerm) 98 | templateOutputPath := path.Join(projectContext.TempDirectoryPath, templateOutput) 99 | file, err := os.Create(templateOutputPath) 100 | if err != nil { 101 | return errors.WithMessagef(err, "create template output file '%s'", templateOutputPath) 102 | } 103 | output = file 104 | } 105 | 106 | err = tmpl.ExecuteTemplate(output, path.Base(templatePath), context) 107 | if err != nil { 108 | uii.Errorln( 109 | "Failed to execute template\nPlan: %s\nProject: %s", 110 | context.PlanPath, 111 | context.ProjectPath, 112 | ) 113 | return err 114 | } 115 | return nil 116 | }, 117 | } 118 | 119 | templateCmd.Flags(). 120 | StringVarP(&templateOutput, "output", "o", "", "Select filename to output file to in temporary directory") 121 | templateCmd.Flags(). 122 | StringVarP(&delimsArg, "delims", "", "", "Select delims for templating. Split by ','. If ',' is in the delims, then use --left-delim and --right-delim instead") 123 | templateCmd.Flags(). 124 | StringVarP(&leftDelimArg, "left-delim", "", "", "Select delims for templating. Defaults to '{{'") 125 | templateCmd.Flags(). 126 | StringVarP(&rightDelimArg, "right-delim", "", "", "Select delims for templating. Defaults to '}}'") 127 | templateCmd.Flags(). 128 | BoolVarP(&ignoreProjectOverrides, "ignore-project-overrides", "", false, "Set flag to ignore template files located in the project folder") 129 | 130 | return templateCmd 131 | } 132 | 133 | func resolveFirstPath(paths []string) string { 134 | for _, templatePath := range paths { 135 | if fileAvailable(templatePath) { 136 | return templatePath 137 | } 138 | } 139 | return "" 140 | } 141 | 142 | func fileAvailable(name string) bool { 143 | if _, err := os.Stat(name); err != nil { 144 | if os.IsNotExist(err) { 145 | return false 146 | } 147 | } 148 | return true 149 | } 150 | 151 | func parseDelims(leftDelimArg, rightDelimArg, delimsArg string) (string, string, error) { 152 | if (leftDelimArg != "" && rightDelimArg == "") || (leftDelimArg == "" && rightDelimArg != "") { 153 | return "", "", fmt.Errorf("--left-delim and --right-delim should always be used together") 154 | } 155 | if delimsArg != "" && (leftDelimArg != "" || rightDelimArg != "") { 156 | return "", "", fmt.Errorf( 157 | "either use --left-delim and --right-delim together or use --delims", 158 | ) 159 | } 160 | if delimsArg != "" { 161 | parts := strings.Split(delimsArg, ",") 162 | if len(parts) != 2 { 163 | return "", "", fmt.Errorf( 164 | "--delims should have exactly 2 values split by ',' but value was '%s'", 165 | delimsArg, 166 | ) 167 | } 168 | return parts[0], parts[1], nil 169 | } 170 | if leftDelimArg != "" && rightDelimArg != "" { 171 | return leftDelimArg, rightDelimArg, nil 172 | } 173 | return "{{", "}}", nil 174 | } 175 | -------------------------------------------------------------------------------- /pkg/executors/help_test.go: -------------------------------------------------------------------------------- 1 | package executors_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/lunarway/shuttle/pkg/config" 9 | "github.com/lunarway/shuttle/pkg/executors" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test(t *testing.T) { 14 | scriptMap := func(args ...interface{}) map[string]config.ShuttlePlanScript { 15 | m := make(map[string]config.ShuttlePlanScript) 16 | for i := 0; i < len(args); i = i + 2 { 17 | m[args[i].(string)] = args[i+1].(config.ShuttlePlanScript) 18 | } 19 | return m 20 | } 21 | scripts := func(description string, args ...config.ShuttleScriptArgs) config.ShuttlePlanScript { 22 | return config.ShuttlePlanScript{ 23 | Description: description, 24 | Args: args, 25 | } 26 | } 27 | arg := func(name string, required bool, description string) config.ShuttleScriptArgs { 28 | return config.ShuttleScriptArgs{ 29 | Name: name, 30 | Required: required, 31 | Description: description, 32 | } 33 | } 34 | tt := []struct { 35 | name string 36 | scripts map[string]config.ShuttlePlanScript 37 | script string 38 | err error 39 | output string 40 | }{ 41 | { 42 | name: "no scripts", 43 | scripts: nil, 44 | script: "test", 45 | err: errors.New("unrecognized script"), 46 | }, 47 | { 48 | name: "no script matches", 49 | scripts: scriptMap("build", scripts("build stuff")), 50 | script: "test", 51 | err: errors.New("unrecognized script"), 52 | }, 53 | { 54 | name: "script without arguments", 55 | scripts: scriptMap("build", scripts("A script to build stuff")), 56 | script: "build", 57 | output: `A script to build stuff 58 | 59 | `, 60 | }, 61 | { 62 | name: "script with argument", 63 | scripts: scriptMap( 64 | "test", 65 | scripts("A script to test stuff", arg("long", false, "Run long running tests")), 66 | ), 67 | script: "test", 68 | output: `A script to test stuff 69 | 70 | Available arguments: 71 | long Run long running tests 72 | `, 73 | }, 74 | { 75 | name: "script with required argument", 76 | scripts: scriptMap( 77 | "test", 78 | scripts("A script to test stuff", arg("long", true, "Run long running tests")), 79 | ), 80 | script: "test", 81 | output: `A script to test stuff 82 | 83 | Available arguments: 84 | long (required) Run long running tests 85 | `, 86 | }, 87 | { 88 | name: "script with multiple arguments", 89 | scripts: scriptMap( 90 | "test", 91 | scripts( 92 | "A script to test stuff", 93 | arg("long", true, "Run long running tests"), 94 | arg("short", false, "Run short tests"), 95 | ), 96 | ), 97 | script: "test", 98 | output: `A script to test stuff 99 | 100 | Available arguments: 101 | long (required) Run long running tests 102 | short Run short tests 103 | `, 104 | }, 105 | } 106 | for _, tc := range tt { 107 | t.Run(tc.name, func(t *testing.T) { 108 | output := bytes.Buffer{} 109 | 110 | err := executors.Help(tc.scripts, tc.script, &output, "") 111 | 112 | if tc.err != nil { 113 | assert.EqualError(t, err, tc.err.Error(), "output error not as expected") 114 | } else { 115 | assert.NoError(t, err, "no output error expected") 116 | } 117 | assert.Equal(t, tc.output, output.String(), "output not as expected") 118 | }) 119 | } 120 | } 121 | 122 | func TestHelp_customTemplate(t *testing.T) { 123 | scriptMap := func(args ...interface{}) map[string]config.ShuttlePlanScript { 124 | m := make(map[string]config.ShuttlePlanScript) 125 | for i := 0; i < len(args); i = i + 2 { 126 | m[args[i].(string)] = args[i+1].(config.ShuttlePlanScript) 127 | } 128 | return m 129 | } 130 | scripts := func(description string, args ...config.ShuttleScriptArgs) config.ShuttlePlanScript { 131 | return config.ShuttlePlanScript{ 132 | Description: description, 133 | Args: args, 134 | } 135 | } 136 | arg := func(name string, required bool, description string) config.ShuttleScriptArgs { 137 | return config.ShuttleScriptArgs{ 138 | Name: name, 139 | Required: required, 140 | Description: description, 141 | } 142 | } 143 | tt := []struct { 144 | name string 145 | scripts map[string]config.ShuttlePlanScript 146 | script string 147 | template string 148 | err error 149 | output string 150 | }{ 151 | { 152 | name: "script without arguments", 153 | scripts: scriptMap("build", scripts("A script to build stuff")), 154 | script: "build", 155 | template: `{{.Args}}`, 156 | output: `[]`, 157 | }, 158 | { 159 | name: "ranging args", 160 | scripts: scriptMap( 161 | "test", 162 | scripts("A script to test stuff", arg("long", false, "Run long running tests")), 163 | ), 164 | script: "test", 165 | template: `{{- range $i, $arg := .Args -}}{{$arg.Name}}{{end}}`, 166 | output: `long`, 167 | }, 168 | { 169 | name: "invalid template", 170 | scripts: scriptMap( 171 | "test", 172 | scripts("A script to test stuff", arg("long", true, "Run long running tests")), 173 | ), 174 | script: "test", 175 | template: `{{.Args`, 176 | err: errors.New("invalid template: template: runHelp:1: unclosed action"), 177 | }, 178 | } 179 | for _, tc := range tt { 180 | t.Run(tc.name, func(t *testing.T) { 181 | output := bytes.Buffer{} 182 | 183 | err := executors.Help(tc.scripts, tc.script, &output, tc.template) 184 | 185 | if tc.err != nil { 186 | assert.EqualError(t, err, tc.err.Error(), "output error not as expected") 187 | } else { 188 | assert.NoError(t, err, "no output error expected") 189 | } 190 | assert.Equal(t, tc.output, output.String(), "output not as expected") 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /pkg/executors/golang/codegen/mod_file_test.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestContainsModule(t *testing.T) { 11 | t.Parallel() 12 | 13 | sut := actionsModFile{ 14 | content: []string{ 15 | "someModuleName", 16 | "someOtherModuleName", 17 | " someSpacedModule", 18 | "package somePackaged", 19 | "module someModModule", 20 | "require someRequireModule", 21 | }, 22 | } 23 | 24 | t.Run("contains a module name", func(t *testing.T) { 25 | actual := sut.containsModule("someModuleName") 26 | 27 | assert.True(t, actual) 28 | }) 29 | 30 | t.Run("does not contain module", func(t *testing.T) { 31 | actual := sut.containsModule("someNonExistingModule") 32 | 33 | assert.False(t, actual) 34 | }) 35 | 36 | t.Run("spaced matches", func(t *testing.T) { 37 | actual := sut.containsModule("someSpacedModule") 38 | 39 | assert.True(t, actual) 40 | }) 41 | 42 | t.Run("packaged matches", func(t *testing.T) { 43 | actual := sut.containsModule("somePackaged") 44 | 45 | assert.True(t, actual) 46 | }) 47 | 48 | t.Run("modded module matches", func(t *testing.T) { 49 | actual := sut.containsModule("someModModule") 50 | 51 | assert.True(t, actual) 52 | }) 53 | 54 | t.Run("required module matches", func(t *testing.T) { 55 | actual := sut.containsModule("someRequireModule") 56 | 57 | assert.True(t, actual) 58 | }) 59 | 60 | t.Run("case sensitive doesn't match", func(t *testing.T) { 61 | actual := sut.containsModule("SOMEMODULENAME") 62 | 63 | assert.False(t, actual) 64 | }) 65 | } 66 | 67 | func TestReplaceModulePath(t *testing.T) { 68 | t.Parallel() 69 | 70 | createSut := func() actionsModFile { 71 | 72 | modFileContent := `module actions 73 | 74 | require ( 75 | root_workspace v0.0.0 76 | subpackage v0.0.0 77 | othersubpackage v0.0.0 78 | ) 79 | 80 | go 1.21.4 81 | 82 | replace othersubpackage => ../../../../othersubpackage` 83 | 84 | return actionsModFile{ 85 | path: "/some-path/some-other-path", 86 | content: strings.Split(modFileContent, "\n"), 87 | } 88 | 89 | } 90 | 91 | t.Run("module matches, not replaced already", func(t *testing.T) { 92 | sut := createSut() 93 | 94 | expected := `module actions 95 | 96 | require ( 97 | root_workspace v0.0.0 98 | subpackage v0.0.0 99 | othersubpackage v0.0.0 100 | ) 101 | 102 | go 1.21.4 103 | 104 | replace othersubpackage => ../../../../othersubpackage 105 | 106 | replace subpackage => ../subpackage` 107 | 108 | sut.replaceModulePath("some-other-path/newpath", module{ 109 | name: "subpackage", 110 | path: "subpackage", 111 | }) 112 | 113 | assert.Equal(t, expected, strings.Join(sut.content, "\n")) 114 | }) 115 | 116 | t.Run("module matches, not replaced already, deeper nesting", func(t *testing.T) { 117 | sut := createSut() 118 | 119 | expected := `module actions 120 | 121 | require ( 122 | root_workspace v0.0.0 123 | subpackage v0.0.0 124 | othersubpackage v0.0.0 125 | ) 126 | 127 | go 1.21.4 128 | 129 | replace othersubpackage => ../../../../othersubpackage 130 | 131 | replace subpackage => subpackage` 132 | 133 | sut.replaceModulePath("/some-path", module{ 134 | name: "subpackage", 135 | path: "subpackage", 136 | }) 137 | 138 | assert.Equal(t, expected, strings.Join(sut.content, "\n")) 139 | }) 140 | 141 | t.Run("module matches, already replaced already", func(t *testing.T) { 142 | sut := createSut() 143 | 144 | expected := `module actions 145 | 146 | require ( 147 | root_workspace v0.0.0 148 | subpackage v0.0.0 149 | othersubpackage v0.0.0 150 | ) 151 | 152 | go 1.21.4 153 | 154 | replace othersubpackage => ../othersubpackage` 155 | 156 | sut.replaceModulePath("some-other-path/newpath", module{ 157 | name: "othersubpackage", 158 | path: "othersubpackage", 159 | }) 160 | 161 | assert.Equal(t, expected, strings.Join(sut.content, "\n")) 162 | }) 163 | 164 | t.Run("module matches, already replaced already deeper nesting", func(t *testing.T) { 165 | sut := createSut() 166 | 167 | expected := `module actions 168 | 169 | require ( 170 | root_workspace v0.0.0 171 | subpackage v0.0.0 172 | othersubpackage v0.0.0 173 | ) 174 | 175 | go 1.21.4 176 | 177 | replace othersubpackage => othersubpackage` 178 | 179 | sut.replaceModulePath("/some-path", module{ 180 | name: "othersubpackage", 181 | path: "othersubpackage", 182 | }) 183 | 184 | assert.Equal(t, expected, strings.Join(sut.content, "\n")) 185 | }) 186 | } 187 | 188 | func TestSegmentsTo(t *testing.T) { 189 | t.Parallel() 190 | 191 | testCases := []struct { 192 | name string 193 | path string 194 | rootDir string 195 | expected int 196 | }{ 197 | { 198 | name: "empty", 199 | path: "", 200 | rootDir: "", 201 | expected: 0, 202 | }, 203 | { 204 | name: "current dir", 205 | path: "/some-dir/", 206 | rootDir: "/some-dir/", 207 | expected: 0, 208 | }, 209 | { 210 | name: "one level", 211 | path: "/some-dir/some-other-dir/", 212 | rootDir: "/some-dir", 213 | expected: 1, 214 | }, 215 | { 216 | name: "2 level", 217 | path: "/some-dir/some-other-dir/some-third-dir/", 218 | rootDir: "/some-dir", 219 | expected: 2, 220 | }, 221 | { 222 | name: "1 level", 223 | path: "/some-dir/some-other-dir/some-third-dir/", 224 | rootDir: "/some-dir/some-other-dir", 225 | expected: 1, 226 | }, 227 | { 228 | name: "without trailing", 229 | path: "/some-dir/some-third-dir", 230 | rootDir: "/some-dir", 231 | expected: 0, 232 | }, 233 | } 234 | 235 | for _, testCase := range testCases { 236 | t.Run(testCase.name, func(t *testing.T) { 237 | sut := actionsModFile{ 238 | path: testCase.path, 239 | } 240 | 241 | actual := sut.segmentsTo(testCase.rootDir) 242 | 243 | assert.Equal(t, testCase.expected, actual) 244 | }) 245 | } 246 | 247 | } 248 | -------------------------------------------------------------------------------- /pkg/executors/executor.go: -------------------------------------------------------------------------------- 1 | package executors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/lunarway/shuttle/pkg/config" 10 | "github.com/lunarway/shuttle/pkg/errors" 11 | "github.com/lunarway/shuttle/pkg/ui" 12 | ) 13 | 14 | type Registry struct { 15 | executors []Matcher 16 | } 17 | 18 | type ( 19 | Matcher func(config.ShuttleAction) (Executor, bool) 20 | Executor func(context.Context, *ui.UI, ActionExecutionContext) error 21 | ) 22 | 23 | func NewRegistry(executors ...Matcher) *Registry { 24 | return &Registry{ 25 | executors: executors, 26 | } 27 | } 28 | 29 | // ScriptExecutionContext gives context to the execution of a plan script 30 | type ScriptExecutionContext struct { 31 | ScriptName string 32 | Script config.ShuttlePlanScript 33 | Project config.ShuttleProjectContext 34 | Args map[string]string 35 | } 36 | 37 | // ActionExecutionContext gives context to the execution of Actions in a script 38 | type ActionExecutionContext struct { 39 | ScriptContext ScriptExecutionContext 40 | Action config.ShuttleAction 41 | ActionIndex int 42 | } 43 | 44 | // Execute is the command executor for the plan files 45 | func (r *Registry) Execute( 46 | ctx context.Context, 47 | p config.ShuttleProjectContext, 48 | command string, 49 | args map[string]string, 50 | validateArgs bool, 51 | ) error { 52 | script, ok := p.Scripts[command] 53 | if !ok { 54 | return errors.NewExitCode(2, "Script '%s' not found", command) 55 | } 56 | 57 | scriptContext := ScriptExecutionContext{ 58 | ScriptName: command, 59 | Script: script, 60 | Project: p, 61 | Args: args, 62 | } 63 | 64 | for actionIndex, action := range script.Actions { 65 | actionContext := ActionExecutionContext{ 66 | ScriptContext: scriptContext, 67 | Action: action, 68 | ActionIndex: actionIndex, 69 | } 70 | err := r.executeAction(ctx, p.UI, actionContext) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | // validateArguments parses and validates args against available arguments in 79 | // scriptArgs. 80 | // 81 | // All detectable constraints are checked before reporting to the UI. 82 | func validateArguments( 83 | p config.ShuttleProjectContext, 84 | command string, 85 | scriptArgs []config.ShuttleScriptArgs, 86 | args []string, 87 | validateArgs bool, 88 | ) (map[string]string, error) { 89 | var validationErrors []validationError 90 | 91 | namedArgs, parsingErrors := validateArgFormat(args) 92 | validationErrors = append(validationErrors, parsingErrors...) 93 | if validateArgs { 94 | validationErrors = append(validationErrors, validateRequiredArgs(scriptArgs, namedArgs)...) 95 | validationErrors = append(validationErrors, validateUnknownArgs(scriptArgs, namedArgs)...) 96 | } 97 | if len(validationErrors) != 0 { 98 | sortValidationErrors(validationErrors) 99 | var s strings.Builder 100 | s.WriteString("Arguments not valid:\n") 101 | for _, e := range validationErrors { 102 | fmt.Fprintf(&s, " %s\n", e) 103 | } 104 | fmt.Fprintf(&s, "\n%s", expectedArgumentsHelp(command, scriptArgs)) 105 | return nil, errors.NewExitCode(2, s.String()) 106 | } 107 | return namedArgs, nil 108 | } 109 | 110 | type validationError struct { 111 | arg string 112 | err string 113 | } 114 | 115 | func (v validationError) String() string { 116 | return fmt.Sprintf("'%s' %s", v.arg, v.err) 117 | } 118 | 119 | func validateArgFormat(args []string) (map[string]string, []validationError) { 120 | var validationErrors []validationError 121 | namedArgs := map[string]string{} 122 | for _, arg := range args { 123 | parts := strings.SplitN(arg, "=", 2) 124 | if len(parts) < 2 { 125 | validationErrors = append(validationErrors, validationError{ 126 | arg: arg, 127 | err: "not =", 128 | }) 129 | continue 130 | } 131 | namedArgs[parts[0]] = parts[1] 132 | } 133 | return namedArgs, validationErrors 134 | } 135 | 136 | func validateRequiredArgs( 137 | scriptArgs []config.ShuttleScriptArgs, 138 | args map[string]string, 139 | ) []validationError { 140 | var validationErrors []validationError 141 | for _, argSpec := range scriptArgs { 142 | if _, ok := args[argSpec.Name]; argSpec.Required && !ok { 143 | validationErrors = append(validationErrors, validationError{ 144 | arg: argSpec.Name, 145 | err: "not supplied but is required", 146 | }) 147 | } 148 | } 149 | return validationErrors 150 | } 151 | 152 | func validateUnknownArgs( 153 | scriptArgs []config.ShuttleScriptArgs, 154 | args map[string]string, 155 | ) []validationError { 156 | var validationErrors []validationError 157 | for namedArg := range args { 158 | found := false 159 | for _, arg := range scriptArgs { 160 | if arg.Name == namedArg { 161 | found = true 162 | break 163 | } 164 | } 165 | if !found { 166 | validationErrors = append(validationErrors, validationError{ 167 | arg: namedArg, 168 | err: "unknown", 169 | }) 170 | } 171 | } 172 | return validationErrors 173 | } 174 | 175 | func sortValidationErrors(errs []validationError) { 176 | sort.Slice(errs, func(i, j int) bool { 177 | return errs[i].arg < errs[j].arg 178 | }) 179 | } 180 | 181 | func expectedArgumentsHelp(command string, args []config.ShuttleScriptArgs) string { 182 | var s strings.Builder 183 | fmt.Fprintf(&s, "Script '%s' accepts ", command) 184 | if len(args) == 0 { 185 | s.WriteString("no arguments.") 186 | return s.String() 187 | } 188 | s.WriteString("the following arguments:") 189 | for _, a := range args { 190 | fmt.Fprintf(&s, "\n %s", a) 191 | } 192 | return s.String() 193 | } 194 | 195 | func (r *Registry) executeAction( 196 | ctx context.Context, 197 | ui *ui.UI, 198 | context ActionExecutionContext, 199 | ) error { 200 | for _, executor := range r.executors { 201 | handler, ok := executor(context.Action) 202 | if ok { 203 | return handler(ctx, ui, context) 204 | } 205 | } 206 | 207 | panic( 208 | fmt.Sprintf( 209 | "Could not find an executor for %v.actions[%v]!", 210 | context.ScriptContext.ScriptName, 211 | context.ActionIndex, 212 | ), 213 | ) 214 | } 215 | -------------------------------------------------------------------------------- /cmd/run_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestRun(t *testing.T) { 11 | pwd, err := os.Getwd() 12 | if err != nil { 13 | t.Fatalf("Failed to get working directory: %v", err) 14 | } 15 | 16 | testCases := []testCase{ 17 | { 18 | name: "std out echo", 19 | input: args("-p", "testdata/project", "run", "hello_stdout"), 20 | stdoutput: "Hello stdout\n", 21 | erroutput: "", 22 | err: nil, 23 | }, 24 | { 25 | name: "std err echo", 26 | input: args("-p", "testdata/project", "run", "hello_stderr"), 27 | stdoutput: "", 28 | erroutput: "Hello stderr\n", 29 | err: nil, 30 | }, 31 | { 32 | name: "exit 0", 33 | input: args("-p", "testdata/project", "run", "exit_0"), 34 | stdoutput: "", 35 | erroutput: "", 36 | err: nil, 37 | }, 38 | { 39 | name: "exit 1", 40 | input: args("-p", "testdata/project", "run", "exit_1"), 41 | stdoutput: ``, 42 | erroutput: "Error: exit code 4 - Failed executing script `exit_1`: shell script `exit 1`\nExit code: 1\n", 43 | err: errors.New( 44 | "exit code 4 - Failed executing script `exit_1`: shell script `exit 1`\nExit code: 1", 45 | ), 46 | }, 47 | { 48 | name: "project with absolute path", 49 | input: args("-p", filepath.Join(pwd, "testdata/project"), "run", "hello_stdout"), 50 | stdoutput: "Hello stdout\n", 51 | erroutput: "", 52 | err: nil, 53 | }, 54 | { 55 | name: "project without shuttle.yaml", 56 | input: args("-p", "testdata/base", "run", "hello_stdout"), 57 | stdoutput: "", 58 | erroutput: "Error: shuttle run is not available in this context. To use shuttle run you need to be in a project with a shuttle.yaml file\n", 59 | err: errors.New( 60 | "shuttle run is not available in this context. To use shuttle run you need to be in a project with a shuttle.yaml file", 61 | ), 62 | }, 63 | { 64 | name: "script fails when required argument is missing", 65 | input: args("-p", "testdata/project", "run", "required_arg"), 66 | stdoutput: "", 67 | erroutput: `Error: required flag(s) "foo" not set 68 | `, 69 | err: errors.New(`required flag(s) "foo" not set`), 70 | }, 71 | { 72 | name: "script succeeds with required argument", 73 | input: args("-p", "testdata/project", "run", "required_arg", "foo=bar"), 74 | stdoutput: "bar\n", 75 | erroutput: "", 76 | err: nil, 77 | }, 78 | { 79 | name: "script succeeds with required argument with hash in value", 80 | input: args("-p", "testdata/project", "run", "required_arg", "foo=bar="), 81 | stdoutput: "bar=\n", 82 | erroutput: "", 83 | err: nil, 84 | }, 85 | { 86 | name: "script succeeds with required argument missing and validation disabled", 87 | input: args("-p", "testdata/project", "run", "--validate=false", "required_arg"), 88 | stdoutput: "\n", 89 | erroutput: "", 90 | err: nil, 91 | }, 92 | { 93 | name: "script fails when validation is disabled and argument is not in valid format", 94 | input: args( 95 | "-p", 96 | "testdata/project", 97 | "run", 98 | "--validate=false", 99 | "required_arg", 100 | "foo", 101 | ), 102 | stdoutput: "\n", 103 | erroutput: "", 104 | err: nil, 105 | }, 106 | { 107 | name: "script fails on unkown argument", 108 | input: args("-p", "testdata/project", "run", "required_arg", "foo=bar", "--a b"), 109 | stdoutput: "", 110 | erroutput: `Error: unknown flag: --a b 111 | `, 112 | err: errors.New(`unknown flag: --a b`), 113 | }, 114 | { 115 | name: "branched git plan", 116 | input: args("-p", "testdata/project-git-branched", "run", "say"), 117 | stdoutput: "something clever\n", 118 | erroutput: "Cloning plan https://github.com/lunarway/shuttle-example-go-plan.git\n", 119 | err: nil, 120 | }, 121 | { 122 | name: "git plan", 123 | input: args("-p", "testdata/project-git", "run", "say"), 124 | stdoutput: "something masterly\n", 125 | erroutput: "Cloning plan https://github.com/lunarway/shuttle-example-go-plan.git\n", 126 | initErr: errors.New("something"), 127 | }, 128 | { 129 | name: "tagged git plan", 130 | input: args("-p", "testdata/project-git", "--plan", "#tagged", "run", "say"), 131 | stdoutput: "something tagged\n", 132 | erroutput: "\x1b[032;1mOverload git plan branch/tag/sha with tagged\x1b[0m\nCloning plan https://github.com/lunarway/shuttle-example-go-plan.git\n", 133 | err: nil, 134 | }, 135 | { 136 | name: "Local project", 137 | input: args( 138 | "--project", 139 | "./testdata/project-local/service", 140 | "--plan", 141 | "./testdata/project-local/plan", 142 | "run", 143 | "hello-plan", 144 | ), 145 | stdoutput: "Hello from plan\n", 146 | erroutput: "Using overloaded plan ./testdata/project-local/plan\n", 147 | err: nil, 148 | }, 149 | // FIXME: This case actually hits a bug as we do not support fetching specific commits 150 | // { 151 | // name: "sha git plan", 152 | // input: args("-p", "testdata/project-git", "--plan", "#df4630118c7dfb594b4de903621681e677534638", "run", "say"), 153 | // stdoutput: "\x1b[032;1mOverload git plan branch/tag/sha with 2b52c21\x1b[0m\nCloning plan https://github.com/lunarway/shuttle-example-go-plan.git\nsomething minor\n", 154 | // erroutput: "", 155 | // err: nil, 156 | // }, 157 | } 158 | executeTestCases(t, testCases) 159 | 160 | testContainsCases := []testCase{ 161 | { 162 | name: "Local project fail", 163 | input: args( 164 | "--project", 165 | "./testdata/project-local/service", 166 | "--plan", 167 | "./testdata/wrong-project-local/plan", 168 | "run", 169 | "hello-plan", 170 | ), 171 | stdoutput: "", 172 | erroutput: "shuttle/cmd/testdata/wrong-project-local/plan: no such file or directory", 173 | err: errors.New( 174 | "shuttle/cmd/testdata/wrong-project-local/plan: no such file or directory", 175 | ), 176 | initErr: errors.New( 177 | `failed to copy plan to .shuttle/plan, make sure the upstream plan exists`, 178 | ), 179 | }, 180 | } 181 | executeTestContainsCases(t, testContainsCases) 182 | } 183 | --------------------------------------------------------------------------------