├── assets └── demo.gif ├── .gitignore ├── Makefile ├── cue.mod └── module.cue ├── Dockerfile ├── SECURITY.md ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── examples ├── simple │ ├── stack.cue │ └── builder.cue ├── yaml │ ├── builder.cue │ └── stack.devx.yaml ├── buckets │ ├── stack.cue │ └── builders.cue ├── helm │ ├── stack.cue │ └── builder.cue ├── env_differences │ ├── builder.cue │ └── stack.cue ├── shared_volume │ ├── builder.cue │ └── stack.cue ├── secrets │ ├── stack.cue │ └── builder.cue ├── vanilla-drivers │ └── stack.cue └── v2alpha1 │ └── stack.cue ├── cmd └── devx │ ├── retire.go │ ├── reserve.go │ ├── xray.go │ ├── run.go │ ├── diff.go │ ├── build.go │ ├── logging.go │ ├── auth.go │ ├── project.go │ └── main.go ├── pkg ├── drivers │ ├── drivers.go │ ├── gitlab.go │ ├── json.go │ ├── yaml.go │ ├── compose.go │ ├── github.go │ ├── terraform.go │ └── kubernetes.go ├── xray │ ├── scan.go │ └── xray.go ├── policy │ └── policy.go ├── stack │ ├── stack_test.go │ └── stack.go ├── stackbuilder │ ├── flow_test.go │ ├── flow.go │ └── stackbuilder.go ├── gitrepo │ └── git.go ├── taskfile │ └── run.go ├── client │ └── client.go ├── auth │ └── auth.go ├── catalog │ └── catalog.go ├── utils │ └── utils.go └── project │ └── project.go ├── .goreleaser.yaml ├── go.mod ├── LICENSE.md ├── README.md ├── LICENSE └── go.sum /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stakpak/devx/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | experiments 2 | bin 3 | examples/cue.mod 4 | dist/ 5 | cue.mod/pkg 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | @go build -o ./bin/ ./cmd/devx 4 | 5 | .PHONY: test 6 | test: 7 | @go test ./... --race --cover 8 | -------------------------------------------------------------------------------- /cue.mod/module.cue: -------------------------------------------------------------------------------- 1 | { 2 | module: "stakpak.dev" 3 | cue: lang: "v0.6.0-alpha.1" 4 | deps: "github.com/stakpak/devx-catalog": {} 5 | } 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.17.0 2 | RUN apk add git>=2.38 3 | COPY devx /usr/bin/devx 4 | RUN mkdir /app 5 | WORKDIR /app 6 | ENTRYPOINT ["devx"] 7 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 0.2.x | :white_check_mark: | 8 | | < 2.0 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Please report vulnerabilities by e-mail to the following address: 13 | + contact@stakpak.dev 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 13 | - uses: actions/setup-go@v3 14 | with: 15 | go-version: ">=1.18" 16 | cache: true 17 | - run: make test 18 | -------------------------------------------------------------------------------- /examples/simple/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | ) 7 | 8 | stack: v1.#Stack & { 9 | components: { 10 | cowsay: { 11 | traits.#Workload 12 | containers: default: { 13 | image: "docker/whalesay" 14 | command: ["cowsay"] 15 | args: ["Hello DevX!"] 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/yaml/builder.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/transformers/compose" 6 | ) 7 | 8 | builders: v1.#StackBuilder & { 9 | dev: { 10 | mainflows: [ 11 | { 12 | pipeline: [compose.#AddComposeService] 13 | }, 14 | { 15 | pipeline: [compose.#ExposeComposeService] 16 | }, 17 | { 18 | pipeline: [compose.#AddComposePostgres] 19 | }, 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/buckets/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | ) 7 | 8 | stack: v1.#Stack 9 | stack: { 10 | $metadata: stack: "myapp" 11 | components: { 12 | bucket: { 13 | traits.#S3CompatibleBucket 14 | s3: { 15 | prefix: "guku-io-" 16 | name: "my-bucket-123" 17 | versioning: false 18 | objectLocking: false 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/simple/builder.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/transformers/compose" 6 | ) 7 | 8 | builders: v1.#StackBuilder & { 9 | dev: { 10 | mainflows: [ 11 | v1.#Flow & { 12 | pipeline: [ 13 | compose.#AddComposeService & {}, 14 | ] 15 | }, 16 | v1.#Flow & { 17 | pipeline: [ 18 | compose.#ExposeComposeService & {}, 19 | ] 20 | }, 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cmd/devx/retire.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/stakpak/devx/pkg/client" 6 | ) 7 | 8 | var retireCmd = &cobra.Command{ 9 | Use: "retire [build id]", 10 | Short: "Retire build resources", 11 | Args: cobra.ExactArgs(1), 12 | RunE: func(cmd *cobra.Command, args []string) error { 13 | if err := client.Retire(args[0], server); err != nil { 14 | return err 15 | } 16 | return nil 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /examples/helm/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | ) 7 | 8 | stack: v1.#Stack & { 9 | components: { 10 | cowsay: { 11 | traits.#Helm 12 | helm: { 13 | k8s: version: minor: 19 14 | url: "stakpak.dev" 15 | chart: "guku" 16 | version: "v1" 17 | namespace: "somethingelse" 18 | values: { 19 | bla: 123 20 | } 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd/devx/reserve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/stakpak/devx/pkg/client" 6 | ) 7 | 8 | var reserveCmd = &cobra.Command{ 9 | Use: "reserve [build id]", 10 | Short: "Reserve build resources", 11 | Args: cobra.ExactArgs(1), 12 | RunE: func(cmd *cobra.Command, args []string) error { 13 | if err := client.Reserve(args[0], server, dryRun); err != nil { 14 | return err 15 | } 16 | return nil 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /examples/env_differences/builder.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/transformers/compose" 6 | ) 7 | 8 | builders: v1.#StackBuilder & { 9 | dev: { 10 | mainflows: [ 11 | v1.#Flow & { 12 | pipeline: [ 13 | compose.#AddComposeService & {}, 14 | ] 15 | }, 16 | ] 17 | } 18 | prod: { 19 | mainflows: [ 20 | v1.#Flow & { 21 | pipeline: [ 22 | compose.#AddComposeService & {}, 23 | ] 24 | }, 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/env_differences/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | ) 7 | 8 | stack: v1.#Stack & { 9 | components: { 10 | cowsay: { 11 | traits.#Workload 12 | containers: default: { 13 | image: "docker/whalesay" 14 | command: ["cowsay"] 15 | } 16 | } 17 | } 18 | } 19 | 20 | builders: { 21 | dev: additionalComponents: cowsay: containers: default: args: ["Hello DEV!"] 22 | prod: additionalComponents: cowsay: containers: default: args: ["Hello Prod!"] 23 | } 24 | -------------------------------------------------------------------------------- /cmd/devx/xray.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cuelang.org/go/cue/errors" 7 | "github.com/spf13/cobra" 8 | "github.com/stakpak/devx/pkg/xray" 9 | ) 10 | 11 | var xrayCmd = &cobra.Command{ 12 | Use: "ray", 13 | Short: "Let devx analyze your source code and auto-generate a stack", 14 | Aliases: []string{"x", "detect"}, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | if err := xray.Run(configDir, server); err != nil { 17 | return fmt.Errorf(errors.Details(err, nil)) 18 | } 19 | return nil 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /examples/shared_volume/builder.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/transformers/compose" 6 | ) 7 | 8 | builders: v1.#StackBuilder & { 9 | dev: { 10 | mainflows: [ 11 | v1.#Flow & { 12 | pipeline: [ 13 | compose.#AddComposeService & {}, 14 | ] 15 | }, 16 | v1.#Flow & { 17 | pipeline: [ 18 | compose.#ExposeComposeService & {}, 19 | ] 20 | }, 21 | v1.#Flow & { 22 | pipeline: [ 23 | compose.#AddComposeVolume & {}, 24 | ] 25 | }, 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cmd/devx/run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/stakpak/devx/pkg/taskfile" 6 | ) 7 | 8 | var runFlags taskfile.RunFlags 9 | 10 | var runCmd = &cobra.Command{ 11 | Use: "run [environment] [task...]", 12 | Short: "Run taskfile.dev tasks", 13 | Args: cobra.MinimumNArgs(1), 14 | RunE: func(cmd *cobra.Command, args []string) error { 15 | doubleDashPos := cmd.ArgsLenAtDash() 16 | if err := taskfile.Run(configDir, buildersPath, server, runFlags, args[0], doubleDashPos, args[1:]); err != nil { 17 | return err 18 | } 19 | return nil 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /examples/helm/builder.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/transformers/argocd" 6 | terraform "stakpak.dev/devx/v1/transformers/terraform/helm" 7 | ) 8 | 9 | builders: v1.#StackBuilder & { 10 | dev: { 11 | mainflows: [ 12 | v1.#Flow & { 13 | pipeline: [ 14 | argocd.#AddHelmRelease & {helm: namespace: string | *"default"}, 15 | ] 16 | }, 17 | ] 18 | } 19 | prod: { 20 | mainflows: [ 21 | v1.#Flow & { 22 | pipeline: [ 23 | terraform.#AddHelmRelease & {helm: namespace: "somethingelse"}, 24 | ] 25 | }, 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cmd/devx/diff.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cuelang.org/go/cue/errors" 7 | "github.com/spf13/cobra" 8 | "github.com/stakpak/devx/pkg/client" 9 | ) 10 | 11 | var diffCmd = &cobra.Command{ 12 | Use: "diff [environment] [target git revision]", 13 | Short: "Diff the current stack with that @ target (e.g. HEAD, commit, tag).", 14 | Args: cobra.ExactArgs(2), 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | if err := client.Diff(args[1], args[0], configDir, stackPath, buildersPath, server, noStrict); err != nil { 17 | return fmt.Errorf(errors.Details(err, nil)) 18 | } 19 | return nil 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /cmd/devx/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cuelang.org/go/cue/errors" 7 | "github.com/spf13/cobra" 8 | "github.com/stakpak/devx/pkg/client" 9 | ) 10 | 11 | var buildCmd = &cobra.Command{ 12 | Use: "build [environment]", 13 | Short: "Build DevX magic for the specified environment", 14 | Args: cobra.ExactArgs(1), 15 | Aliases: []string{"do"}, 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := client.Run(args[0], configDir, stackPath, buildersPath, reserve, dryRun, server, noStrict, stdout); err != nil { 18 | return fmt.Errorf(errors.Details(err, nil)) 19 | } 20 | return nil 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /cmd/devx/logging.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type PlainFormatter struct { 12 | } 13 | 14 | func (f *PlainFormatter) Format(entry *log.Entry) ([]byte, error) { 15 | return []byte(fmt.Sprintf("%s\n", entry.Message)), nil 16 | } 17 | 18 | func setupLogging(cmd *cobra.Command, args []string) { 19 | color.NoColor = noColor 20 | 21 | if verbosity == "debug" { 22 | log.Info("Debug logs enabled") 23 | log.SetLevel(log.DebugLevel) 24 | log.SetFormatter(&log.TextFormatter{}) 25 | } else { 26 | plainFormatter := new(PlainFormatter) 27 | log.SetFormatter(plainFormatter) 28 | } 29 | 30 | if verbosity == "error" { 31 | log.SetLevel(log.ErrorLevel) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/secrets/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | ) 7 | 8 | stack: v1.#Stack & { 9 | components: { 10 | commonSecrets: { 11 | traits.#Secret 12 | secrets: apiKey: { 13 | name: "apikey-a" 14 | version: "4" 15 | } 16 | } 17 | cowsay: { 18 | traits.#Workload 19 | traits.#Volume 20 | containers: default: { 21 | image: "docker/whalesay" 22 | command: ["cowsay"] 23 | args: ["Hello DevX!"] 24 | env: { 25 | // you can use secrets directly in an env var 26 | API_KEY: commonSecrets.secrets.apiKey 27 | SOMETHING: "bla" 28 | } 29 | mounts: [ 30 | { 31 | // or you can mount secrets as files via volumes 32 | volume: volumes.default 33 | path: "secrets/file" 34 | }, 35 | ] 36 | } 37 | volumes: default: secret: commonSecrets.secrets.apiKey 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cmd/devx/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/stakpak/devx/pkg/auth" 6 | ) 7 | 8 | var loginCmd = &cobra.Command{ 9 | Use: "auth", 10 | Short: "Authenticate to DevX Server", 11 | Aliases: []string{"login"}, 12 | RunE: func(cmd *cobra.Command, args []string) error { 13 | if err := auth.Login(server); err != nil { 14 | return err 15 | } 16 | return nil 17 | }, 18 | } 19 | 20 | var clearCmd = &cobra.Command{ 21 | Use: "clear", 22 | Short: "Clear cached credentials", 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | if err := auth.Clear(server); err != nil { 25 | return err 26 | } 27 | return nil 28 | }, 29 | } 30 | 31 | var infoCmd = &cobra.Command{ 32 | Use: "info", 33 | Short: "Display session information", 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | if err := auth.Info(server); err != nil { 36 | return err 37 | } 38 | return nil 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /examples/shared_volume/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | ) 7 | 8 | stack: v1.#Stack & { 9 | components: { 10 | sharedvol: { 11 | traits.#Volume 12 | volumes: default: persistent: "bazo" 13 | } 14 | cowsay: { 15 | traits.#Workload 16 | containers: default: { 17 | image: "docker/whalesay" 18 | command: ["cowsay"] 19 | args: ["Hello DevX!"] 20 | mounts: [ 21 | { 22 | volume: sharedvol.volumes.default 23 | path: "/data/dir" 24 | readOnly: false 25 | }, 26 | ] 27 | } 28 | } 29 | cowsayagain: { 30 | traits.#Workload 31 | containers: default: { 32 | image: "docker/whalesay" 33 | command: ["cowsay"] 34 | args: ["Hello again!"] 35 | mounts: [ 36 | { 37 | volume: sharedvol.volumes.default 38 | path: "/data/dir" 39 | readOnly: true 40 | }, 41 | ] 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/vanilla-drivers/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v2alpha1" 6 | ) 7 | 8 | stack: v1.#Stack & { 9 | components: { 10 | app1: yo: 1 11 | app2: yo: 1 12 | } 13 | } 14 | 15 | #YAMLResource: { 16 | $metadata: labels: { 17 | driver: "yaml" 18 | type: "" 19 | } 20 | ... 21 | } 22 | #JSONResource: { 23 | $metadata: labels: { 24 | driver: "json" 25 | type: "" 26 | } 27 | ... 28 | } 29 | builders: v2alpha1.#Environments & { 30 | config: v2alpha1.#StackBuilder & { 31 | drivers: { 32 | yaml: output: { 33 | dir: ["."] 34 | file: "conf.yml" 35 | } 36 | json: output: { 37 | dir: ["."] 38 | file: "conf.json" 39 | } 40 | } 41 | flows: "yaml": pipeline: [{ 42 | $metadata: _ 43 | yo: _ 44 | $resources: { 45 | config: #YAMLResource & { 46 | "\($metadata.id)": yo 47 | } 48 | other: #JSONResource & { 49 | "\($metadata.id)": yo 50 | } 51 | } 52 | }] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - run: git fetch --force --tags 20 | - name: Docker Login 21 | uses: docker/login-action@v2 22 | with: 23 | registry: ghcr.io 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - uses: actions/setup-go@v3 27 | with: 28 | go-version: ">=1.18" 29 | cache: true 30 | - uses: goreleaser/goreleaser-action@v2 31 | with: 32 | distribution: goreleaser 33 | version: latest 34 | args: release --clean 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /examples/yaml/stack.devx.yaml: -------------------------------------------------------------------------------- 1 | import: 2 | v1: "stakpak.dev/devx/v1" 3 | traits: "stakpak.dev/devx/v1/traits" 4 | 5 | stack: 6 | $schema: 7 | - v1.#Stack 8 | components: 9 | cowsay: 10 | $traits: 11 | - traits.#Workload 12 | - traits.#Exposable 13 | endpoints: 14 | default: 15 | ports: 16 | - port: 8000 17 | - port: 9000 18 | containers: 19 | default: 20 | image: "docker/whalesay" 21 | command: ["cowsay"] 22 | env: 23 | DB_HOST: ${ db.host } 24 | ENV_GEN: ${ string @guku(generate) } 25 | db: 26 | $traits: 27 | - traits.#Postgres 28 | version: "9.8" 29 | 30 | builders: 31 | prod: 32 | additionalComponents: 33 | cowsay: 34 | containers: 35 | default: 36 | args: ["Hello prod", "again"] 37 | dev: 38 | additionalComponents: 39 | cowsay: 40 | containers: 41 | default: 42 | args: ["Hello dev"] 43 | -------------------------------------------------------------------------------- /examples/secrets/builder.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/transformers/compose" 6 | tfaws "stakpak.dev/devx/v1/transformers/terraform/aws" 7 | k8s "stakpak.dev/devx/v1/transformers/kubernetes" 8 | ) 9 | 10 | builders: v1.#StackBuilder & { 11 | dev: { 12 | mainflows: [ 13 | v1.#Flow & { 14 | pipeline: [compose.#AddComposeService] 15 | }, 16 | v1.#Flow & { 17 | pipeline: [compose.#ExposeComposeService] 18 | }, 19 | v1.#Flow & { 20 | pipeline: [compose.#AddComposeVolume] 21 | }, 22 | { 23 | // allow secrets to not be fulfilled in strict mode 24 | match: traits: Secret: null 25 | }, 26 | ] 27 | } 28 | prod: { 29 | mainflows: [ 30 | v1.#Flow & { 31 | pipeline: [k8s.#AddDeployment] 32 | }, 33 | v1.#Flow & { 34 | pipeline: [k8s.#AddService] 35 | }, 36 | v1.#Flow & { 37 | pipeline: [k8s.#AddWorkloadVolumes] 38 | }, 39 | v1.#Flow & { 40 | pipeline: [tfaws.#AddSSMSecretParameter] 41 | }, 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/drivers/drivers.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "cuelang.org/go/cue" 5 | "github.com/stakpak/devx/pkg/stack" 6 | "github.com/stakpak/devx/pkg/stackbuilder" 7 | ) 8 | 9 | type Driver interface { 10 | match(resource cue.Value) bool 11 | ApplyAll(stack *stack.Stack, stdout bool) error 12 | } 13 | 14 | // TODO we need to decompose this into DI pattern 15 | func NewDriversMap(environment string, config map[string]stackbuilder.DriverConfig) map[string]Driver { 16 | return map[string]Driver{ 17 | "compose": &ComposeDriver{ 18 | Config: config["compose"], 19 | }, 20 | "terraform": &TerraformDriver{ 21 | Config: config["terraform"], 22 | }, 23 | "kubernetes": &KubernetesDriver{ 24 | Config: config["kubernetes"], 25 | }, 26 | "gitlab": &GitlabDriver{ 27 | Config: config["gitlab"], 28 | }, 29 | "github": &GitHubDriver{ 30 | Config: config["github"], 31 | }, 32 | "yaml": &YAMLDriver{ 33 | Config: config["yaml"], 34 | }, 35 | "json": &JSONDriver{ 36 | Config: config["json"], 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/xray/scan.go: -------------------------------------------------------------------------------- 1 | package xray 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func scan(configDir string) (map[string]string, error) { 10 | overlay := map[string]string{} 11 | err := filepath.Walk(configDir, 12 | func(path string, info os.FileInfo, err error) error { 13 | if err != nil { 14 | return err 15 | } 16 | if info.IsDir() { 17 | return nil 18 | } 19 | 20 | if isFileInScope(path) { 21 | fileBytes, err := os.ReadFile(path) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | overlay[path] = string(fileBytes) 27 | } 28 | return nil 29 | }) 30 | if err != nil { 31 | return overlay, err 32 | } 33 | 34 | return overlay, nil 35 | } 36 | 37 | func isFileInScope(path string) bool { 38 | return strings.HasSuffix(path, ".js") || 39 | strings.HasSuffix(path, ".ts") || 40 | strings.HasSuffix(path, ".py") || 41 | strings.HasSuffix(path, ".go") || 42 | strings.HasSuffix(path, ".rs") || 43 | strings.HasSuffix(path, ".php") || 44 | strings.HasSuffix(path, ".rb") || 45 | strings.HasSuffix(path, ".cs") 46 | } 47 | -------------------------------------------------------------------------------- /examples/buckets/builders.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/components" 6 | "stakpak.dev/devx/v1/transformers/compose" 7 | tfaws "stakpak.dev/devx/v1/transformers/terraform/aws" 8 | ) 9 | 10 | builders: v1.#StackBuilder 11 | 12 | builders: prod: mainflows: [ 13 | { 14 | pipeline: [tfaws.#AddS3Bucket] 15 | }, 16 | ] 17 | 18 | builders: dev: mainflows: [ 19 | { 20 | pipeline: [compose.#AddComposeService] 21 | }, 22 | { 23 | pipeline: [compose.#ExposeComposeService] 24 | }, 25 | { 26 | pipeline: [compose.#AddComposeVolume] 27 | }, 28 | { 29 | pipeline: [compose.#AddS3Bucket] 30 | }, 31 | ] 32 | 33 | builders: dev: additionalComponents: { 34 | myminio: { 35 | components.#Minio 36 | minio: { 37 | urlScheme: "http" 38 | userKeys: default: { 39 | accessKey: "admin" 40 | accessSecret: "adminadmin" 41 | } 42 | url: _ 43 | } 44 | } 45 | bucket: s3: { 46 | url: myminio.minio.url 47 | accessKey: myminio.minio.userKeys.default.accessKey 48 | accessSecret: myminio.minio.userKeys.default.accessSecret 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - binary: devx 6 | main: "./cmd/devx" 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | archives: 14 | - name_template: >- 15 | {{ .ProjectName }}_ 16 | {{- title .Os }}_ 17 | {{- if eq .Arch "amd64" }}x86_64 18 | {{- else if eq .Arch "386" }}i386 19 | {{- else if eq .Arch "darwin" }}Darwin 20 | {{- else if eq .Arch "linux" }}Linux 21 | {{- else if eq .Arch "windows" }}Windows 22 | {{- else }}{{ .Arch }}{{ end }} 23 | checksum: 24 | name_template: "checksums.txt" 25 | snapshot: 26 | name_template: "{{ incpatch .Tag }}-next" 27 | changelog: 28 | sort: asc 29 | filters: 30 | exclude: 31 | - "^docs:" 32 | - "^test:" 33 | brews: 34 | - homepage: "https://devx.stakpak.dev/" 35 | tap: 36 | owner: stakpak 37 | name: homebrew-stakpak 38 | branch: main 39 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 40 | dockers: 41 | - image_templates: 42 | - "ghcr.io/stakpak/devx:latest" 43 | - "ghcr.io/stakpak/devx:{{ .Tag }}" 44 | - "ghcr.io/stakpak/devx:v{{ .Major }}.{{ .Minor }}" 45 | -------------------------------------------------------------------------------- /examples/v2alpha1/stack.cue: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "stakpak.dev/devx/v1" 5 | "stakpak.dev/devx/v1/traits" 6 | "stakpak.dev/devx/v2alpha1" 7 | "stakpak.dev/devx/v2alpha1/environments" 8 | ) 9 | 10 | // stack builder 11 | builders: v2alpha1.#Environments & { 12 | dev: environments.#Compose 13 | } 14 | 15 | // common 16 | stack: v1.#Stack & { 17 | components: { 18 | db: { 19 | traits.#Database 20 | traits.#Secret 21 | database: { 22 | name: "prod" 23 | version: "13.7" 24 | username: "root" 25 | password: secrets.dbPassword 26 | } 27 | secrets: dbPassword: name: "prod-db-password" 28 | } 29 | kafka: { 30 | traits.#Kafka 31 | traits.#Secret 32 | kafka: name: "prod" 33 | secrets: kafkaCreds: name: "kafka-user-c" 34 | } 35 | app2: { 36 | traits.#Workload 37 | traits.#Exposable 38 | containers: default: { 39 | image: "app" 40 | resources: requests: { 41 | cpu: "256m" 42 | memory: "512M" 43 | } 44 | env: { 45 | KAFKA_URLS: kafka.kafka.bootstrapServers 46 | KAFKA_SECRET: kafka.secrets.kafkaCreds 47 | DB_HOST: db.database.host 48 | DB_PORT: "\(db.database.port)" 49 | DB_NAME: db.database.database 50 | DB_USERNAME: db.database.username 51 | DB_PASSWORD: db.database.password 52 | } 53 | } 54 | endpoints: default: ports: [ 55 | { 56 | port: 8005 57 | target: 8000 58 | }, 59 | { 60 | port: 8001 61 | }, 62 | ] 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/drivers/gitlab.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "cuelang.org/go/cue" 8 | "cuelang.org/go/encoding/yaml" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/stakpak/devx/pkg/stack" 11 | "github.com/stakpak/devx/pkg/stackbuilder" 12 | "github.com/stakpak/devx/pkg/utils" 13 | ) 14 | 15 | type GitlabDriver struct { 16 | Config stackbuilder.DriverConfig 17 | } 18 | 19 | func (d *GitlabDriver) match(resource cue.Value) bool { 20 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 21 | return driverName == "gitlab" 22 | } 23 | 24 | func (d *GitlabDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 25 | for _, componentId := range stack.GetTasks() { 26 | component, _ := stack.GetComponent(componentId) 27 | 28 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 29 | for resourceIter.Next() { 30 | if d.match(resourceIter.Value()) { 31 | resource, err := utils.RemoveMeta(resourceIter.Value()) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | data, err := yaml.Encode(resource) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if stdout { 42 | _, err := os.Stdout.Write(data) 43 | if err != nil { 44 | return err 45 | } 46 | continue 47 | } 48 | 49 | if _, err := os.Stat(d.Config.Output.Dir); os.IsNotExist(err) { 50 | os.MkdirAll(d.Config.Output.Dir, 0700) 51 | } 52 | filePath := path.Join(d.Config.Output.Dir, d.Config.Output.File) 53 | os.WriteFile(filePath, data, 0700) 54 | 55 | log.Infof("[gitlab] applied a resource to \"%s\"", filePath) 56 | } 57 | } 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/drivers/json.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | 8 | "cuelang.org/go/cue" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/stakpak/devx/pkg/stack" 11 | "github.com/stakpak/devx/pkg/stackbuilder" 12 | "github.com/stakpak/devx/pkg/utils" 13 | ) 14 | 15 | type JSONDriver struct { 16 | Config stackbuilder.DriverConfig 17 | } 18 | 19 | func (d *JSONDriver) match(resource cue.Value) bool { 20 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 21 | return driverName == "json" 22 | } 23 | 24 | func (d *JSONDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 25 | jsonFile := stack.GetContext().CompileString("_") 26 | foundResources := false 27 | 28 | for _, componentId := range stack.GetTasks() { 29 | component, _ := stack.GetComponent(componentId) 30 | 31 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 32 | for resourceIter.Next() { 33 | if d.match(resourceIter.Value()) { 34 | foundResources = true 35 | jsonFile = jsonFile.FillPath(cue.ParsePath(""), resourceIter.Value()) 36 | } 37 | } 38 | } 39 | 40 | if !foundResources { 41 | return nil 42 | } 43 | 44 | jsonFile, err := utils.RemoveMeta(jsonFile) 45 | if err != nil { 46 | return err 47 | } 48 | data, err := json.MarshalIndent(jsonFile, "", " ") 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if stdout { 54 | _, err := os.Stdout.Write(data) 55 | return err 56 | } 57 | 58 | if _, err := os.Stat(d.Config.Output.Dir); os.IsNotExist(err) { 59 | os.MkdirAll(d.Config.Output.Dir, 0700) 60 | } 61 | filePath := path.Join(d.Config.Output.Dir, d.Config.Output.File) 62 | os.WriteFile(filePath, data, 0700) 63 | 64 | log.Infof("[json] applied resources to \"%s\"", filePath) 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/drivers/yaml.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "cuelang.org/go/cue" 8 | "cuelang.org/go/encoding/yaml" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/stakpak/devx/pkg/stack" 11 | "github.com/stakpak/devx/pkg/stackbuilder" 12 | "github.com/stakpak/devx/pkg/utils" 13 | ) 14 | 15 | type YAMLDriver struct { 16 | Config stackbuilder.DriverConfig 17 | } 18 | 19 | func (d *YAMLDriver) match(resource cue.Value) bool { 20 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 21 | return driverName == "yaml" 22 | } 23 | 24 | func (d *YAMLDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 25 | yamlFile := stack.GetContext().CompileString("_") 26 | foundResources := false 27 | 28 | for _, componentId := range stack.GetTasks() { 29 | component, _ := stack.GetComponent(componentId) 30 | 31 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 32 | for resourceIter.Next() { 33 | if d.match(resourceIter.Value()) { 34 | foundResources = true 35 | yamlFile = yamlFile.FillPath(cue.ParsePath(""), resourceIter.Value()) 36 | } 37 | } 38 | } 39 | 40 | if !foundResources { 41 | return nil 42 | } 43 | 44 | yamlFile, err := utils.RemoveMeta(yamlFile) 45 | if err != nil { 46 | return err 47 | } 48 | data, err := yaml.Encode(yamlFile) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if stdout { 54 | _, err := os.Stdout.Write(data) 55 | return err 56 | } 57 | 58 | if _, err := os.Stat(d.Config.Output.Dir); os.IsNotExist(err) { 59 | os.MkdirAll(d.Config.Output.Dir, 0700) 60 | } 61 | filePath := path.Join(d.Config.Output.Dir, d.Config.Output.File) 62 | os.WriteFile(filePath, data, 0700) 63 | 64 | log.Infof("[yaml] applied resources to \"%s\"", filePath) 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/drivers/compose.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "cuelang.org/go/cue" 8 | "cuelang.org/go/encoding/yaml" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/stakpak/devx/pkg/stack" 11 | "github.com/stakpak/devx/pkg/stackbuilder" 12 | "github.com/stakpak/devx/pkg/utils" 13 | ) 14 | 15 | type ComposeDriver struct { 16 | Config stackbuilder.DriverConfig 17 | } 18 | 19 | func (d *ComposeDriver) match(resource cue.Value) bool { 20 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 21 | return driverName == "compose" 22 | } 23 | 24 | func (d *ComposeDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 25 | 26 | composeFile := stack.GetContext().CompileString("_") 27 | foundResources := false 28 | 29 | for _, componentId := range stack.GetTasks() { 30 | component, _ := stack.GetComponent(componentId) 31 | 32 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 33 | for resourceIter.Next() { 34 | if d.match(resourceIter.Value()) { 35 | foundResources = true 36 | composeFile = composeFile.Fill(resourceIter.Value()) 37 | } 38 | } 39 | } 40 | 41 | if !foundResources { 42 | return nil 43 | } 44 | 45 | composeFile, err := utils.RemoveMeta(composeFile) 46 | if err != nil { 47 | return err 48 | } 49 | data, err := yaml.Encode(composeFile) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if stdout { 55 | _, err := os.Stdout.Write(data) 56 | return err 57 | } 58 | 59 | if _, err := os.Stat(d.Config.Output.Dir); os.IsNotExist(err) { 60 | if err := os.MkdirAll(d.Config.Output.Dir, 0700); err != nil { 61 | return err 62 | } 63 | } 64 | filePath := path.Join(d.Config.Output.Dir, d.Config.Output.File) 65 | if err := os.WriteFile(filePath, data, 0600); err != nil { 66 | return err 67 | } 68 | 69 | log.Infof("[compose] applied resources to \"%s\"", filePath) 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/drivers/github.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "cuelang.org/go/cue" 9 | "cuelang.org/go/encoding/yaml" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/stakpak/devx/pkg/stack" 12 | "github.com/stakpak/devx/pkg/stackbuilder" 13 | "github.com/stakpak/devx/pkg/utils" 14 | ) 15 | 16 | type GitHubDriver struct { 17 | Config stackbuilder.DriverConfig 18 | } 19 | 20 | func (d *GitHubDriver) match(resource cue.Value) bool { 21 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 22 | return driverName == "github" 23 | } 24 | 25 | func (d *GitHubDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 26 | foundResources := false 27 | 28 | for _, componentId := range stack.GetTasks() { 29 | component, _ := stack.GetComponent(componentId) 30 | 31 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 32 | for resourceIter.Next() { 33 | if d.match(resourceIter.Value()) { 34 | foundResources = true 35 | resource, err := utils.RemoveMeta(resourceIter.Value()) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | data, err := yaml.Encode(resource) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if stdout { 46 | _, err := os.Stdout.Write(data) 47 | if err != nil { 48 | return err 49 | } 50 | continue 51 | } 52 | 53 | if _, err := os.Stat(d.Config.Output.Dir); os.IsNotExist(err) { 54 | os.MkdirAll(d.Config.Output.Dir, 0700) 55 | } 56 | fileName := fmt.Sprintf("%s.yml", resourceIter.Label()) 57 | if d.Config.Output.File != "" { 58 | fileName = d.Config.Output.File 59 | } 60 | filePath := path.Join(d.Config.Output.Dir, fileName) 61 | os.WriteFile(filePath, data, 0700) 62 | } 63 | } 64 | } 65 | 66 | if !foundResources { 67 | return nil 68 | } 69 | 70 | log.Infof("[github] applied resources to \"%s/*-github-workflow.yml\"", d.Config.Output.Dir) 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/policy/policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cuelang.org/go/cue" 7 | "cuelang.org/go/encoding/gocode/gocodec" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/stakpak/devx/pkg/auth" 10 | "github.com/stakpak/devx/pkg/utils" 11 | ) 12 | 13 | var policyNamePath = cue.ParsePath("$metadata.policy") 14 | 15 | type GlobalPolicy struct { 16 | Name string `json:"name"` 17 | Environments []string `json:"environments"` 18 | PipelineJSON string 19 | IsEnforced bool `json:"enforced"` 20 | IsDisabled bool `json:"disabled"` 21 | } 22 | type GlobalPolicyData struct { 23 | Name string `json:"name"` 24 | Environments []string `json:"environments"` 25 | PipelineJSON string `json:"pipeline"` 26 | IsEnforced bool `json:"enforced"` 27 | IsDisabled bool `json:"disabled"` 28 | } 29 | 30 | func Publish(configDir string, server auth.ServerConfig) error { 31 | overlays, err := utils.GetOverlays(configDir) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | value, _, _ := utils.LoadProject(configDir, &overlays) 37 | codec := gocodec.New((*cue.Runtime)(value.Context()), nil) 38 | 39 | fieldIter, err := value.Fields() 40 | if err != nil { 41 | return err 42 | } 43 | for fieldIter.Next() { 44 | item := fieldIter.Value() 45 | policyNameValue := item.LookupPath(policyNamePath) 46 | if !policyNameValue.Exists() { 47 | continue 48 | } 49 | policyName, err := policyNameValue.String() 50 | if err != nil { 51 | return fmt.Errorf("invalid policy name %s", item.Path()) 52 | } 53 | 54 | err = item.Validate(cue.Concrete(true)) 55 | if err != nil { 56 | return fmt.Errorf("policy %s is not concrete", policyName) 57 | } 58 | 59 | policy := GlobalPolicy{} 60 | err = codec.Encode(item, &policy) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | policyData := GlobalPolicyData(policy) 66 | response, err := utils.SendData(server, "policies", policyData) 67 | if err != nil { 68 | log.Debug(string(response)) 69 | return err 70 | } 71 | 72 | log.Infof("Saved policy %s", policyName) 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/stack/stack_test.go: -------------------------------------------------------------------------------- 1 | package stack 2 | 3 | import ( 4 | "testing" 5 | 6 | "cuelang.org/go/cue/cuecontext" 7 | ) 8 | 9 | var stackString1 = ` 10 | components: { 11 | a: { 12 | $metadata: id: "a" 13 | 14 | tada: b.todo 15 | } 16 | b: { 17 | $metadata: id: "b" 18 | 19 | todo: 123 20 | } 21 | c: { 22 | $metadata: id: "c" 23 | 24 | bla: a.tada 25 | } 26 | } 27 | ` 28 | 29 | func TestNew(t *testing.T) { 30 | ctx := cuecontext.New() 31 | value := ctx.CompileString(stackString1) 32 | 33 | stack, err := NewStack(value, "", []string{}) 34 | if err != nil { 35 | t.Error(err) 36 | } 37 | 38 | keys := []string{"a", "b", "c"} 39 | parents := [][]string{ 40 | {"b"}, 41 | {}, 42 | {"a"}, 43 | } 44 | 45 | for i, key := range keys { 46 | dependencies, err := stack.GetDependencies(key) 47 | if err != nil { 48 | t.Errorf("Error in key %s: %s", key, err) 49 | } 50 | if len(dependencies) != len(parents[i]) { 51 | t.Errorf("Error in key %s: expected dependencies length %s but found %s", key, parents[i], dependencies) 52 | } 53 | for j := range parents[i] { 54 | if parents[i][j] != dependencies[j] { 55 | t.Errorf("Error in key %s: expected dependencies %s but found %s", key, parents[i], dependencies) 56 | } 57 | } 58 | } 59 | } 60 | 61 | func TestTaskOrder(t *testing.T) { 62 | stackString := ` 63 | components: { 64 | a: { 65 | $metadata: id: "a" 66 | todo: 123 67 | } 68 | f: { 69 | $metadata: id: "f" 70 | todo: 123 71 | } 72 | b: { 73 | $metadata: id: "b" 74 | todo: a.todo 75 | } 76 | c: { 77 | $metadata: id: "c" 78 | todo: b.todo 79 | } 80 | } 81 | ` 82 | ctx := cuecontext.New() 83 | value := ctx.CompileString(stackString) 84 | 85 | stack, err := NewStack(value, "", []string{}) 86 | if err != nil { 87 | t.Error(err) 88 | } 89 | 90 | taskOrder := stack.GetTasks() 91 | 92 | expectedOrder := []string{"f", "a", "b", "c"} 93 | 94 | for i, k := range expectedOrder { 95 | if taskOrder[i] != k { 96 | t.Errorf("Error expected element %d in order to be %s but got %s", i, k, taskOrder[i]) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /pkg/stackbuilder/flow_test.go: -------------------------------------------------------------------------------- 1 | package stackbuilder 2 | 3 | import ( 4 | "testing" 5 | 6 | "cuelang.org/go/cue/cuecontext" 7 | ) 8 | 9 | var flowString1 = ` 10 | match: { 11 | traits: { 12 | balabizo: null 13 | tada: 123 14 | } 15 | } 16 | exclude: { 17 | labels: { 18 | tada: "abc" 19 | toto: 123 20 | } 21 | } 22 | pipeline: [] 23 | ` 24 | 25 | var componentMatchFlow1 = ` 26 | $metadata: { 27 | traits: { 28 | balabizo: null 29 | tada: 123 30 | bla: null 31 | } 32 | } 33 | ` 34 | 35 | var componentMissingFlow1 = ` 36 | $metadata: { 37 | traits: { 38 | balabizo: null 39 | bla: null 40 | } 41 | } 42 | ` 43 | 44 | var componentDiffFlow1 = ` 45 | $metadata: { 46 | traits: { 47 | balabizo: null 48 | tada: null 49 | } 50 | } 51 | ` 52 | 53 | var componentExcludeLabelFlow1 = ` 54 | $metadata: { 55 | traits: { 56 | balabizo: null 57 | tada: 123 58 | } 59 | labels: toto: 123 60 | } 61 | ` 62 | 63 | func TestNewFlow(t *testing.T) { 64 | ctx := cuecontext.New() 65 | flowValue := ctx.CompileString(flowString1) 66 | 67 | _, err := NewFlow(flowValue) 68 | if err != nil { 69 | t.Error(err) 70 | } 71 | } 72 | 73 | func TestMatchFlow(t *testing.T) { 74 | ctx := cuecontext.New() 75 | flowValue := ctx.CompileString(flowString1) 76 | 77 | flow, err := NewFlow(flowValue) 78 | if err != nil { 79 | t.Error(err) 80 | } 81 | 82 | componentMatch := ctx.CompileString(componentMatchFlow1) 83 | if !flow.Match(componentMatch) { 84 | t.Error("Expected component to match flow") 85 | } 86 | 87 | componentMissing := ctx.CompileString(componentMissingFlow1) 88 | if flow.Match(componentMissing) { 89 | t.Error("Expected component not to match flow: missing trait") 90 | } 91 | 92 | componentDiff := ctx.CompileString(componentDiffFlow1) 93 | if flow.Match(componentDiff) { 94 | t.Error("Expected component not to match flow: different trait value") 95 | } 96 | } 97 | 98 | func TestMatchExcludeFlow(t *testing.T) { 99 | ctx := cuecontext.New() 100 | flowValue := ctx.CompileString(flowString1) 101 | 102 | flow, err := NewFlow(flowValue) 103 | if err != nil { 104 | t.Error(err) 105 | } 106 | 107 | componentExcludeLabel := ctx.CompileString(componentExcludeLabelFlow1) 108 | if flow.Match(componentExcludeLabel) { 109 | t.Error("Expected component not to match flow: excluded label") 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stakpak/devx 2 | 3 | go 1.18 4 | 5 | require ( 6 | cuelang.org/go v0.6.0 7 | github.com/go-git/go-billy/v5 v5.3.1 8 | golang.org/x/mod v0.9.0 9 | mvdan.cc/sh/v3 v3.6.0 10 | ) 11 | 12 | require ( 13 | github.com/Microsoft/go-winio v0.4.16 // indirect 14 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect 15 | github.com/acomagu/bufpipe v1.0.3 // indirect 16 | github.com/cockroachdb/apd/v3 v3.2.0 // indirect 17 | github.com/emirpasic/gods v1.12.0 // indirect 18 | github.com/go-git/gcfg v1.5.0 // indirect 19 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect 20 | github.com/imdario/mergo v0.3.12 // indirect 21 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 22 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 23 | github.com/joho/godotenv v1.4.0 // indirect 24 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect 25 | github.com/mattn/go-colorable v0.1.9 // indirect 26 | github.com/mattn/go-isatty v0.0.16 // indirect 27 | github.com/mattn/go-runewidth v0.0.14 // indirect 28 | github.com/mattn/go-zglob v0.0.4 // indirect 29 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 30 | github.com/mitchellh/go-homedir v1.1.0 // indirect 31 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 32 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 33 | github.com/radovskyb/watcher v1.0.7 // indirect 34 | github.com/rivo/uniseg v0.4.2 // indirect 35 | github.com/sajari/fuzzy v1.0.0 // indirect 36 | github.com/sergi/go-diff v1.1.0 // indirect 37 | github.com/spf13/pflag v1.0.5 // indirect 38 | github.com/xanzy/ssh-agent v0.3.0 // indirect 39 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect 40 | golang.org/x/exp v0.0.0-20220930202632-ec3f01382ef9 // indirect 41 | golang.org/x/sync v0.1.0 // indirect 42 | golang.org/x/sys v0.6.0 // indirect 43 | golang.org/x/term v0.6.0 // indirect 44 | gopkg.in/warnings.v0 v0.1.2 // indirect 45 | ) 46 | 47 | require ( 48 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d 49 | github.com/emicklei/proto v1.11.0 // indirect 50 | github.com/fatih/color v1.13.0 51 | github.com/go-git/go-git/v5 v5.4.2 52 | github.com/go-task/task/v3 v3.20.0 53 | github.com/google/uuid v1.3.0 // indirect 54 | github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect 55 | github.com/olekukonko/tablewriter v0.0.5 56 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 57 | github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect 58 | github.com/schollz/progressbar/v3 v3.12.1 59 | github.com/sirupsen/logrus v1.9.0 60 | github.com/spf13/cobra v1.7.0 61 | golang.org/x/net v0.8.0 // indirect 62 | golang.org/x/text v0.8.0 // indirect 63 | gopkg.in/yaml.v2 v2.4.0 // indirect 64 | gopkg.in/yaml.v3 v3.0.1 65 | ) 66 | -------------------------------------------------------------------------------- /pkg/xray/xray.go: -------------------------------------------------------------------------------- 1 | package xray 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/olekukonko/tablewriter" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/stakpak/devx/pkg/auth" 12 | "github.com/stakpak/devx/pkg/utils" 13 | ) 14 | 15 | type XRayRequest struct { 16 | Project string `json:"project"` 17 | Source map[string]string `json:"source"` 18 | } 19 | type XRayResponse struct { 20 | Project string `json:"project"` 21 | Source map[string]string `json:"source"` 22 | 23 | State string `json:"state"` 24 | Result string `json:"result"` 25 | Dependencies []XRayDep `json:"dependencies"` 26 | Environments []XRayEnv `json:"environments"` 27 | Languages []string `json:"languages"` 28 | } 29 | type XRayEnv struct { 30 | Name string `json:"name"` 31 | } 32 | type XRayDep struct { 33 | Name string `json:"name"` 34 | } 35 | 36 | func Run(configDir string, server auth.ServerConfig) error { 37 | log.Info("👁️ Scanning your source code...") 38 | overlay, err := scan(configDir) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | item := XRayRequest{ 44 | Project: "none", 45 | Source: overlay, 46 | } 47 | 48 | log.Info("🧠 Analysing data...this will take a couple of mins...") 49 | data, err := utils.SendData(server, "xray", item) 50 | if err != nil { 51 | log.Debug(string(data)) 52 | return err 53 | } 54 | resp := make(map[string]string) 55 | err = json.Unmarshal(data, &resp) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | id := resp["id"] 61 | data, err = utils.GetData(server, "xray", &id, nil) 62 | if err != nil { 63 | log.Debug(string(data)) 64 | return err 65 | } 66 | 67 | xrayResp := XRayResponse{} 68 | err = json.Unmarshal(data, &xrayResp) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | log.Info("🤓 Analysis results") 74 | deps := []string{} 75 | for _, dep := range xrayResp.Dependencies { 76 | deps = append(deps, dep.Name) 77 | } 78 | 79 | tableData := [][]string{ 80 | {"Languages", strings.Join(xrayResp.Languages, " ")}, 81 | {"Dependencies", strings.Join(deps, " ")}, 82 | {"Environment variables", fmt.Sprint(len(xrayResp.Environments))}, 83 | } 84 | 85 | table := tablewriter.NewWriter(os.Stdout) 86 | table.SetAlignment(tablewriter.ALIGN_LEFT) 87 | table.SetCenterSeparator("") 88 | table.SetColumnSeparator("") 89 | table.SetRowSeparator("") 90 | table.SetBorder(false) 91 | table.SetTablePadding("\t") 92 | table.SetNoWhiteSpace(true) 93 | table.AppendBulk(tableData) 94 | table.Render() 95 | 96 | if xrayResp.State != "Success" { 97 | return fmt.Errorf("unexpected devx-ray response status %s", xrayResp.State) 98 | } 99 | 100 | log.Info("🏭 Generating your stack...") 101 | err = os.WriteFile("stack.gen.cue", []byte(xrayResp.Result), 0700) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | log.Info("Created your stack at \"stack.gen.cue\"") 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/drivers/terraform.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | 9 | "cuelang.org/go/cue" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/stakpak/devx/pkg/stack" 12 | "github.com/stakpak/devx/pkg/stackbuilder" 13 | "github.com/stakpak/devx/pkg/utils" 14 | ) 15 | 16 | type TerraformDriver struct { 17 | Config stackbuilder.DriverConfig 18 | } 19 | 20 | func (d *TerraformDriver) match(resource cue.Value) bool { 21 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 22 | return driverName == "terraform" 23 | } 24 | 25 | func (d *TerraformDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 26 | 27 | terraformFiles := map[string]cue.Value{} 28 | defaultFilePath := path.Join(d.Config.Output.Dir, d.Config.Output.File) 29 | foundResources := false 30 | 31 | common := stack.GetContext().CompileString("_") 32 | for _, componentId := range stack.GetTasks() { 33 | component, _ := stack.GetComponent(componentId) 34 | 35 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 36 | for resourceIter.Next() { 37 | v := resourceIter.Value() 38 | if d.match(v) { 39 | foundResources = true 40 | filePath := defaultFilePath 41 | 42 | outputSubdirLabel := v.LookupPath(cue.ParsePath("$metadata.labels.\"output-subdir\"")) 43 | if outputSubdirLabel.Exists() { 44 | outputSubdir, err := outputSubdirLabel.String() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if outputSubdir == "*" { 50 | v, err := utils.RemoveMeta(v) 51 | if err != nil { 52 | return err 53 | } 54 | common = common.FillPath(cue.ParsePath(""), v) 55 | continue 56 | } 57 | 58 | filePath = path.Join(d.Config.Output.Dir, outputSubdir, d.Config.Output.File) 59 | } 60 | 61 | if _, ok := terraformFiles[filePath]; !ok { 62 | terraformFiles[filePath] = stack.GetContext().CompileString("_") 63 | } 64 | 65 | v, err := utils.RemoveMeta(v) 66 | if err != nil { 67 | return err 68 | } 69 | terraformFiles[filePath] = terraformFiles[filePath].FillPath(cue.ParsePath(""), v) 70 | } 71 | } 72 | } 73 | 74 | if !foundResources { 75 | return nil 76 | } 77 | 78 | for filePath, fileValue := range terraformFiles { 79 | fileValue := fileValue.FillPath(cue.ParsePath(""), common) 80 | data, err := json.MarshalIndent(fileValue, "", " ") 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if stdout { 86 | _, err := os.Stdout.Write(data) 87 | if err != nil { 88 | return err 89 | } 90 | _, err = os.Stdout.Write([]byte("\n")) 91 | return err 92 | } 93 | 94 | if _, err := os.Stat(filepath.Dir(filePath)); os.IsNotExist(err) { 95 | os.MkdirAll(filepath.Dir(filePath), 0700) 96 | } 97 | os.WriteFile(filePath, data, 0700) 98 | 99 | log.Infof("[terraform] applied resources to \"%s\"", filePath) 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/drivers/kubernetes.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | 10 | "cuelang.org/go/cue" 11 | "cuelang.org/go/encoding/yaml" 12 | log "github.com/sirupsen/logrus" 13 | "github.com/stakpak/devx/pkg/stack" 14 | "github.com/stakpak/devx/pkg/stackbuilder" 15 | "github.com/stakpak/devx/pkg/utils" 16 | ) 17 | 18 | type KubernetesDriver struct { 19 | Config stackbuilder.DriverConfig 20 | } 21 | 22 | func (d *KubernetesDriver) match(resource cue.Value) bool { 23 | driverName, _ := resource.LookupPath(cue.ParsePath("$metadata.labels.driver")).String() 24 | return driverName == "kubernetes" 25 | } 26 | 27 | func (d *KubernetesDriver) ApplyAll(stack *stack.Stack, stdout bool) error { 28 | manifests := map[string][]byte{} 29 | defaultFilePath := path.Join(d.Config.Output.Dir, d.Config.Output.File) 30 | 31 | for _, componentId := range stack.GetTasks() { 32 | component, _ := stack.GetComponent(componentId) 33 | 34 | resourceIter, _ := component.LookupPath(cue.ParsePath("$resources")).Fields() 35 | for resourceIter.Next() { 36 | v := resourceIter.Value() 37 | if d.match(v) { 38 | filePath := defaultFilePath 39 | outputSubdirLabel := v.LookupPath(cue.ParsePath("$metadata.labels.\"output-subdir\"")) 40 | if outputSubdirLabel.Exists() { 41 | outputSubdir, err := outputSubdirLabel.String() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | filePath = path.Join(d.Config.Output.Dir, outputSubdir, d.Config.Output.File) 47 | } 48 | 49 | resource, err := utils.RemoveMeta(v) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | kind := resource.LookupPath(cue.ParsePath("kind")) 55 | if kind.Err() != nil { 56 | return kind.Err() 57 | } 58 | 59 | kindString, err := kind.String() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | name := resource.LookupPath(cue.ParsePath("metadata.name")) 65 | if kind.Err() != nil { 66 | return kind.Err() 67 | } 68 | 69 | nameString, err := name.String() 70 | if err != nil { 71 | return err 72 | } 73 | 74 | data, err := yaml.Encode(resource) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | filePath = filepath.Join(filePath, fmt.Sprintf("%s-%s.yml", nameString, strings.ToLower(kindString))) 80 | 81 | manifests[filePath] = data 82 | } 83 | } 84 | } 85 | 86 | if len(manifests) == 0 { 87 | return nil 88 | } 89 | 90 | for filePath, fileValue := range manifests { 91 | 92 | if stdout { 93 | if _, err := os.Stdout.Write([]byte("---\n")); err != nil { 94 | return err 95 | } 96 | if _, err := os.Stdout.Write(fileValue); err != nil { 97 | return err 98 | } 99 | _, err := os.Stdout.Write([]byte("\n")) 100 | return err 101 | } 102 | 103 | if _, err := os.Stat(filepath.Dir(filePath)); os.IsNotExist(err) { 104 | os.MkdirAll(filepath.Dir(filePath), 0700) 105 | } 106 | _, err := os.Stat(filePath) 107 | 108 | if !os.IsNotExist(err) { 109 | os.Remove(filePath) 110 | } 111 | 112 | os.WriteFile(filePath, fileValue, 0700) 113 | 114 | log.Infof("[kubernetes] applied resources to \"%s\"", filePath) 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /cmd/devx/project.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "cuelang.org/go/cue/errors" 10 | "github.com/stakpak/devx/pkg/catalog" 11 | "github.com/stakpak/devx/pkg/policy" 12 | "github.com/stakpak/devx/pkg/project" 13 | ) 14 | 15 | var projectCmd = &cobra.Command{ 16 | Use: "project", 17 | Short: "Manage a DevX project", 18 | } 19 | 20 | var initCmd = &cobra.Command{ 21 | Use: "init [module name]", 22 | Short: "Initialize a project", 23 | Args: func(cmd *cobra.Command, args []string) error { 24 | if len(args) != 1 { 25 | return errors.New("1 argument required: init [module name]") 26 | } 27 | return nil 28 | }, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | if err := project.Init(context.TODO(), configDir, args[0]); err != nil { 31 | return err 32 | } 33 | return nil 34 | }, 35 | } 36 | 37 | var updateCmd = &cobra.Command{ 38 | Use: "update", 39 | Short: "Update/Install project dependencies", 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | if err := project.Update(configDir, server); err != nil { 42 | return err 43 | } 44 | return nil 45 | }, 46 | } 47 | 48 | var validateCmd = &cobra.Command{ 49 | Use: "validate", 50 | Aliases: []string{"v"}, 51 | Short: "Validate configurations", 52 | RunE: func(cmd *cobra.Command, args []string) error { 53 | if err := project.Validate(configDir, stackPath, buildersPath, noStrict); err != nil { 54 | return fmt.Errorf(errors.Details(err, nil)) 55 | } 56 | return nil 57 | }, 58 | } 59 | 60 | var discoverCmd = &cobra.Command{ 61 | Use: "discover", 62 | Aliases: []string{"d"}, 63 | Short: "Discover traits", 64 | RunE: func(cmd *cobra.Command, args []string) error { 65 | if err := project.Discover(configDir, showDefs, showTransformers); err != nil { 66 | return err 67 | } 68 | return nil 69 | }, 70 | } 71 | 72 | var genCmd = &cobra.Command{ 73 | Use: "gen", 74 | Short: "Generate bare config file", 75 | RunE: func(cmd *cobra.Command, args []string) error { 76 | if err := project.Generate(configDir); err != nil { 77 | return err 78 | } 79 | return nil 80 | }, 81 | } 82 | 83 | var publishCmd = &cobra.Command{ 84 | Use: "publish", 85 | Short: "Publish this project", 86 | } 87 | 88 | var publishStackCmd = &cobra.Command{ 89 | Use: "stack", 90 | Short: "Publish this stack", 91 | RunE: func(cmd *cobra.Command, args []string) error { 92 | if err := project.Publish(configDir, stackPath, buildersPath, server); err != nil { 93 | return err 94 | } 95 | return nil 96 | }, 97 | } 98 | 99 | var publishPolicyCmd = &cobra.Command{ 100 | Use: "policy", 101 | Short: "Publish global policies in this project", 102 | RunE: func(cmd *cobra.Command, args []string) error { 103 | if err := policy.Publish(configDir, server); err != nil { 104 | return err 105 | } 106 | return nil 107 | }, 108 | } 109 | 110 | var publishCatalogCmd = &cobra.Command{ 111 | Use: "catalog", 112 | Short: "Publish catalog components in this project", 113 | RunE: func(cmd *cobra.Command, args []string) error { 114 | if err := catalog.Publish(gitDir, configDir, server); err != nil { 115 | return err 116 | } 117 | return nil 118 | }, 119 | } 120 | 121 | var publishModuleCmd = &cobra.Command{ 122 | Use: "mod", 123 | Short: "Publish this module", 124 | RunE: func(cmd *cobra.Command, args []string) error { 125 | if err := catalog.PublishModule(gitDir, configDir, server, tags); err != nil { 126 | return err 127 | } 128 | return nil 129 | }, 130 | } 131 | 132 | var importCmd = &cobra.Command{ 133 | Use: "import [@]", 134 | Short: "Import a dependency", 135 | Args: cobra.ExactArgs(1), 136 | RunE: func(cmd *cobra.Command, args []string) error { 137 | if err := project.Import(args[0], configDir, server); err != nil { 138 | return err 139 | } 140 | return nil 141 | }, 142 | } 143 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Portions of this software are licensed as follows: 4 | 5 | - Content of branches other than the main branch (i.e. "main") are not licensed. 6 | - All third party components incorporated into the DevX Software are licensed under the original license 7 | provided by the owner of the applicable component. 8 | - Content outside of the above mentioned files or restrictions is available under the "Sustainable Use 9 | License" as defined below. 10 | 11 | ## Sustainable Use License 12 | 13 | Version 1.0 14 | 15 | ### Acceptance 16 | 17 | By using the software, you agree to all of the terms and conditions below. 18 | 19 | ### Copyright License 20 | 21 | The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license 22 | to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject 23 | to the limitations below. 24 | 25 | ### Limitations 26 | 27 | You may use or modify the software only for your own internal business purposes or for non-commercial or 28 | personal use. You may distribute the software or provide it to others only if you do so free of charge for 29 | non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of 30 | the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. 31 | 32 | ### Patents 33 | 34 | The licensor grants you a license, under any patent claims the licensor can license, or becomes able to 35 | license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case 36 | subject to the limitations and conditions in this license. This license does not cover any patent claims that 37 | you cause to be infringed by modifications or additions to the software. If you or your company make any 38 | written claim that the software infringes or contributes to infringement of any patent, your patent license 39 | for the software granted under these terms ends immediately. If your company makes such a claim, your patent 40 | license ends immediately for work on behalf of your company. 41 | 42 | ### Notices 43 | 44 | You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these 45 | terms. If you modify the software, you must include in any modified copies of the software a prominent notice 46 | stating that you have modified the software. 47 | 48 | ### No Other Rights 49 | 50 | These terms do not imply any licenses other than those expressly granted in these terms. 51 | 52 | ### Termination 53 | 54 | If you use the software in violation of these terms, such use is not licensed, and your license will 55 | automatically terminate. If the licensor provides you with a notice of your violation, and you cease all 56 | violation of this license no later than 30 days after you receive that notice, your license will be reinstated 57 | retroactively. However, if you violate these terms after such reinstatement, any additional violation of these 58 | terms will cause your license to terminate automatically and permanently. 59 | 60 | ### No Liability 61 | 62 | As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will 63 | not be liable to you for any damages arising out of these terms or the use or nature of the software, under 64 | any kind of legal claim. 65 | 66 | ### Definitions 67 | 68 | The “licensor” is the entity offering these terms. 69 | 70 | The “software” is the software the licensor makes available under these terms, including any portion of it. 71 | 72 | “You” refers to the individual or entity agreeing to these terms. 73 | 74 | “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus 75 | all organizations that have control over, are under the control of, or are under common control with that 76 | organization. Control means ownership of substantially all the assets of an entity, or the power to direct its 77 | management and policies by vote, contract, or otherwise. Control can be direct or indirect. 78 | 79 | “Your license” is the license granted to you for the software under these terms. 80 | 81 | “Use” means anything you do with the software requiring your license. 82 | 83 | “Trademark” means trademarks, service marks, and similar rights. 84 | -------------------------------------------------------------------------------- /pkg/gitrepo/git.go: -------------------------------------------------------------------------------- 1 | package gitrepo 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/go-git/go-git/v5/plumbing" 11 | "github.com/go-git/go-git/v5/plumbing/object" 12 | "golang.org/x/mod/semver" 13 | ) 14 | 15 | type ProjectGitData struct { 16 | Remotes []GitRemote `json:"remotes"` 17 | Contributors []Contributor `json:"contributors"` 18 | } 19 | type Contributor struct { 20 | Name string `json:"name"` 21 | Email string `json:"email"` 22 | Commits uint `json:"commits"` 23 | } 24 | type GitRemote struct { 25 | URL string `json:"url"` 26 | Branches []string `json:"branches"` 27 | Tags []string `json:"tags"` 28 | } 29 | type GitData struct { 30 | Commit string `json:"commit"` 31 | Branch string `json:"branch"` 32 | Message string `json:"message"` 33 | Author string `json:"author"` 34 | Time time.Time `json:"time"` 35 | IsClean bool `json:"clean"` 36 | Parents []string `json:"parents"` 37 | Tags []string `json:"tags"` 38 | } 39 | 40 | func GetProjectGitData(configDir string) (*ProjectGitData, error) { 41 | repo, err := git.PlainOpen(configDir) 42 | if err == git.ErrRepositoryNotExists { 43 | return nil, nil 44 | } 45 | 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | gitData := ProjectGitData{ 51 | Remotes: []GitRemote{}, 52 | Contributors: []Contributor{}, 53 | } 54 | remotes, err := repo.Remotes() 55 | if err != nil { 56 | return nil, err 57 | } 58 | for _, remote := range remotes { 59 | remotePrefix := remote.Config().Name + "/" 60 | url := strings.TrimSuffix(remote.Config().URLs[0], ".git") 61 | branches := []string{} 62 | tags := []string{} 63 | 64 | var mainBranch *plumbing.Reference 65 | 66 | refIter, _ := repo.References() 67 | refIter.ForEach(func(r *plumbing.Reference) error { 68 | if r.Name().IsRemote() { 69 | branch := r.Name().Short() 70 | if strings.HasPrefix(branch, remotePrefix) && !strings.HasSuffix(branch, "HEAD") { 71 | branches = append(branches, strings.TrimPrefix(branch, remotePrefix)) 72 | if strings.HasSuffix(r.Name().Short(), "main") || strings.HasSuffix(r.Name().Short(), "master") { 73 | mainBranch = r 74 | } 75 | } 76 | } 77 | if r.Name().IsTag() { 78 | tags = append(tags, r.Name().Short()) 79 | } 80 | return nil 81 | }) 82 | 83 | contributors := map[string]Contributor{} 84 | if mainBranch != nil { 85 | cIter, err := repo.Log(&git.LogOptions{From: mainBranch.Hash()}) 86 | if err != nil { 87 | return nil, err 88 | } 89 | cIter.ForEach(func(c *object.Commit) error { 90 | commiter := c.Committer.Email 91 | author := c.Author.Email 92 | 93 | contr, ok := contributors[commiter] 94 | if ok { 95 | contr.Commits += 1 96 | contributors[commiter] = contr 97 | } else { 98 | contributors[commiter] = Contributor{ 99 | Name: c.Committer.Name, 100 | Email: c.Committer.Email, 101 | Commits: 1, 102 | } 103 | } 104 | 105 | if author != commiter { 106 | contr, ok := contributors[author] 107 | if ok { 108 | contr.Commits += 1 109 | contributors[author] = contr 110 | } else { 111 | contributors[author] = Contributor{ 112 | Name: c.Author.Name, 113 | Email: c.Author.Email, 114 | Commits: 1, 115 | } 116 | } 117 | } 118 | return nil 119 | }) 120 | } 121 | 122 | for _, contr := range contributors { 123 | gitData.Contributors = append(gitData.Contributors, contr) 124 | } 125 | 126 | gitData.Remotes = append(gitData.Remotes, GitRemote{ 127 | URL: url, 128 | Branches: branches, 129 | Tags: tags, 130 | }) 131 | } 132 | 133 | return &gitData, nil 134 | } 135 | 136 | func GetGitData(configDir string) (*GitData, error) { 137 | repo, err := git.PlainOpen(configDir) 138 | if err == git.ErrRepositoryNotExists { 139 | return nil, nil 140 | } 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | ref, err := repo.Head() 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | commit, err := repo.CommitObject(ref.Hash()) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | parents := []string{} 156 | for _, p := range commit.ParentHashes { 157 | parents = append(parents, p.String()) 158 | } 159 | 160 | w, err := repo.Worktree() 161 | if err != nil { 162 | return nil, err 163 | } 164 | status, err := w.Status() 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | isClean := status.IsClean() 170 | 171 | tags := []string{} 172 | tagRefs, _ := repo.Tags() 173 | tagRefs.ForEach(func(t *plumbing.Reference) error { 174 | if t.Hash() == ref.Hash() { 175 | tagName := t.Name().Short() 176 | if semver.IsValid(tagName) { 177 | tags = append(tags, tagName) 178 | } else { 179 | log.Warnf("Skipping an invalid tag %s that is not a valid semantic version, please check https://semver.org/", tagName) 180 | } 181 | } 182 | return nil 183 | }) 184 | 185 | return &GitData{ 186 | IsClean: isClean, 187 | Commit: commit.ID().String(), 188 | Message: strings.TrimSpace(commit.Message), 189 | Author: commit.Author.String(), 190 | Time: commit.Author.When, 191 | Parents: parents, 192 | Branch: ref.Name().Short(), 193 | Tags: tags, 194 | }, nil 195 | } 196 | -------------------------------------------------------------------------------- /pkg/stackbuilder/flow.go: -------------------------------------------------------------------------------- 1 | package stackbuilder 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "cuelang.org/go/cue" 11 | log "github.com/sirupsen/logrus" 12 | "github.com/stakpak/devx/pkg/stack" 13 | "github.com/stakpak/devx/pkg/utils" 14 | ) 15 | 16 | type Flow struct { 17 | match cue.Value 18 | exclude cue.Value 19 | pipeline []cue.Value 20 | } 21 | 22 | func NewFlow(value cue.Value) (*Flow, error) { 23 | matchValue := value.LookupPath(cue.ParsePath("match")) 24 | if matchValue.Err() != nil { 25 | return nil, matchValue.Err() 26 | } 27 | excludeValue := value.LookupPath(cue.ParsePath("exclude")) 28 | if excludeValue.Err() != nil { 29 | return nil, excludeValue.Err() 30 | } 31 | pipelineValue := value.LookupPath(cue.ParsePath("pipeline")) 32 | if pipelineValue.Err() != nil { 33 | return nil, pipelineValue.Err() 34 | } 35 | 36 | flow := Flow{ 37 | match: matchValue, 38 | exclude: excludeValue, 39 | pipeline: make([]cue.Value, 0), 40 | } 41 | pipelineIter, _ := pipelineValue.List() 42 | for pipelineIter.Next() { 43 | flow.pipeline = append(flow.pipeline, pipelineIter.Value()) 44 | } 45 | 46 | return &flow, nil 47 | } 48 | 49 | func (f *Flow) GetHandledTraits() []string { 50 | traits := []string{} 51 | traitIter, _ := f.match.LookupPath(cue.ParsePath("traits")).Fields() 52 | for traitIter.Next() { 53 | traits = append(traits, traitIter.Label()) 54 | } 55 | return traits 56 | } 57 | 58 | func (f *Flow) Match(component cue.Value) bool { 59 | metadata := component.LookupPath(cue.ParsePath("$metadata")) 60 | 61 | // Check matches 62 | matchIter, _ := f.match.Fields() 63 | for matchIter.Next() { 64 | fieldName := utils.GetLastPathFragment(matchIter.Value()) 65 | componentField := metadata.LookupPath(cue.ParsePath(fieldName)) 66 | 67 | if !componentField.Exists() { 68 | return false 69 | } 70 | 71 | err := matchIter.Value().Subsume(componentField, cue.Final()) 72 | if err != nil { 73 | return false 74 | } 75 | } 76 | 77 | // Check excludes 78 | excludeIter, _ := f.exclude.Fields() 79 | for excludeIter.Next() { 80 | fieldName := utils.GetLastPathFragment(excludeIter.Value()) 81 | componentField := metadata.LookupPath(cue.ParsePath(fieldName)) 82 | 83 | excludedSubfieldsIter, _ := excludeIter.Value().Fields() 84 | for excludedSubfieldsIter.Next() { 85 | excludedSubfieldName := utils.GetLastPathFragment(excludedSubfieldsIter.Value()) 86 | componentSubfield := componentField.LookupPath(cue.ParsePath(excludedSubfieldName)) 87 | 88 | if componentSubfield.Exists() && componentSubfield.Equals(excludedSubfieldsIter.Value()) { 89 | return false 90 | } 91 | } 92 | } 93 | 94 | return true 95 | } 96 | 97 | func (f *Flow) Run(ctx context.Context, stack *stack.Stack, componentId string, component cue.Value) (cue.Value, error) { 98 | if !f.Match(component) { 99 | return component, nil 100 | } 101 | 102 | dependencies, err := stack.GetDependencies(componentId) 103 | if err != nil { 104 | return component, err 105 | } 106 | 107 | // Transform 108 | component = component.FillPath(cue.ParsePath("$dependencies"), dependencies) 109 | for _, transformer := range f.pipeline { 110 | component = component.FillPath(cue.ParsePath(""), transformer) 111 | if component.Err() != nil { 112 | return component, component.Err() 113 | } 114 | } 115 | component = populateGeneratedFields(ctx, component) 116 | if component.Err() != nil { 117 | return component, component.Err() 118 | } 119 | 120 | return component, nil 121 | } 122 | 123 | func populateGeneratedFields(ctx context.Context, value cue.Value) cue.Value { 124 | pathsToFill := []cue.Path{} 125 | valuesToFill := []string{} 126 | utils.Walk(value, func(v cue.Value) bool { 127 | gukuAttr := v.Attribute("guku") 128 | if !v.IsConcrete() && gukuAttr.Err() == nil { 129 | valueToFill := "" 130 | 131 | filePath, found, _ := gukuAttr.Lookup(0, "file") 132 | if found { 133 | if !strings.HasPrefix(filePath, "/") { 134 | configDir := ctx.Value(utils.ConfigDirKey).(string) 135 | filePath = filepath.Join(configDir, filePath) 136 | } 137 | filePath, err := verifyPath(filePath) 138 | if err != nil { 139 | log.Errorf("\nPath error %s\n", err) 140 | return true 141 | } 142 | content, err := os.ReadFile(filePath) 143 | if err != nil { 144 | log.Errorf("\nFile error %s\n", err) 145 | return true 146 | } 147 | valueToFill = string(content) 148 | } 149 | 150 | env, found, _ := gukuAttr.Lookup(0, "env") 151 | if found && valueToFill == "" { 152 | content, found := os.LookupEnv(env) 153 | if !found { 154 | log.Errorf("\nEnvironment variable %s not set\n", env) 155 | return true 156 | } 157 | valueToFill = content 158 | } 159 | 160 | isGenerated, _ := gukuAttr.Flag(0, "generate") 161 | if isGenerated && valueToFill == "" { 162 | valueToFill = "dummy" 163 | } 164 | 165 | if valueToFill != "" { 166 | selectors := v.Path().Selectors() 167 | pathsToFill = append(pathsToFill, cue.MakePath(selectors[3:]...)) 168 | valuesToFill = append(valuesToFill, valueToFill) 169 | } 170 | } 171 | return true 172 | }, nil) 173 | 174 | for i, path := range pathsToFill { 175 | value = value.FillPath(path, valuesToFill[i]) 176 | if value.Err() != nil { 177 | return value 178 | } 179 | } 180 | 181 | return value 182 | } 183 | 184 | func verifyPath(path string) (string, error) { 185 | c := filepath.Clean(path) 186 | r, err := filepath.EvalSymlinks(c) 187 | if err != nil { 188 | return c, fmt.Errorf("unsafe or invalid path specified: %s", err) 189 | } 190 | return r, nil 191 | } 192 | -------------------------------------------------------------------------------- /cmd/devx/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strings" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | "github.com/stakpak/devx/pkg/auth" 13 | ) 14 | 15 | var ( 16 | gitDir string 17 | configDir string 18 | stackPath string 19 | buildersPath string 20 | showDefs bool 21 | showTransformers bool 22 | dryRun bool 23 | noColor bool 24 | noStrict bool 25 | verbosity string 26 | stdout bool 27 | reserve bool 28 | tags []string 29 | ) 30 | var server = auth.ServerConfig{} 31 | 32 | var version = "DEV" 33 | var commit = "X" 34 | 35 | type Version struct { 36 | Version string `json:"version"` 37 | Commit string `json:"commit"` 38 | } 39 | 40 | func init() { 41 | rootCmd.PersistentFlags().StringVarP(&verbosity, "verbosity", "v", "info", "log verbosity *info | debug | error") 42 | rootCmd.PersistentFlags().BoolVarP(&server.Disable, "offline", "D", false, "disable sending telemetry to the Hub") 43 | rootCmd.PersistentFlags().StringVarP(&server.Endpoint, "server", "e", auth.DEVX_CLOUD_ENDPOINT, "server endpoint") 44 | rootCmd.PersistentFlags().StringVarP(&server.Tenant, "tenant", "n", "", "server tenant") 45 | rootCmd.PersistentFlags().StringVarP(&configDir, "project", "p", ".", "project config dir") 46 | rootCmd.PersistentFlags().StringVarP(&gitDir, "git", "g", ".", "project git dir") 47 | rootCmd.PersistentFlags().StringVarP(&stackPath, "stack", "s", "stack", "stack field name in config file") 48 | rootCmd.PersistentFlags().StringVarP(&buildersPath, "builders", "b", "builders", "builders field name in config file") 49 | rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colors") 50 | rootCmd.PersistentFlags().BoolVarP(&noStrict, "no-strict", "S", false, "ignore traits not fulfilled by a builder") 51 | buildCmd.PersistentFlags().BoolVarP(&reserve, "reserve", "r", false, "reserve build resources") 52 | buildCmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "d", false, "output the entire stack after transformation without applying drivers") 53 | buildCmd.PersistentFlags().BoolVarP(&stdout, "stdout", "o", false, "output result to stdout") 54 | discoverCmd.PersistentFlags().BoolVarP(&showDefs, "definitions", "d", false, "show definitions") 55 | discoverCmd.PersistentFlags().BoolVarP(&showTransformers, "transformers", "t", false, "show transformers") 56 | reserveCmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "d", false, "attempt reserving stack resources") 57 | 58 | runCmd.PersistentFlags().BoolVar(&runFlags.Verbose, "verbose", false, "enables verbose mode") 59 | runCmd.PersistentFlags().BoolVar(&runFlags.Parallel, "parallel", false, "executes tasks provided on command line in parallel") 60 | runCmd.PersistentFlags().BoolVar(&runFlags.List, "list", false, "lists tasks with description of current Taskfile") 61 | runCmd.PersistentFlags().BoolVar(&runFlags.ListAll, "list-all", false, "lists tasks with or without a description") 62 | runCmd.PersistentFlags().BoolVar(&runFlags.ListJson, "json", false, "formats task list as json") 63 | runCmd.PersistentFlags().BoolVar(&runFlags.Status, "status", false, "exits with non-zero exit code if any of the given tasks is not up-to-date") 64 | runCmd.PersistentFlags().BoolVar(&runFlags.Force, "force", false, "forces execution even when the task is up-to-date") 65 | runCmd.PersistentFlags().BoolVar(&runFlags.Watch, "watch", false, "enables watch of the given task") 66 | runCmd.PersistentFlags().BoolVar(&runFlags.Dry, "dry", false, "compiles and prints tasks in the order that they would be run, without executing them") 67 | runCmd.PersistentFlags().BoolVar(&runFlags.Summary, "summary", false, "show summary about a task") 68 | runCmd.PersistentFlags().BoolVar(&runFlags.ExitCode, "exit-code", false, "pass-through the exit code of the task command") 69 | runCmd.PersistentFlags().BoolVar(&runFlags.Color, "color", true, "colored output. Enabled by default. Set flag to false to disable") 70 | runCmd.PersistentFlags().DurationVar(&runFlags.Interval, "interval", 0, "interval to watch for changes") 71 | 72 | publishModuleCmd.PersistentFlags().StringArrayVarP(&tags, "tag", "t", []string{}, "tags to publish the module with") 73 | 74 | rootCmd.AddCommand( 75 | buildCmd, 76 | projectCmd, 77 | versionCmd, 78 | diffCmd, 79 | reserveCmd, 80 | runCmd, 81 | loginCmd, 82 | retireCmd, 83 | xrayCmd, 84 | ) 85 | 86 | projectCmd.AddCommand( 87 | initCmd, 88 | updateCmd, 89 | validateCmd, 90 | discoverCmd, 91 | genCmd, 92 | publishCmd, 93 | importCmd, 94 | ) 95 | 96 | publishCmd.AddCommand( 97 | publishPolicyCmd, 98 | publishStackCmd, 99 | publishCatalogCmd, 100 | publishModuleCmd, 101 | ) 102 | 103 | loginCmd.AddCommand( 104 | clearCmd, 105 | infoCmd, 106 | ) 107 | } 108 | 109 | var rootCmd = &cobra.Command{ 110 | Use: "devx", 111 | Short: "DevX cloud native self-service magic", 112 | SilenceUsage: true, 113 | PersistentPreRun: preRun, 114 | } 115 | 116 | var versionCmd = &cobra.Command{ 117 | Use: "version", 118 | Short: "Print the version number of guku DevX", 119 | RunE: func(cmd *cobra.Command, args []string) error { 120 | encoded, err := json.Marshal(Version{ 121 | Version: version, 122 | Commit: commit, 123 | }) 124 | if err != nil { 125 | return err 126 | } 127 | fmt.Println(string(encoded)) 128 | return nil 129 | }, 130 | } 131 | 132 | func preRun(cmd *cobra.Command, args []string) { 133 | setupLogging(cmd, args) 134 | 135 | resp, err := http.Get("https://api.github.com/repos/stakpak/devx/releases?per_page=1") 136 | if err == nil { 137 | releases := []struct { 138 | TagName string `json:"tag_name"` 139 | }{} 140 | 141 | json.NewDecoder(resp.Body).Decode(&releases) 142 | 143 | if len(releases) > 0 { 144 | latestVersion := strings.TrimPrefix(releases[0].TagName, "v") 145 | if latestVersion != version && version != "DEV" { 146 | log.Infof("A newer version of DevX \"v%s\" is available, please upgrade!\n", latestVersion) 147 | } 148 | } 149 | 150 | } 151 | } 152 | 153 | func main() { 154 | if err := rootCmd.Execute(); err != nil { 155 | os.Exit(1) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Documentation](https://devx.stakpak.dev/docs/intro) 2 | 3 | ## Introduction 4 | 5 | DevX is a tool for building lightweight Internal Developer Platforms. Use DevX to build internal standards, prevent misconfigurations early, and enable infrastructure self-service. 6 | 7 | ## Installation 8 | 9 | ### Homebrew 10 | ```bash 11 | brew tap stakpak/stakpak 12 | brew install devx 13 | ``` 14 | 15 | ### Download the binary 16 | 17 | [Releases page](https://github.com/stakpak/devx/releases) 18 | 19 | ### Docker image 20 | ```bash 21 | docker run --rm -v "$(pwd):/app" ghcr.io/stakpak/devx:latest -h 22 | ``` 23 | 24 | ## Quick start 25 | ```bash 26 | ➜ devx project init 27 | ➜ devx project update 28 | ➜ devx project gen 29 | ➜ devx build dev 30 | 🏭 Transforming stack for the "dev" environment... 31 | [compose] applied resources to "build/dev/compose/docker-compose.yml" 32 | [terraform] applied resources to "build/dev/terraform/generated.tf.json" 33 | ``` 34 | 35 | ![demo](assets/demo.gif) 36 | 37 | 38 | ## Usage 39 | 40 | ### Configuration language 41 | We use [CUE](https://cuelang.org/) to write strongly typed configurations. You can now shift YAML typos left, instead of detecting errors when applying configurations. You can easily transform CUE configuration files to and from YAML (CUE is a superset of YAML & JSON). 42 | 43 | [CUE](https://cuelang.org/) is the result of years of experience writing configuration languages at Google, and seeks to improve the developer experience while avoiding some nasty pitfalls. CUE looks like JSON, while making declarative data definition, generation, and validation a breeze. You can find a primer on CUE [here](https://docs.dagger.io/1215/what-is-cue/#understanding-cue). 44 | 45 | 46 | ### Create a stack (by Developers) 47 | You create a stack to define the workload and its dependencies. 48 | ```cue 49 | package main 50 | 51 | import ( 52 | "stakpak.dev/devx/v1" 53 | "stakpak.dev/devx/v1/traits" 54 | ) 55 | 56 | stack: v1.#Stack & { 57 | components: { 58 | cowsay: { 59 | traits.#Workload 60 | containers: default: { 61 | image: "docker/whalesay" 62 | command: ["cowsay"] 63 | args: ["Hello DevX!"] 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ### Create your own stack builders or use community packages (by Platform Engineers) 71 | You can customize how the stack is processed by writing declarative transformers. 72 | ```cue 73 | package main 74 | 75 | import ( 76 | "stakpak.dev/devx/v2alpha1" 77 | "stakpak.dev/devx/v2alpha1/environments" 78 | ) 79 | 80 | builders: v2alpha1.#Environments & { 81 | dev: environments.#Compose 82 | } 83 | ``` 84 | 85 | ### Validation 86 | Validate configurations while writing 87 | ```bash 88 | ➜ devx project validate 89 | 👌 Looks good 90 | ``` 91 | 92 | ### Platform capability discovery 93 | ```bash 94 | ➜ devx project discover --transformers 95 | [🏷️ traits] "stakpak.dev/devx/v1/traits" 96 | traits.#Workload a component that runs a container 97 | traits.#Replicable a component that can be horizontally scaled 98 | traits.#Exposable a component that has endpoints that can be exposed 99 | traits.#Postgres a postgres database 100 | traits.#Helm a helm chart using helm repo 101 | traits.#HelmGit a helm chart using git 102 | traits.#HelmOCI a helm chart using oci 103 | 104 | [🏭 transformers] "stakpak.dev/devx/v1/transformers/argocd" 105 | argocd.#AddHelmRelease add a helm release (requires trait:Helm) 106 | 107 | [🏭 transformers] "stakpak.dev/devx/v1/transformers/compose" 108 | compose.#AddComposeService add a compose service (requires trait:Workload) 109 | compose.#ExposeComposeService expose a compose service ports (requires trait:Exposable) 110 | compose.#AddComposePostgres add a compose service for a postgres database (requires trait:Postgres) 111 | 112 | [🏭 transformers] "stakpak.dev/devx/v1/transformers/terraform" 113 | terraform.#AddHelmRelease add a helm release (requires trait:Helm) 114 | ``` 115 | 116 | ## Package management 117 | 118 | You can publish and share CUE packages directly through git repositories. 119 | 120 | ### Create a new packages 121 | Create a new repository to store your packages (you can host multiple packages per repository). 122 | 123 | ```bash 124 | cue.mod 125 | └── module.cue # module: "domain.com/platform" 126 | subpackage 127 | └── file.cue 128 | file.cue 129 | ``` 130 | 131 | ### Add the package to `module.cue` 132 | ```cue 133 | module: "" 134 | 135 | packages: [ 136 | "github.com//@:", 137 | ] 138 | ``` 139 | 140 | ### For private packages (optional) 141 | ```bash 142 | export GIT_USERNAME="username" 143 | export GIT_PASSWORD="password" 144 | ``` 145 | 146 | ### Update packages (pulling updates will replace existing packages) 147 | ``` 148 | ➜ devx project update 149 | ``` 150 | 151 | ## Contributors 152 | 153 | 154 | 155 | 162 | 169 | 176 | 183 | 190 | 191 |
156 | 157 | George/ 158 |
159 | George 160 |
161 |
163 | 164 | Mohamed 165 |
166 | Mohamed Hamza 167 |
168 |
170 | 171 | Lam 172 |
173 | Lam Tran 174 |
175 |
177 | 178 | Ahmed 179 |
180 | Ahmed Hesham 181 |
182 |
184 | 185 | John 186 |
187 | John Gosset 188 |
189 |
192 | -------------------------------------------------------------------------------- /pkg/taskfile/run.go: -------------------------------------------------------------------------------- 1 | package taskfile 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "cuelang.org/go/cue" 14 | cueyaml "cuelang.org/go/encoding/yaml" 15 | 16 | "github.com/stakpak/devx/pkg/auth" 17 | "github.com/stakpak/devx/pkg/gitrepo" 18 | "github.com/stakpak/devx/pkg/stackbuilder" 19 | "github.com/stakpak/devx/pkg/utils" 20 | 21 | "mvdan.cc/sh/v3/syntax" 22 | 23 | "github.com/acarl005/stripansi" 24 | "github.com/go-task/task/v3" 25 | taskargs "github.com/go-task/task/v3/args" 26 | 27 | "github.com/go-task/task/v3/taskfile" 28 | 29 | log "github.com/sirupsen/logrus" 30 | ) 31 | 32 | type RunFlags struct { 33 | List bool 34 | ListAll bool 35 | ListJson bool 36 | Status bool 37 | Force bool 38 | Watch bool 39 | Dry bool 40 | Summary bool 41 | ExitCode bool 42 | Parallel bool 43 | Verbose bool 44 | Color bool 45 | Interval time.Duration 46 | } 47 | 48 | func Run(configDir string, buildersPath string, server auth.ServerConfig, runFlags RunFlags, environment string, doubleDashPos int, args []string) error { 49 | overlays, err := utils.GetOverlays(configDir) 50 | if err != nil { 51 | return err 52 | } 53 | value, stackId, _ := utils.LoadProject(configDir, &overlays) 54 | err = value.Validate() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | buildersValue := value.LookupPath(cue.ParsePath(buildersPath)) 60 | if !buildersValue.Exists() { 61 | return fmt.Errorf("missing builders field") 62 | } 63 | 64 | builders, err := stackbuilder.NewEnvironments(buildersValue) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | builder, ok := builders[environment] 70 | if !ok { 71 | return fmt.Errorf("environment %s was not found", environment) 72 | } 73 | 74 | if builder.Taskfile == nil { 75 | return fmt.Errorf("no taskfile definition found in environment %s", environment) 76 | } 77 | 78 | log.Debug(builder.Taskfile) 79 | err = builder.Taskfile.Validate(cue.Concrete(true)) 80 | if err != nil { 81 | log.Error(err) 82 | return err 83 | } 84 | 85 | taskFileContent, err := cueyaml.Encode(*builder.Taskfile) 86 | if err != nil { 87 | log.Error(err) 88 | return err 89 | } 90 | 91 | taskFile, err := os.CreateTemp(configDir, ".taskfile-*.yml") 92 | if err != nil { 93 | log.Error(err) 94 | return err 95 | } 96 | defer os.RemoveAll(taskFile.Name()) 97 | 98 | _, err = taskFile.Write(taskFileContent) 99 | if err != nil { 100 | log.Error(err) 101 | return err 102 | } 103 | 104 | outBuff := new(bytes.Buffer) 105 | errBuff := new(bytes.Buffer) 106 | outWriter := io.MultiWriter(os.Stdout, outBuff) 107 | errWriter := io.MultiWriter(os.Stderr, errBuff) 108 | 109 | e := task.Executor{ 110 | Force: runFlags.Force, 111 | Watch: runFlags.Watch, 112 | Verbose: runFlags.Verbose, 113 | Silent: true, 114 | Dir: "", 115 | Dry: runFlags.Dry, 116 | Entrypoint: taskFile.Name(), 117 | Summary: runFlags.Summary, 118 | Parallel: runFlags.Parallel, 119 | Color: runFlags.Color, 120 | Concurrency: 0, 121 | Interval: runFlags.Interval, 122 | 123 | Stdin: os.Stdin, 124 | Stdout: outWriter, 125 | Stderr: errWriter, 126 | 127 | OutputStyle: taskfile.Output{ 128 | Name: "", 129 | Group: taskfile.OutputGroup{ 130 | Begin: "", 131 | End: "", 132 | }, 133 | }, 134 | } 135 | 136 | var listOptions = task.NewListOptions(runFlags.List, runFlags.ListAll, runFlags.ListJson) 137 | if err := listOptions.Validate(); err != nil { 138 | log.Error(err) 139 | return err 140 | } 141 | 142 | err = e.Setup() 143 | if err != nil { 144 | log.Error(err) 145 | return err 146 | } 147 | 148 | if len(args) == 0 && !listOptions.ShouldListTasks() { 149 | listOptions.ListAllTasks = true 150 | } 151 | 152 | if listOptions.ShouldListTasks() { 153 | if foundTasks, err := e.ListTasks(listOptions); !foundTasks || err != nil { 154 | log.Error(err) 155 | return err 156 | } 157 | return nil 158 | } 159 | 160 | var tasks []string 161 | cliArgs := "" 162 | if doubleDashPos == -1 { 163 | tasks = args 164 | } else { 165 | var quotedCliArgs []string 166 | for _, arg := range args[doubleDashPos-1:] { 167 | quotedCliArg, err := syntax.Quote(arg, syntax.LangBash) 168 | if err != nil { 169 | log.Error(err) 170 | return nil 171 | } 172 | quotedCliArgs = append(quotedCliArgs, quotedCliArg) 173 | } 174 | tasks = args[:doubleDashPos-1] 175 | cliArgs = strings.Join(quotedCliArgs, " ") 176 | } 177 | 178 | calls, globals := taskargs.ParseV3(tasks...) 179 | 180 | globals.Set("CLI_ARGS", taskfile.Var{Static: cliArgs}) 181 | e.Taskfile.Vars.Merge(globals) 182 | e.InterceptInterruptSignals() 183 | 184 | taskMap := map[string]map[string]string{} 185 | for _, call := range calls { 186 | args := map[string]string{} 187 | if call.Vars != nil { 188 | for k, v := range call.Vars.Mapping { 189 | args[k] = v.Static 190 | if v.Sh != "" { 191 | args[k] = v.Sh 192 | } 193 | } 194 | } 195 | taskMap[call.Task] = args 196 | } 197 | globalsMap := map[string]string{} 198 | if globals != nil { 199 | for k, v := range globals.Mapping { 200 | globalsMap[k] = v.Static 201 | if v.Sh != "" { 202 | globalsMap[k] = v.Sh 203 | } 204 | } 205 | } 206 | source := string(taskFileContent) 207 | 208 | if err := e.Run(context.TODO(), calls...); err != nil { 209 | taskErr := fmt.Sprintf("%s\n%s", err.Error(), stripansi.Strip(errBuff.String())) 210 | taskOutput := stripansi.Strip(outBuff.String()) 211 | if taskId, err := sendTask(configDir, server, environment, stackId, string(source), taskMap, globalsMap, &taskErr, &taskOutput); err != nil { 212 | log.Error("failed to save task run data: ", err.Error()) 213 | } else { 214 | log.Infof("\nSaved failed task run at %s/tasks/%s\n", server.Endpoint, taskId) 215 | } 216 | return err 217 | } 218 | 219 | if auth.IsLoggedIn(server) { 220 | taskOutput := stripansi.Strip(outBuff.String()) 221 | if taskId, err := sendTask(configDir, server, environment, stackId, string(source), taskMap, globalsMap, nil, &taskOutput); err != nil { 222 | log.Error("failed to save task run data: ", err.Error()) 223 | } else { 224 | log.Infof("\nCreated task run at %s/tasks/%s\n", server.Endpoint, taskId) 225 | } 226 | } 227 | return nil 228 | } 229 | 230 | type TaskData struct { 231 | Stack string `json:"stack"` 232 | Identity string `json:"identity,omitempty"` 233 | Environment string `json:"environment"` 234 | Git *gitrepo.GitData `json:"git,omitempty"` 235 | Error *string `json:"error"` 236 | Output *string `json:"output"` 237 | Source string `json:"source"` 238 | Tasks map[string]map[string]string `json:"tasks"` 239 | Globals map[string]string `json:"globals"` 240 | } 241 | 242 | func sendTask(configDir string, server auth.ServerConfig, environment string, stack string, source string, tasks map[string]map[string]string, globals map[string]string, taskError *string, taskOutput *string) (string, error) { 243 | taskData := TaskData{ 244 | Stack: stack, 245 | Identity: "", 246 | Environment: environment, 247 | Git: nil, 248 | Source: source, 249 | Tasks: tasks, 250 | Globals: globals, 251 | Error: taskError, 252 | Output: taskOutput, 253 | } 254 | 255 | gitData, err := gitrepo.GetGitData(configDir) 256 | if err != nil { 257 | return "", err 258 | } 259 | taskData.Git = gitData 260 | 261 | data, err := utils.SendData(server, "tasks", &taskData) 262 | if err != nil { 263 | return "", err 264 | } 265 | 266 | taskResponse := make(map[string]string) 267 | err = json.Unmarshal(data, &taskResponse) 268 | if err != nil { 269 | return "", err 270 | } 271 | 272 | return taskResponse["id"], nil 273 | } 274 | -------------------------------------------------------------------------------- /pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "cuelang.org/go/cue" 11 | "cuelang.org/go/cue/errors" 12 | "cuelang.org/go/cue/format" 13 | "github.com/fatih/color" 14 | "github.com/go-git/go-git/v5" 15 | "github.com/go-git/go-git/v5/plumbing" 16 | log "github.com/sirupsen/logrus" 17 | 18 | "github.com/stakpak/devx/pkg/auth" 19 | "github.com/stakpak/devx/pkg/drivers" 20 | "github.com/stakpak/devx/pkg/project" 21 | "github.com/stakpak/devx/pkg/stack" 22 | "github.com/stakpak/devx/pkg/stackbuilder" 23 | "github.com/stakpak/devx/pkg/utils" 24 | ) 25 | 26 | func Run(environment string, configDir string, stackPath string, buildersPath string, reserve bool, dryRun bool, server auth.ServerConfig, noStrict bool, stdout bool) error { 27 | ctx := context.Background() 28 | ctx = context.WithValue(ctx, utils.ConfigDirKey, configDir) 29 | ctx = context.WithValue(ctx, utils.DryRunKey, dryRun) 30 | 31 | if err := project.Update(configDir, server); err != nil { 32 | return err 33 | } 34 | 35 | stack, builder, err := buildStack(ctx, environment, configDir, stackPath, buildersPath, noStrict) 36 | if err != nil { 37 | if auth.IsLoggedIn(server) { 38 | details := errors.Details(err, nil) 39 | if buildId, err := stack.SendBuild(configDir, server, environment, &details); err != nil { 40 | log.Error("failed to save build data: ", err.Error()) 41 | } else { 42 | log.Infof("\nSaved failed build at %s/builds/%s\n", server.Endpoint, buildId) 43 | } 44 | } 45 | return err 46 | } 47 | 48 | if dryRun { 49 | log.Info(stack.GetComponents()) 50 | return nil 51 | } 52 | 53 | for id, driver := range drivers.NewDriversMap(environment, builder.DriverConfig) { 54 | if err := driver.ApplyAll(stack, stdout); err != nil { 55 | newErr := fmt.Errorf("error running %s driver: %s", id, errors.Details(err, nil)) 56 | if auth.IsLoggedIn(server) { 57 | details := newErr.Error() 58 | if buildId, err := stack.SendBuild(configDir, server, environment, &details); err != nil { 59 | log.Error("failed to save build data: ", err.Error()) 60 | } else { 61 | log.Infof("\nSaved failed build at %s/builds/%s", server.Endpoint, buildId) 62 | } 63 | } 64 | return newErr 65 | } 66 | } 67 | 68 | if auth.IsLoggedIn(server) { 69 | log.Info("📤 Analyzing & uploading build data...") 70 | buildId, err := stack.SendBuild(configDir, server, environment, nil) 71 | if err != nil { 72 | return err 73 | } 74 | log.Infof("\nCreated build at %s/builds/%s", server.Endpoint, buildId) 75 | 76 | if reserve { 77 | err := Reserve(buildId, server, dryRun) 78 | if err != nil { 79 | return err 80 | } 81 | } else { 82 | log.Info("To reserve build resources run:") 83 | log.Infof("devx reserve %s\n", buildId) 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func Diff(target string, environment string, configDir string, stackPath string, buildersPath string, server auth.ServerConfig, noStrict bool) error { 91 | log.Infof("📍 Processing target stack @ %s", target) 92 | targetDir, err := os.MkdirTemp("", "devx-target-*") 93 | if err != nil { 94 | return err 95 | } 96 | defer os.RemoveAll(targetDir) 97 | 98 | repo, err := git.PlainClone(targetDir, false, &git.CloneOptions{ 99 | URL: configDir, 100 | }) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | w, err := repo.Worktree() 106 | if err != nil { 107 | return err 108 | } 109 | 110 | hash, err := repo.ResolveRevision(plumbing.Revision(target)) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | err = w.Checkout(&git.CheckoutOptions{ 116 | Hash: *hash, 117 | }) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | err = project.Update(targetDir, server) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | targetCtx := context.Background() 128 | targetCtx = context.WithValue(targetCtx, utils.ConfigDirKey, targetDir) 129 | targetCtx = context.WithValue(targetCtx, utils.DryRunKey, true) 130 | targetStack, _, err := buildStack(targetCtx, environment, targetDir, stackPath, buildersPath, noStrict) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | log.Info("\n📍 Processing current stack") 136 | err = project.Update(configDir, server) 137 | if err != nil { 138 | return err 139 | } 140 | currentCtx := context.Background() 141 | currentCtx = context.WithValue(currentCtx, utils.ConfigDirKey, configDir) 142 | currentCtx = context.WithValue(currentCtx, utils.DryRunKey, true) 143 | currentStack, _, err := buildStack(currentCtx, environment, configDir, stackPath, buildersPath, noStrict) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | currentValues := utils.GetLeaves(currentStack.GetComponents(), false) 149 | targetValues := utils.GetLeaves(targetStack.GetComponents(), false) 150 | 151 | remColor := color.New(color.FgRed) 152 | addColor := color.New(color.FgGreen) 153 | updColor := color.New(color.FgYellow) 154 | log.Info("\n🔬 Diff") 155 | foundDiff := false 156 | ci, ti := 0, 0 157 | for ci < len(currentValues) || ti < len(targetValues) { 158 | if ci == len(currentValues) { 159 | tv := targetValues[ti] 160 | log.Infof("\t%s %s: %s", remColor.Sprintf("-"), tv.Path, tv.Value) 161 | foundDiff = true 162 | ti++ 163 | continue 164 | } 165 | if ti == len(targetValues) { 166 | cv := currentValues[ci] 167 | log.Infof("\t%s %s: %s", addColor.Sprintf("+"), cv.Path, cv.Value) 168 | foundDiff = true 169 | ci++ 170 | continue 171 | } 172 | 173 | cv := currentValues[ci] 174 | tv := targetValues[ti] 175 | switch strings.Compare(cv.Path, tv.Path) { 176 | case 0: 177 | if strings.Compare(cv.Value, tv.Value) != 0 { 178 | log.Infof("\t%s %s: %s -> %s", updColor.Sprintf("~"), cv.Path, tv.Value, cv.Value) 179 | foundDiff = true 180 | } 181 | ci++ 182 | ti++ 183 | case -1: 184 | log.Infof("\t%s %s: %s", addColor.Sprintf("+"), cv.Path, cv.Value) 185 | foundDiff = true 186 | ci++ 187 | case 1: 188 | log.Infof("\t%s %s: %s", remColor.Sprintf("-"), tv.Path, tv.Value) 189 | foundDiff = true 190 | ti++ 191 | } 192 | } 193 | 194 | if !foundDiff { 195 | log.Infof("No changes found") 196 | } 197 | 198 | return nil 199 | } 200 | 201 | func buildStack(ctx context.Context, environment string, configDir string, stackPath string, buildersPath string, noStrict bool) (*stack.Stack, *stackbuilder.StackBuilder, error) { 202 | log.Infof("🏗️ Loading stack...") 203 | 204 | overlays, err := utils.GetOverlays(configDir) 205 | if err != nil { 206 | return nil, nil, err 207 | } 208 | value, stackId, depIds := utils.LoadProject(configDir, &overlays) 209 | 210 | buildSource, err := format.Node(value.Syntax(), format.Simplify()) 211 | if err != nil { 212 | log.Fatal(err) 213 | } 214 | 215 | emptyStack := stack.Stack{ 216 | ID: stackId, 217 | DepIDs: depIds, 218 | BuildSource: string(buildSource), 219 | } 220 | emptyStack.AddComponents(value.Context().CompileString("{}")) 221 | 222 | log.Info("👀 Validating stack...") 223 | err = project.ValidateProject(value, stackPath, buildersPath, noStrict) 224 | if err != nil { 225 | return &emptyStack, nil, err 226 | } 227 | 228 | builders, err := stackbuilder.NewEnvironments(value.LookupPath(cue.ParsePath(buildersPath))) 229 | if err != nil { 230 | return &emptyStack, nil, err 231 | } 232 | 233 | builder, ok := builders[environment] 234 | if !ok { 235 | return &emptyStack, nil, fmt.Errorf("environment %s was not found", environment) 236 | } 237 | 238 | stack, err := stack.NewStack(value.LookupPath(cue.ParsePath(stackPath)), stackId, depIds) 239 | if err != nil { 240 | return &emptyStack, nil, err 241 | } 242 | stack.BuildSource = emptyStack.BuildSource 243 | 244 | err = builder.TransformStack(ctx, stack) 245 | if err != nil { 246 | return stack, nil, err 247 | } 248 | 249 | return stack, builder, nil 250 | } 251 | 252 | func Reserve(buildId string, server auth.ServerConfig, dryRun bool) error { 253 | if !auth.IsLoggedIn(server) { 254 | return fmt.Errorf("must be logged in to be able to reserve resources") 255 | } 256 | 257 | reserveData := struct { 258 | DryRun bool `json:"dryRun,omitempty"` 259 | }{ 260 | DryRun: dryRun, 261 | } 262 | 263 | apiPath := path.Join("builds", buildId, "reserve") 264 | data, err := utils.SendData(server, apiPath, reserveData) 265 | if err != nil { 266 | log.Debug(string(data)) 267 | return err 268 | } 269 | 270 | if dryRun { 271 | log.Infof("Looks good, you can reserve this build!") 272 | return nil 273 | } 274 | 275 | log.Infof("Reserved build with id %s", buildId) 276 | 277 | return nil 278 | } 279 | 280 | func Retire(buildId string, server auth.ServerConfig) error { 281 | if !auth.IsLoggedIn(server) { 282 | return fmt.Errorf("must be logged in to be able to retire resources") 283 | } 284 | 285 | apiPath := path.Join("builds", buildId, "retire") 286 | data, err := utils.SendData(server, apiPath, map[string]interface{}{}) 287 | if err != nil { 288 | log.Debug(string(data)) 289 | return err 290 | } 291 | 292 | log.Infof("Retired build with id %s", buildId) 293 | 294 | return nil 295 | } 296 | -------------------------------------------------------------------------------- /pkg/stackbuilder/stackbuilder.go: -------------------------------------------------------------------------------- 1 | package stackbuilder 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "cuelang.org/go/cue" 12 | "cuelang.org/go/cue/errors" 13 | "github.com/schollz/progressbar/v3" 14 | log "github.com/sirupsen/logrus" 15 | "github.com/stakpak/devx/pkg/stack" 16 | "github.com/stakpak/devx/pkg/utils" 17 | ) 18 | 19 | type Environments = map[string]*StackBuilder 20 | 21 | type StackBuilder struct { 22 | DriverConfig map[string]DriverConfig 23 | AdditionalComponents *cue.Value 24 | Flows []*Flow 25 | Taskfile *cue.Value 26 | } 27 | type DriverConfig struct { 28 | Output DriverOutput `json:"output"` 29 | } 30 | type DriverOutput struct { 31 | Dir string `json:"dir"` 32 | File string `json:"file"` 33 | } 34 | 35 | func NewEnvironments(value cue.Value) (Environments, error) { 36 | environments := map[string]*StackBuilder{} 37 | 38 | envIter, err := value.Fields() 39 | if err != nil { 40 | return environments, err 41 | } 42 | 43 | for envIter.Next() { 44 | name := strings.Trim(utils.GetLastPathFragment(envIter.Value()), "\"") 45 | environments[name], err = NewStackBuilder(name, envIter.Value()) 46 | if err != nil { 47 | return environments, err 48 | } 49 | } 50 | 51 | return environments, nil 52 | } 53 | 54 | func NewStackBuilder(environment string, value cue.Value) (*StackBuilder, error) { 55 | isV2Builder := false 56 | envName := value.LookupPath(cue.ParsePath("environment")) 57 | if envName.Exists() { 58 | isV2Builder = true 59 | } 60 | 61 | flows := value.LookupPath(cue.ParsePath("flows")) 62 | if flows.Err() != nil { 63 | return nil, flows.Err() 64 | } 65 | 66 | var additionalComponents *cue.Value 67 | additionalComponentsPath := "additionalComponents" 68 | if isV2Builder { 69 | additionalComponentsPath = "components" 70 | } 71 | additionalComponentsValue := value.LookupPath(cue.ParsePath(additionalComponentsPath)) 72 | if additionalComponentsValue.Exists() { 73 | additionalComponents = &additionalComponentsValue 74 | } 75 | 76 | driverConfig := map[string]DriverConfig{} 77 | driverConfigValue := value.LookupPath(cue.ParsePath("drivers")) 78 | if driverConfigValue.Exists() { 79 | driverIter, err := driverConfigValue.Fields() 80 | if err != nil { 81 | return nil, err 82 | } 83 | for driverIter.Next() { 84 | driverConfig[driverIter.Label()] = DriverConfig{} 85 | configIter, err := driverIter.Value().Fields() 86 | if err != nil { 87 | return nil, err 88 | } 89 | for configIter.Next() { 90 | switch configIter.Value().Kind() { 91 | case cue.StringKind: 92 | value, err := configIter.Value().String() 93 | if err != nil { 94 | return nil, err 95 | } 96 | dir, file := filepath.Split(value) 97 | if filepath.Ext(file) == "" { 98 | dir, file = value, "" 99 | } 100 | driverConfig[driverIter.Label()] = DriverConfig{ 101 | Output: DriverOutput{ 102 | Dir: dir, 103 | File: file, 104 | }, 105 | } 106 | case cue.StructKind: 107 | dirValue := configIter.Value().LookupPath(cue.ParsePath("dir")) 108 | fileValue := configIter.Value().LookupPath(cue.ParsePath("file")) 109 | 110 | var dirPaths []string 111 | var file string 112 | 113 | err := dirValue.Decode(&dirPaths) 114 | if err != nil { 115 | return nil, err 116 | } 117 | err = fileValue.Decode(&file) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | driverConfig[driverIter.Label()] = DriverConfig{ 123 | Output: DriverOutput{ 124 | Dir: filepath.Join(dirPaths...), 125 | File: file, 126 | }, 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | if !isV2Builder { 134 | driverDefaults := map[string]string{ 135 | "compose": "docker-compose.yml", 136 | "gitlab": ".gitlab-ci.yml", 137 | "terraform": "generated.tf.json", 138 | "github": "", 139 | "kubernetes": "", 140 | } 141 | for name, file := range driverDefaults { 142 | config, ok := driverConfig[name] 143 | if !ok { 144 | driverConfig[name] = DriverConfig{ 145 | Output: DriverOutput{"", ""}, 146 | } 147 | } 148 | if config.Output.Dir == "" && config.Output.File == "" { 149 | config.Output.Dir = filepath.Join("build", environment, name) 150 | } 151 | if config.Output.File == "" { 152 | config.Output.File = file 153 | } 154 | driverConfig[name] = config 155 | } 156 | } 157 | 158 | var taskfile *cue.Value 159 | taskfilePath := "taskfile" 160 | if isV2Builder { 161 | taskfileValue := value.LookupPath(cue.ParsePath(taskfilePath)) 162 | if taskfileValue.Exists() { 163 | taskfile = &taskfileValue 164 | } 165 | } 166 | 167 | stackBuilder := StackBuilder{ 168 | DriverConfig: driverConfig, 169 | AdditionalComponents: additionalComponents, 170 | Flows: make([]*Flow, 0), 171 | Taskfile: taskfile, 172 | } 173 | 174 | if isV2Builder { 175 | flowIter, err := flows.Fields() 176 | if err != nil { 177 | return nil, err 178 | } 179 | for flowIter.Next() { 180 | flow, err := NewFlow(flowIter.Value()) 181 | if err != nil { 182 | return nil, err 183 | } 184 | stackBuilder.Flows = append(stackBuilder.Flows, flow) 185 | } 186 | } else { 187 | flowIter, err := flows.List() 188 | if err != nil { 189 | return nil, err 190 | } 191 | for flowIter.Next() { 192 | flow, err := NewFlow(flowIter.Value()) 193 | if err != nil { 194 | return nil, err 195 | } 196 | stackBuilder.Flows = append(stackBuilder.Flows, flow) 197 | } 198 | } 199 | 200 | return &stackBuilder, nil 201 | } 202 | 203 | func (sb *StackBuilder) TransformStack(ctx context.Context, stack *stack.Stack) error { 204 | if sb.AdditionalComponents != nil { 205 | stack.AddComponents(*sb.AdditionalComponents) 206 | } 207 | orderedTasks := stack.GetTasks() 208 | 209 | total := 0 210 | for _, flow := range sb.Flows { 211 | total += len(orderedTasks) * len(flow.pipeline) 212 | } 213 | 214 | progressWriter := log.StandardLogger().Out 215 | if log.GetLevel() == log.ErrorLevel { 216 | progressWriter = io.Discard 217 | } 218 | bar := progressbar.NewOptions64( 219 | int64(total), 220 | progressbar.OptionSetDescription("🏭 Transforming stack"), 221 | progressbar.OptionSetWriter(progressWriter), 222 | progressbar.OptionSetWidth(10), 223 | progressbar.OptionThrottle(65*time.Millisecond), 224 | progressbar.OptionShowCount(), 225 | progressbar.OptionShowIts(), 226 | progressbar.OptionOnCompletion(func() { 227 | log.Info() 228 | }), 229 | progressbar.OptionSpinnerType(14), 230 | progressbar.OptionFullWidth(), 231 | progressbar.OptionSetRenderBlankState(true), 232 | ) 233 | defer bar.Finish() 234 | for _, componentId := range orderedTasks { 235 | component, err := stack.GetComponent(componentId) 236 | if err != nil { 237 | return err 238 | } 239 | for _, flow := range sb.Flows { 240 | component, err = flow.Run(ctx, stack, componentId, component) 241 | if err != nil { 242 | return err 243 | } 244 | if !stack.HasConcreteResourceDrivers(component) { 245 | return fmt.Errorf( 246 | "component %s resources do not have concrete drivers", 247 | componentId, 248 | ) 249 | } 250 | bar.Add(len(flow.pipeline)) 251 | } 252 | if !stack.IsConcreteComponent(component) { 253 | err := component.Validate(cue.Concrete(true), cue.All()) 254 | log.Debugln(component) 255 | return fmt.Errorf("component %s is not concrete after transformation:\n%s", componentId, errors.Details(err, nil)) 256 | } 257 | stack.UpdateComponent(componentId, component) 258 | } 259 | return nil 260 | } 261 | 262 | func CheckTraitFulfillment(builders Environments, stack *stack.Stack) error { 263 | compIter, err := stack.GetComponents().Fields() 264 | if err != nil { 265 | return err 266 | } 267 | // component -> trait -> env -> handled 268 | compFlowMap := map[string]map[string]map[string]bool{} 269 | unmatched := []string{} 270 | for compIter.Next() { 271 | component := compIter.Label() 272 | compFlowMap[component] = map[string]map[string]bool{} 273 | traitIter, _ := compIter.Value().LookupPath(cue.ParsePath("$metadata.traits")).Fields() 274 | for traitIter.Next() { 275 | compFlowMap[component][traitIter.Label()] = map[string]bool{} 276 | } 277 | for env, builder := range builders { 278 | for trait := range compFlowMap[component] { 279 | compFlowMap[component][trait][env] = false 280 | } 281 | for _, flow := range builder.Flows { 282 | if flow.Match(compIter.Value()) { 283 | for _, trait := range flow.GetHandledTraits() { 284 | compFlowMap[component][trait][env] = true 285 | } 286 | } 287 | } 288 | for trait := range compFlowMap[component] { 289 | if !compFlowMap[component][trait][env] { 290 | unmatched = append(unmatched, fmt.Sprintf("%s/#%s in %s", component, trait, env)) 291 | } 292 | } 293 | } 294 | } 295 | if len(unmatched) > 0 { 296 | return fmt.Errorf("trait is not fulfilled by any flow: \n %s", strings.Join(unmatched, "\n ")) 297 | } 298 | return nil 299 | } 300 | -------------------------------------------------------------------------------- /pkg/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | "time" 13 | 14 | log "github.com/sirupsen/logrus" 15 | 16 | "github.com/pkg/browser" 17 | "gopkg.in/yaml.v3" 18 | ) 19 | 20 | const DEVX_CLOUD_ENDPOINT = "https://hub.stakpak.dev" 21 | 22 | type ServerConfig struct { 23 | Disable bool 24 | Tenant string 25 | Endpoint string 26 | } 27 | 28 | type ConfigFile map[string]Config 29 | 30 | type Config struct { 31 | Default bool `yaml:"default,omitempty"` 32 | Endpoint *string `yaml:"endpoint,omitempty"` 33 | Username *string `yaml:"username,omitempty"` 34 | Password *string `yaml:"password,omitempty"` 35 | Token *string `yaml:"token,omitempty"` 36 | TokenExpiresOn *time.Time `yaml:"tokenExpiresOn,omitempty"` 37 | } 38 | 39 | type TokenResponse struct { 40 | AccessToken string `json:"access_token"` 41 | ExpiresIn uint `json:"expires_in"` 42 | Scope string `json:"scope,omitempty"` 43 | TokenType string `json:"token_type"` 44 | } 45 | 46 | func IsLoggedIn(server ServerConfig) bool { 47 | var cfg Config 48 | 49 | if server.Disable { 50 | return false 51 | } 52 | 53 | if server.Tenant == "" { 54 | tenant, _, err := loadDefaultConfig() 55 | if err != nil { 56 | return false 57 | } 58 | server.Tenant = tenant 59 | } 60 | 61 | cfgFile, err := loadConfig() 62 | if err != nil { 63 | return false 64 | } 65 | 66 | if _, ok := cfgFile[server.Tenant]; ok { 67 | cfg = cfgFile[server.Tenant] 68 | } 69 | 70 | if cfg.TokenExpiresOn != nil && cfg.TokenExpiresOn.After(time.Now()) { 71 | return true 72 | } 73 | 74 | log.Info("Found Hub configurations but without a valid token, proceeding offline\nTry again after logging in using: devx auth --tenant ") 75 | 76 | return false 77 | } 78 | 79 | func IsValidToken(cfg Config) bool { 80 | return cfg.TokenExpiresOn != nil && cfg.TokenExpiresOn.After(time.Now()) 81 | } 82 | 83 | func RefreshToken(server ServerConfig) (Config, error) { 84 | err := Login(server) 85 | if err != nil { 86 | return Config{}, err 87 | } 88 | 89 | cfgMap, err := loadConfig() 90 | if err != nil { 91 | return Config{}, err 92 | } 93 | 94 | return cfgMap[server.Tenant], nil 95 | } 96 | 97 | func GetToken(server ServerConfig) (*string, error) { 98 | cfgMap, err := loadConfig() 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | cfg, exists := cfgMap[server.Tenant] 104 | if !exists || !IsValidToken(cfg) { 105 | cfg, err = RefreshToken(server) 106 | if err != nil { 107 | return nil, err 108 | } 109 | } 110 | return cfg.Token, nil 111 | } 112 | 113 | func GetDefaultToken(server ServerConfig) (string, *string, error) { 114 | tenant, cfg, err := loadDefaultConfig() 115 | if err != nil { 116 | return "", nil, err 117 | } 118 | if tenant == "" { 119 | return "", nil, nil 120 | } 121 | 122 | server.Tenant = tenant 123 | if !IsValidToken(cfg) { 124 | cfg, err = RefreshToken(server) 125 | if err != nil { 126 | return "", nil, err 127 | } 128 | } 129 | return tenant, cfg.Token, nil 130 | } 131 | 132 | func Clear(server ServerConfig) error { 133 | return deleteConfig() 134 | } 135 | 136 | func Info(server ServerConfig) error { 137 | var cfg Config 138 | 139 | if server.Tenant == "" { 140 | tenant, _, err := loadDefaultConfig() 141 | if err != nil { 142 | return err 143 | } 144 | server.Tenant = tenant 145 | } 146 | 147 | cfgFile, err := loadConfig() 148 | if err != nil { 149 | return err 150 | } 151 | 152 | if _, ok := cfgFile[server.Tenant]; ok { 153 | cfg = cfgFile[server.Tenant] 154 | } 155 | 156 | log.Infof(`Auth info: 157 | Tenant: %s 158 | Server endpoint: %s 159 | Token valid until: %s 160 | `, 161 | server.Tenant, 162 | *cfg.Endpoint, 163 | cfg.TokenExpiresOn, 164 | ) 165 | 166 | return nil 167 | } 168 | 169 | func Login(server ServerConfig) error { 170 | if server.Endpoint == "" { 171 | server.Endpoint = DEVX_CLOUD_ENDPOINT 172 | } 173 | 174 | if server.Tenant == "" { 175 | return fmt.Errorf("--tenant is required") 176 | } 177 | 178 | cfgFile, err := loadConfig() 179 | if err != nil { 180 | return err 181 | } 182 | 183 | cfg := Config{} 184 | if _, ok := cfgFile[server.Tenant]; ok { 185 | cfg = cfgFile[server.Tenant] 186 | } 187 | 188 | if len(cfgFile) == 0 { 189 | cfg.Default = true 190 | } 191 | 192 | if cfg.Endpoint == nil { 193 | cfg.Endpoint = &server.Endpoint 194 | } 195 | 196 | if cfg.Token != nil && cfg.TokenExpiresOn != nil && cfg.TokenExpiresOn.After(time.Now()) { 197 | log.Info("Already logged in") 198 | return nil 199 | } 200 | 201 | if cfg.Username != nil && cfg.Password != nil { 202 | log.Info("Non interactive login") 203 | return nil 204 | } 205 | 206 | loginURL := fmt.Sprintf( 207 | "%s/login?redirect_uri=%s&tenant=%s", 208 | *cfg.Endpoint, 209 | url.QueryEscape("http://localhost:17777/callback"), 210 | url.QueryEscape(server.Tenant), 211 | ) 212 | 213 | browser.Stderr = io.Discard 214 | browser.Stdout = io.Discard 215 | err = browser.OpenURL(loginURL) 216 | if err != nil { 217 | log.Info("Unable to open your browser") 218 | } 219 | log.Info("Check your browser to complete the login flow") 220 | log.Info("Or paste this in your browser") 221 | log.Info("\t", loginURL) 222 | 223 | codeCh := make(chan string, 1) 224 | 225 | http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 226 | w.Header().Add("Access-Control-Allow-Origin", r.Header.Get("Origin")) 227 | w.Header().Add("Access-Control-Allow-Headers", "content-type") 228 | w.Header().Add("Access-Control-Allow-Credentials", "true") 229 | 230 | queryCode := r.URL.Query().Get("code") 231 | if r.Method == "GET" && queryCode != "" { 232 | codeCh <- queryCode 233 | http.Redirect(w, r, fmt.Sprintf("%s/cli-success", *cfg.Endpoint), http.StatusTemporaryRedirect) 234 | } 235 | 236 | _, err := w.Write([]byte{}) 237 | if err != nil { 238 | log.Error(err) 239 | } 240 | }) 241 | go func() { 242 | err = http.ListenAndServe("localhost:17777", nil) 243 | if err != nil { 244 | log.Fatal(err) 245 | } 246 | }() 247 | 248 | code := <-codeCh 249 | log.Info("Received auth code") 250 | 251 | resp, err := http.PostForm( 252 | fmt.Sprintf("%s/oauth2/token", *cfg.Endpoint), 253 | url.Values{ 254 | "code": {code}, 255 | "redirect_uri": {"http://localhost:17777/callback"}, 256 | "grant_type": {"authorization_code"}, 257 | "client_id": {"devx-server"}, 258 | }, 259 | ) 260 | if err != nil { 261 | return err 262 | } 263 | defer resp.Body.Close() 264 | 265 | body, err := ioutil.ReadAll(resp.Body) 266 | if err != nil { 267 | return err 268 | } 269 | 270 | tokenResp := TokenResponse{} 271 | err = json.Unmarshal(body, &tokenResp) 272 | if err != nil { 273 | return err 274 | } 275 | 276 | log.Info("Logged in successfully") 277 | 278 | expiresOn := time.Now().Add(time.Second * time.Duration(tokenResp.ExpiresIn)) 279 | 280 | cfg.Token = &tokenResp.AccessToken 281 | cfg.TokenExpiresOn = &expiresOn 282 | 283 | cfgFile[server.Tenant] = cfg 284 | err = saveConfig(cfgFile) 285 | if err != nil { 286 | return err 287 | } 288 | 289 | return nil 290 | } 291 | 292 | func getConfigDir() (string, error) { 293 | homeDir, err := os.UserHomeDir() 294 | if err != nil { 295 | return "", err 296 | } 297 | return filepath.Join(homeDir, ".devx"), nil 298 | } 299 | 300 | func loadDefaultConfig() (string, Config, error) { 301 | cfgFile, err := loadConfig() 302 | if err != nil { 303 | return "", Config{}, err 304 | } 305 | for tenant, cfg := range cfgFile { 306 | if cfg.Default { 307 | return tenant, cfg, nil 308 | } 309 | } 310 | return "", Config{}, fmt.Errorf("no credentials found\nTry logging in using: devx auth --tenant ") 311 | } 312 | 313 | func loadConfig() (ConfigFile, error) { 314 | configDir, err := getConfigDir() 315 | if err != nil { 316 | return nil, err 317 | } 318 | 319 | configPath := filepath.Join(configDir, "config") 320 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 321 | return ConfigFile{}, nil 322 | } 323 | 324 | configData, err := os.ReadFile(configPath) 325 | if err != nil { 326 | return nil, err 327 | } 328 | 329 | cfg := ConfigFile{} 330 | err = yaml.Unmarshal(configData, &cfg) 331 | if err != nil { 332 | return nil, err 333 | } 334 | 335 | return cfg, err 336 | } 337 | 338 | func saveConfig(cfg ConfigFile) error { 339 | configDir, err := getConfigDir() 340 | if err != nil { 341 | return err 342 | } 343 | 344 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 345 | err = os.MkdirAll(configDir, 0700) 346 | if err != nil { 347 | return err 348 | } 349 | } 350 | 351 | configPath := filepath.Join(configDir, "config") 352 | configData, err := yaml.Marshal(&cfg) 353 | if err != nil { 354 | return err 355 | } 356 | 357 | return os.WriteFile(configPath, configData, 0700) 358 | } 359 | 360 | func deleteConfig() error { 361 | configDir, err := getConfigDir() 362 | if err != nil { 363 | return err 364 | } 365 | 366 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 367 | err = os.MkdirAll(configDir, 0700) 368 | if err != nil { 369 | return err 370 | } 371 | } 372 | 373 | configPath := filepath.Join(configDir, "config") 374 | 375 | if err := os.Remove(configPath); err != nil { 376 | return err 377 | } 378 | 379 | return nil 380 | } 381 | -------------------------------------------------------------------------------- /pkg/catalog/catalog.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "cuelang.org/go/cue" 11 | "golang.org/x/mod/semver" 12 | 13 | "cuelang.org/go/cue/cuecontext" 14 | "cuelang.org/go/cue/format" 15 | log "github.com/sirupsen/logrus" 16 | 17 | "github.com/stakpak/devx/pkg/auth" 18 | "github.com/stakpak/devx/pkg/gitrepo" 19 | "github.com/stakpak/devx/pkg/utils" 20 | ) 21 | 22 | type CatalogItem struct { 23 | Name string `json:"name"` 24 | Source string `json:"source"` 25 | Metadata map[string]interface{} `json:"metadata"` 26 | Package Package `json:"package"` 27 | } 28 | type Package struct { 29 | Module string `json:"module"` 30 | Package string `json:"package"` 31 | Tags []string `json:"tags"` 32 | Git Git `json:"git"` 33 | } 34 | type Git struct { 35 | gitrepo.ProjectGitData 36 | gitrepo.GitData 37 | } 38 | type ModuleItem struct { 39 | Module string `json:"module"` 40 | Dependencies map[string]ModuleDependency `json:"dependencies"` 41 | Package string `json:"package"` 42 | Source map[string]string `json:"source"` 43 | Tags []string `json:"tags"` 44 | } 45 | type ModuleDependency struct { 46 | V *string `json:"v,omitempty"` 47 | } 48 | 49 | type ModuleCUE struct { 50 | Module string `json:"module"` 51 | Dependencies map[string]ModuleDependency `json:"deps"` 52 | Cue struct { 53 | Language string `json:"lang"` 54 | } `json:"cue,omitempty"` 55 | } 56 | 57 | func PublishModule(gitDir string, configDir string, server auth.ServerConfig, tags []string) error { 58 | gitData, err := gitrepo.GetGitData(gitDir) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | tagsToPush := []string{} 64 | 65 | if gitData != nil { 66 | for _, gitTag := range gitData.Tags { 67 | exists := false 68 | for _, tag := range tags { 69 | if tag == gitTag { 70 | exists = true 71 | break 72 | } 73 | } 74 | if exists { 75 | continue 76 | } 77 | tagsToPush = append(tagsToPush, gitTag) 78 | } 79 | } 80 | 81 | for _, tag := range tags { 82 | if !semver.IsValid(tag) { 83 | return fmt.Errorf("invalid tag \"%s\" that is not a valid semantic version, please check https://semver.org/", tag) 84 | } 85 | tagsToPush = append(tagsToPush, tag) 86 | } 87 | 88 | if len(tagsToPush) == 0 { 89 | return fmt.Errorf("no tags specified, cannot publish the module without semver tags") 90 | } 91 | 92 | moduleFilePath := filepath.Join(configDir, "cue.mod", "module.cue") 93 | moduleData, err := os.ReadFile(moduleFilePath) 94 | if err != nil { 95 | return fmt.Errorf("%s not found", moduleFilePath) 96 | } 97 | 98 | ctx := cuecontext.New() 99 | module := ctx.CompileBytes(moduleData) 100 | moduleCue := ModuleCUE{} 101 | if err := module.Decode(&moduleCue); err != nil { 102 | return err 103 | } 104 | 105 | totalSizeBytes := int64(0) 106 | overlay := map[string]string{} 107 | err = filepath.Walk(configDir, func(path string, info fs.FileInfo, err error) error { 108 | if !info.IsDir() && 109 | !strings.HasPrefix(path, "cue.mod") && 110 | !strings.HasPrefix(path, ".git") && 111 | strings.HasSuffix(path, ".cue") { 112 | totalSizeBytes += info.Size() 113 | content, err := os.ReadFile(path) 114 | if err != nil { 115 | return fmt.Errorf("failed to read %s : %s", path, err.Error()) 116 | } 117 | 118 | overlay[path] = string(content) 119 | } 120 | return nil 121 | }) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | item := ModuleItem{ 127 | Module: moduleCue.Module, 128 | Package: moduleCue.Module, 129 | Dependencies: moduleCue.Dependencies, 130 | Source: overlay, 131 | Tags: tagsToPush, 132 | } 133 | err = publishModule(server, &item) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func Publish(gitDir string, configDir string, server auth.ServerConfig) error { 142 | overlays, err := utils.GetOverlays(configDir) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | instances := utils.LoadInstances(configDir, &overlays) 148 | instance := instances[0] 149 | 150 | ctx := cuecontext.New() 151 | value := ctx.BuildInstance(instance) 152 | 153 | projectGitData, err := gitrepo.GetProjectGitData(gitDir) 154 | if err != nil { 155 | return nil 156 | } 157 | if projectGitData == nil { 158 | return fmt.Errorf("git is not initialized, cannot publish a catalog without version control") 159 | } 160 | gitData, err := gitrepo.GetGitData(gitDir) 161 | if err != nil { 162 | return nil 163 | } 164 | if gitData == nil { 165 | return fmt.Errorf("git is not initialized, cannot publish a catalog without version control") 166 | } 167 | if len(gitData.Tags) == 0 { 168 | return fmt.Errorf("no git tags found, cannot publish to catalog without semver tags") 169 | } 170 | 171 | pkgItem := Package{ 172 | Module: instance.Module, 173 | Package: instance.ID(), 174 | Tags: gitData.Tags, 175 | Git: Git{ 176 | *projectGitData, 177 | *gitData, 178 | }, 179 | } 180 | 181 | fieldIter, err := value.Fields(cue.Definitions(true)) 182 | if err != nil { 183 | return err 184 | } 185 | for fieldIter.Next() { 186 | item := fieldIter.Value() 187 | metadata := item.LookupPath(cue.ParsePath("$metadata")) 188 | if !metadata.Exists() { 189 | continue 190 | } 191 | 192 | transformedMeta := metadata.LookupPath(cue.ParsePath("transformed")) 193 | if transformedMeta.Exists() && transformedMeta.IsConcrete() { 194 | if isTransformed, _ := transformedMeta.Bool(); isTransformed { 195 | 196 | traitMeta := metadata.LookupPath(cue.ParsePath("traits")) 197 | traits := []string{} 198 | traitIter, _ := traitMeta.Fields() 199 | for traitIter.Next() { 200 | traits = append(traits, traitIter.Label()) 201 | } 202 | 203 | data, _ := format.Node(item.Source()) 204 | catalogItem := CatalogItem{ 205 | Source: strings.TrimSpace(string(data)), 206 | Name: fieldIter.Label(), 207 | Metadata: map[string]interface{}{ 208 | "traits": traits, 209 | "type": "Transformer", 210 | }, 211 | Package: pkgItem, 212 | } 213 | err = publishCatalogItem(server, &catalogItem) 214 | if err != nil { 215 | return err 216 | } 217 | continue 218 | } 219 | } 220 | 221 | traitMeta := metadata.LookupPath(cue.ParsePath("traits")) 222 | if traitMeta.Exists() { 223 | traits := []string{} 224 | traitIter, _ := traitMeta.Fields() 225 | for traitIter.Next() { 226 | traits = append(traits, traitIter.Label()) 227 | } 228 | 229 | catalogItemType := "Trait" 230 | if len(traits) > 1 || strings.TrimPrefix(fieldIter.Label(), "#") != traits[0] { 231 | catalogItemType = "Component" 232 | } 233 | 234 | data, _ := format.Node(item.Source()) 235 | catalogItem := CatalogItem{ 236 | Source: strings.TrimSpace(string(data)), 237 | Name: fieldIter.Label(), 238 | Metadata: map[string]interface{}{ 239 | "traits": traits, 240 | "type": catalogItemType, 241 | }, 242 | Package: pkgItem, 243 | } 244 | err = publishCatalogItem(server, &catalogItem) 245 | if err != nil { 246 | return err 247 | } 248 | continue 249 | } 250 | 251 | stackMeta := metadata.LookupPath(cue.ParsePath("stack")) 252 | if stackMeta.Exists() { 253 | componentsMeta := map[string]interface{}{} 254 | 255 | components := item.LookupPath(cue.ParsePath("components")) 256 | componentIter, _ := components.Fields() 257 | for componentIter.Next() { 258 | traits := []string{} 259 | traitIter, _ := componentIter.Value().LookupPath(cue.ParsePath("$metadata.traits")).Fields() 260 | for traitIter.Next() { 261 | traits = append(traits, traitIter.Label()) 262 | } 263 | componentsMeta[componentIter.Label()] = map[string]interface{}{ 264 | "traits": traits, 265 | } 266 | } 267 | 268 | data, _ := format.Node(item.Source()) 269 | catalogItem := CatalogItem{ 270 | Source: strings.TrimSpace(string(data)), 271 | Name: fieldIter.Label(), 272 | Metadata: map[string]interface{}{ 273 | "components": componentsMeta, 274 | "type": "Stack", 275 | }, 276 | Package: pkgItem, 277 | } 278 | err = publishCatalogItem(server, &catalogItem) 279 | if err != nil { 280 | return err 281 | } 282 | } 283 | 284 | builderMeta := metadata.LookupPath(cue.ParsePath("builder")) 285 | if builderMeta.Exists() { 286 | traitsMap := map[string]interface{}{} 287 | 288 | flows := item.LookupPath(cue.ParsePath("flows")) 289 | flowIter, _ := flows.Fields() 290 | for flowIter.Next() { 291 | traitIter, _ := flowIter.Value().LookupPath(cue.ParsePath("match.traits")).Fields() 292 | for traitIter.Next() { 293 | traitsMap[traitIter.Label()] = nil 294 | } 295 | } 296 | 297 | traits := []string{} 298 | for trait := range traitsMap { 299 | traits = append(traits, trait) 300 | } 301 | 302 | data, _ := format.Node(item.Source()) 303 | catalogItem := CatalogItem{ 304 | Source: strings.TrimSpace(string(data)), 305 | Name: fieldIter.Label(), 306 | Metadata: map[string]interface{}{ 307 | "traits": traits, 308 | "type": "StackBuilder", 309 | }, 310 | Package: pkgItem, 311 | } 312 | err = publishCatalogItem(server, &catalogItem) 313 | if err != nil { 314 | return err 315 | } 316 | } 317 | 318 | } 319 | 320 | return nil 321 | } 322 | 323 | func publishCatalogItem(server auth.ServerConfig, catalogItem *CatalogItem) error { 324 | data, err := utils.SendData(server, "catalog", catalogItem) 325 | if err != nil { 326 | log.Debug(string(data)) 327 | return err 328 | } 329 | 330 | log.Infof("🚀 Published %s %s successfully", catalogItem.Package.Package, catalogItem.Name) 331 | 332 | return nil 333 | } 334 | 335 | func publishModule(server auth.ServerConfig, item *ModuleItem) error { 336 | data, err := utils.SendData(server, "package", item) 337 | if err != nil { 338 | log.Debug(string(data)) 339 | return err 340 | } 341 | 342 | if len(item.Tags) > 0 { 343 | log.Infof("📦 Published module %s@%s successfully", item.Module, item.Tags[0]) 344 | } else { 345 | log.Infof("📦 Published module %s successfully", item.Module) 346 | } 347 | 348 | return nil 349 | } 350 | -------------------------------------------------------------------------------- /pkg/stack/stack.go: -------------------------------------------------------------------------------- 1 | package stack 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "cuelang.org/go/cue" 10 | "cuelang.org/go/cue/ast" 11 | "cuelang.org/go/cue/ast/astutil" 12 | "cuelang.org/go/cue/cuecontext" 13 | cueflow "cuelang.org/go/tools/flow" 14 | log "github.com/sirupsen/logrus" 15 | "github.com/stakpak/devx/pkg/auth" 16 | "github.com/stakpak/devx/pkg/gitrepo" 17 | "github.com/stakpak/devx/pkg/utils" 18 | ) 19 | 20 | type Stack struct { 21 | ID string 22 | DepIDs []string 23 | BuildSource string 24 | components cue.Value 25 | tasks []string 26 | dependencies map[string][]string 27 | } 28 | 29 | func NewStack(value cue.Value, stackId string, depIds []string) (*Stack, error) { 30 | components := value.LookupPath(cue.ParsePath("components")) 31 | if components.Err() != nil { 32 | return nil, components.Err() 33 | } 34 | 35 | stack := &Stack{ 36 | ID: stackId, 37 | DepIDs: depIds, 38 | components: components, 39 | dependencies: make(map[string][]string), 40 | } 41 | 42 | cfg := &cueflow.Config{ 43 | FindHiddenTasks: true, 44 | Root: cue.ParsePath(""), 45 | } 46 | 47 | flow := cueflow.New( 48 | cfg, 49 | components, 50 | taskFunc, 51 | ) 52 | 53 | tasks := flow.Tasks() 54 | for _, task := range tasks { 55 | id := utils.GetLastPathFragment(task.Value()) 56 | parents := task.Dependencies() 57 | stack.dependencies[id] = make([]string, 0) 58 | for _, parent := range parents { 59 | parentId := utils.GetLastPathFragment(parent.Value()) 60 | stack.addDependency(id, parentId) 61 | } 62 | } 63 | 64 | stack.tasks = computeOrderedTasks(stack) 65 | 66 | return stack, nil 67 | } 68 | 69 | func (s *Stack) Print() { 70 | log.Info(s.components) 71 | } 72 | 73 | func (s *Stack) GetDependencies(id string) ([]string, error) { 74 | val, ok := s.dependencies[id] 75 | if !ok { 76 | return nil, fmt.Errorf("Id %s not found in dependencies map", id) 77 | } 78 | return val, nil 79 | } 80 | 81 | func (s *Stack) UpdateComponent(id string, value cue.Value) error { 82 | // s.components. 83 | s.components = s.components.FillPath(cue.ParsePath(id), value) 84 | if s.components.Err() != nil { 85 | return s.components.Err() 86 | } 87 | return nil 88 | } 89 | 90 | func (s *Stack) GetComponent(id string) (cue.Value, error) { 91 | result := s.components.LookupPath(cue.ParsePath(id)) 92 | return result, result.Err() 93 | } 94 | 95 | func (s *Stack) IsConcreteComponent(component cue.Value) bool { 96 | err := component.Validate(cue.Concrete(true)) 97 | return err == nil 98 | } 99 | 100 | func (s *Stack) HasConcreteResourceDrivers(component cue.Value) bool { 101 | resources := component.LookupPath(cue.ParsePath("$resource")) 102 | 103 | if resources.Exists() { 104 | resourceIter, _ := resources.Fields() 105 | for resourceIter.Next() { 106 | driver := resourceIter.Value().LookupPath(cue.ParsePath("$metadata.labels.driver")) 107 | if !driver.Exists() { 108 | return false 109 | } 110 | err := driver.Validate(cue.Concrete(true)) 111 | if err != nil { 112 | return false 113 | } 114 | } 115 | } 116 | return true 117 | } 118 | 119 | func (s *Stack) GetContext() *cue.Context { 120 | return s.components.Context() 121 | } 122 | 123 | func (s *Stack) GetTasks() []string { 124 | return s.tasks 125 | } 126 | 127 | func (s *Stack) AddComponents(value cue.Value) { 128 | s.components = s.components.FillPath(cue.ParsePath(""), value) 129 | 130 | cfg := &cueflow.Config{ 131 | FindHiddenTasks: true, 132 | Root: cue.ParsePath(""), 133 | } 134 | 135 | flow := cueflow.New( 136 | cfg, 137 | s.components, 138 | taskFunc, 139 | ) 140 | 141 | tasks := flow.Tasks() 142 | for _, task := range tasks { 143 | id := utils.GetLastPathFragment(task.Value()) 144 | parents := task.Dependencies() 145 | s.dependencies[id] = make([]string, 0) 146 | for _, parent := range parents { 147 | parentId := utils.GetLastPathFragment(parent.Value()) 148 | s.addDependency(id, parentId) 149 | } 150 | } 151 | 152 | s.tasks = computeOrderedTasks(s) 153 | } 154 | 155 | func (s *Stack) addDependency(id string, depId string) { 156 | s.dependencies[id] = append(s.dependencies[id], depId) 157 | } 158 | 159 | // cue flow already checks for cycles, so we don't have to 160 | func computeOrderedTasks(s *Stack) []string { 161 | 162 | result := make([]string, 0) 163 | 164 | // initialize data structures 165 | visited := make(map[string]bool) 166 | stack := make([]string, 0) 167 | for id := range s.dependencies { 168 | stack = append(stack, id) 169 | visited[id] = false 170 | } 171 | 172 | // make scheduling deterministic 173 | sort.Strings(stack) 174 | 175 | for len(stack) > 0 { 176 | current := stack[len(stack)-1] 177 | if isVisited, _ := visited[current]; isVisited { 178 | stack = stack[:len(stack)-1] 179 | continue 180 | } 181 | 182 | parents, _ := s.GetDependencies(current) 183 | isReady := true 184 | for _, parent := range parents { 185 | if isVisited, _ := visited[parent]; !isVisited { 186 | isReady = false 187 | stack = append(stack, parent) 188 | } 189 | } 190 | if !isReady { 191 | continue 192 | } 193 | 194 | visited[current] = true 195 | result = append(result, current) 196 | stack = stack[:len(stack)-1] 197 | } 198 | 199 | return result 200 | } 201 | 202 | func taskFunc(v cue.Value) (cueflow.Runner, error) { 203 | idPath := cue.ParsePath("$metadata.id") 204 | componentId := v.LookupPath(idPath) 205 | if !componentId.Exists() { 206 | // Not a task 207 | return nil, nil 208 | } 209 | 210 | return cueflow.RunnerFunc(func(t *cueflow.Task) error { 211 | return nil 212 | }), nil 213 | } 214 | 215 | func (s *Stack) GetComponents() cue.Value { 216 | return s.components 217 | } 218 | 219 | type BuildData struct { 220 | Stack string `json:"stack"` 221 | Identity string `json:"identity,omitempty"` 222 | Result cue.Value `json:"result"` 223 | Imports []string `json:"imports"` 224 | References map[string][]Reference `json:"references"` 225 | Environment string `json:"environment"` 226 | Git *gitrepo.GitData `json:"git,omitempty"` 227 | Error *string `json:"error"` 228 | Source string `json:"source"` 229 | } 230 | type Reference struct { 231 | Source string `json:"source"` 232 | Target string `json:"target"` 233 | } 234 | 235 | func (s *Stack) SendBuild(configDir string, server auth.ServerConfig, environment string, buildError *string) (string, error) { 236 | build := BuildData{ 237 | Stack: s.ID, 238 | Identity: "", 239 | Imports: s.DepIDs, 240 | Environment: environment, 241 | Git: nil, 242 | Error: buildError, 243 | Source: s.BuildSource, 244 | } 245 | 246 | if buildError == nil { 247 | build.Result = s.GetComponents() 248 | build.References = s.GetReferences() 249 | } else { 250 | build.Result = cuecontext.New().CompileString("{}") 251 | build.References = map[string][]Reference{} 252 | } 253 | 254 | gitData, err := gitrepo.GetGitData(configDir) 255 | if err != nil { 256 | return "", err 257 | } 258 | build.Git = gitData 259 | 260 | data, err := utils.SendData(server, "builds", &build) 261 | if err != nil { 262 | return "", err 263 | } 264 | 265 | buildResponse := make(map[string]string) 266 | err = json.Unmarshal(data, &buildResponse) 267 | if err != nil { 268 | return "", err 269 | } 270 | 271 | return buildResponse["id"], nil 272 | } 273 | 274 | func (s *Stack) GetReferences() map[string][]Reference { 275 | refMap := map[string][]Reference{} 276 | 277 | refs := removeDuplicates(GetRef(s.components.Syntax())) 278 | for _, r := range refs { 279 | if r.Target == "" { 280 | continue 281 | } 282 | parts := strings.SplitN(r.Target, ".", 2) 283 | refMap[parts[0]] = append(refMap[parts[0]], r) 284 | } 285 | 286 | return refMap 287 | } 288 | 289 | func GetRef(node ast.Node) []Reference { 290 | refs := []Reference{} 291 | 292 | if node == nil { 293 | return refs 294 | } 295 | 296 | astutil.Apply(node, func(c astutil.Cursor) bool { 297 | path := GetPath(c) 298 | if strings.Contains(path, "#") || strings.Contains(path, "$") { 299 | return false 300 | } 301 | 302 | switch n := c.Node().(type) { 303 | case *ast.SelectorExpr: 304 | sourcePath := GetName(n) 305 | if strings.Contains(sourcePath, "#") || strings.Contains(sourcePath, "?") { 306 | return false 307 | } 308 | refs = append(refs, Reference{ 309 | Source: sourcePath, 310 | Target: path, 311 | }) 312 | return false 313 | case *ast.IndexExpr: 314 | sourcePath := GetName(n) 315 | if strings.Contains(sourcePath, "#") || strings.Contains(sourcePath, "?") { 316 | return false 317 | } 318 | refs = append(refs, Reference{ 319 | Source: sourcePath, 320 | Target: path, 321 | }) 322 | return false 323 | } 324 | 325 | return true 326 | }, nil) 327 | 328 | return refs 329 | } 330 | 331 | func GetPath(c astutil.Cursor) string { 332 | if c == nil { 333 | return "" 334 | } 335 | 336 | path := GetPath(c.Parent()) 337 | if c.Parent() != nil { 338 | switch p := c.Parent().Node().(type) { 339 | case *ast.ListLit: 340 | for i, e := range p.Elts { 341 | if e == c.Node() { 342 | path = path + fmt.Sprintf("[%d]", i) 343 | break 344 | } 345 | } 346 | } 347 | } 348 | 349 | switch n := c.Node().(type) { 350 | case *ast.Field: 351 | path = path + "." + GetName(n.Label) 352 | case *ast.Ident: 353 | path = path + "." + GetName(n) 354 | } 355 | return strings.TrimPrefix(path, ".") 356 | } 357 | 358 | func GetName(n ast.Node) string { 359 | switch n := n.(type) { 360 | case *ast.Ident: 361 | return n.Name 362 | case *ast.Field: 363 | return GetName(n.Label) 364 | case *ast.SelectorExpr: 365 | return fmt.Sprintf("%s.%s", GetName(n.X), GetName(n.Sel)) 366 | case *ast.IndexExpr: 367 | return fmt.Sprintf("%s[%s]", GetName(n.X), GetName(n.Index)) 368 | case *ast.BasicLit: 369 | return n.Value 370 | } 371 | 372 | return "?" 373 | } 374 | 375 | func removeDuplicates(refs []Reference) []Reference { 376 | allKeys := make(map[string]bool) 377 | list := []Reference{} 378 | for _, item := range refs { 379 | key := fmt.Sprintf(item.Source, item.Target) 380 | if _, value := allKeys[key]; !value { 381 | allKeys[key] = true 382 | list = append(list, item) 383 | } 384 | } 385 | return list 386 | } 387 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "regexp" 17 | "sort" 18 | "strings" 19 | 20 | "cuelang.org/go/cue" 21 | "cuelang.org/go/cue/build" 22 | "cuelang.org/go/cue/cuecontext" 23 | cueload "cuelang.org/go/cue/load" 24 | "github.com/go-git/go-billy/v5" 25 | log "github.com/sirupsen/logrus" 26 | "github.com/stakpak/devx/pkg/auth" 27 | "gopkg.in/yaml.v3" 28 | ) 29 | 30 | type ContextKey string 31 | 32 | const ( 33 | ConfigDirKey ContextKey = "configDir" 34 | DryRunKey ContextKey = "dryRun" 35 | ) 36 | 37 | func LoadInstances(configDir string, overlays *map[string]string) []*build.Instance { 38 | sourceOverlays := map[string]cueload.Source{} 39 | 40 | if overlays != nil { 41 | for file, content := range *overlays { 42 | absConfigDir, _ := filepath.Abs(configDir) 43 | filePath := path.Join(absConfigDir, file) 44 | sourceOverlays[filePath] = cueload.FromString(content) 45 | } 46 | } 47 | 48 | buildConfig := &cueload.Config{ 49 | Dir: configDir, 50 | Overlay: sourceOverlays, 51 | } 52 | return cueload.Instances([]string{}, buildConfig) 53 | } 54 | 55 | func LoadProject(configDir string, overlays *map[string]string) (cue.Value, string, []string) { 56 | instances := LoadInstances(configDir, overlays) 57 | 58 | ctx := cuecontext.New() 59 | stackID := strings.Split(instances[0].ID(), ":")[0] 60 | 61 | return ctx.BuildInstance(instances[0]), stackID, instances[0].Deps 62 | } 63 | 64 | func GetLastPathFragment(value cue.Value) string { 65 | selector := value.Path().Selectors() 66 | return selector[len(selector)-1].String() 67 | } 68 | 69 | func GetComments(value cue.Value) string { 70 | comments := value.Doc() 71 | result := "" 72 | for _, comment := range comments { 73 | result += comment.Text() 74 | } 75 | return strings.ReplaceAll(result, "\n", " ") 76 | } 77 | 78 | func HasComments(value cue.Value) bool { 79 | comments := value.Doc() 80 | 81 | return len(comments) > 0 82 | } 83 | 84 | func Walk(v cue.Value, before func(cue.Value) bool, after func(cue.Value)) { 85 | switch v.Kind() { 86 | case cue.StructKind: 87 | if before != nil && !before(v) { 88 | return 89 | } 90 | fieldIter, _ := v.Fields(cue.All()) 91 | for fieldIter.Next() { 92 | Walk(fieldIter.Value(), before, after) 93 | } 94 | case cue.ListKind: 95 | if before != nil && !before(v) { 96 | return 97 | } 98 | valueIter, _ := v.List() 99 | for valueIter.Next() { 100 | Walk(valueIter.Value(), before, after) 101 | } 102 | default: 103 | if before != nil { 104 | before(v) 105 | } 106 | } 107 | if after != nil { 108 | after(v) 109 | } 110 | } 111 | 112 | func RemoveMeta(value cue.Value) (cue.Value, error) { 113 | result := value.Context().CompileString("_") 114 | 115 | iter, err := value.Fields() 116 | if err != nil { 117 | return result, err 118 | } 119 | 120 | for iter.Next() { 121 | v := iter.Value() 122 | label, _ := v.Label() 123 | if !strings.HasPrefix(label, "$") { 124 | result = result.FillPath(cue.ParsePath(label), v) 125 | } 126 | } 127 | 128 | return result, nil 129 | } 130 | 131 | func FsWalk(fs billy.Filesystem, filePath string, process func(p string, content []byte) error) error { 132 | file, err := fs.Lstat(filePath) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | if file.IsDir() { 138 | files, err := fs.ReadDir(filePath) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | for _, f := range files { 144 | childPath := path.Join(filePath, f.Name()) 145 | err := FsWalk(fs, childPath, process) 146 | if err != nil { 147 | return err 148 | } 149 | } 150 | } else { 151 | content, err := fs.Open(filePath) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | data, err := io.ReadAll(bufio.NewReader(content)) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | return process(filePath, data) 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func IsReference(value cue.Value) bool { 168 | _, vs := value.Expr() 169 | for _, v := range vs { 170 | op, _ := v.Expr() 171 | if op.String() == "." { 172 | return true 173 | } 174 | } 175 | return false 176 | } 177 | 178 | func GetOverlays(configDir string) (map[string]string, error) { 179 | overlays := map[string]string{} 180 | 181 | files, err := os.ReadDir(configDir) 182 | if err != nil { 183 | return overlays, err 184 | } 185 | 186 | for _, f := range files { 187 | if !f.IsDir() && (strings.HasSuffix(f.Name(), ".devx.yaml") || strings.HasSuffix(f.Name(), ".devx.yml")) { 188 | file, err := os.ReadFile(path.Join(configDir, f.Name())) 189 | if err != nil { 190 | return overlays, err 191 | } 192 | 193 | var n yaml.Node 194 | err = yaml.Unmarshal(file, &n) 195 | if err != nil { 196 | return overlays, err 197 | } 198 | 199 | overlays[f.Name()+".cue"] = BuildCUEFile("", &n) 200 | } 201 | } 202 | 203 | return overlays, nil 204 | } 205 | 206 | func BuildCUEFile(content string, n *yaml.Node) string { 207 | newContent := content 208 | 209 | switch n.Kind { 210 | case yaml.DocumentNode: 211 | newContent += "package main\n" 212 | for _, child := range n.Content { 213 | newContent = BuildCUEFile(newContent, child) 214 | } 215 | case yaml.SequenceNode: 216 | newContent = fmt.Sprintf("%s [\n", newContent) 217 | for _, child := range n.Content { 218 | newContent = fmt.Sprintf("%s,\n", BuildCUEFile(newContent, child)) 219 | } 220 | newContent = fmt.Sprintf("%s\n]\n", newContent) 221 | case yaml.MappingNode: 222 | addBrace := false 223 | for i := 0; i < len(n.Content); i += 2 { 224 | name := n.Content[i].Value 225 | 226 | if name == "import" && newContent == "package main\n" { 227 | for j := 0; j < len(n.Content[i+1].Content); j += 2 { 228 | newContent = fmt.Sprintf( 229 | "%simport %s \"%s\"\n", 230 | newContent, 231 | n.Content[i+1].Content[j].Value, 232 | n.Content[i+1].Content[j+1].Value, 233 | ) 234 | } 235 | continue 236 | } 237 | 238 | if name == "$schema" { 239 | schmaValues := []string{} 240 | for _, child := range n.Content[i+1].Content { 241 | schmaValues = append(schmaValues, child.Value) 242 | } 243 | schema := strings.Join(schmaValues, " & ") 244 | if !addBrace { 245 | newContent = fmt.Sprintf("%s%s & {\n", newContent, schema) 246 | addBrace = true 247 | } 248 | continue 249 | } 250 | 251 | if name == "$traits" { 252 | if !addBrace { 253 | newContent = fmt.Sprintf("%s {\n", newContent) 254 | addBrace = true 255 | } 256 | for _, child := range n.Content[i+1].Content { 257 | newContent = fmt.Sprintf("%s%s\n", newContent, child.Value) 258 | } 259 | continue 260 | } 261 | 262 | if !addBrace { 263 | newContent = fmt.Sprintf("%s {\n", newContent) 264 | addBrace = true 265 | } 266 | 267 | child := n.Content[i+1] 268 | newContent = fmt.Sprintf("%s%s: ", newContent, name) 269 | newContent = fmt.Sprintf("%s\n", BuildCUEFile(newContent, child)) 270 | } 271 | newContent = fmt.Sprintf("%s\n}\n", newContent) 272 | case yaml.ScalarNode: 273 | matched, _ := regexp.MatchString(`^\$\{.*\}$`, n.Value) 274 | value := n.Value 275 | 276 | if matched { 277 | value = n.Value[2 : len(n.Value)-1] 278 | } 279 | 280 | if matched || n.Tag != "!!str" { 281 | return fmt.Sprintf("%s%s", newContent, value) 282 | } else { 283 | return fmt.Sprintf("%s\"%s\"", newContent, n.Value) 284 | 285 | } 286 | } 287 | 288 | return newContent 289 | } 290 | 291 | type Leaf struct { 292 | Path string 293 | Value string 294 | } 295 | 296 | func GetLeaves(value cue.Value, skipReserved bool) []Leaf { 297 | result := make([]Leaf, 0) 298 | 299 | value.Walk(func(v cue.Value) bool { 300 | switch v.Kind() { 301 | case cue.BoolKind, cue.NumberKind, cue.StringKind, cue.BytesKind: 302 | sel := v.Path().Selectors() 303 | path := cue.MakePath(sel[2:]...).String() 304 | if skipReserved && strings.Contains(path, "$") || strings.Contains(path, "$metadata") { 305 | return true 306 | } 307 | result = append( 308 | result, 309 | Leaf{ 310 | Path: path, 311 | Value: fmt.Sprint(v), 312 | }, 313 | ) 314 | } 315 | return true 316 | }, nil) 317 | 318 | sort.SliceStable(result, func(i, j int) bool { 319 | return strings.Compare(result[i].Path, result[j].Path) == -1 320 | }) 321 | 322 | return result 323 | } 324 | 325 | func addAuthHeader(server auth.ServerConfig, request *http.Request) error { 326 | var tenant string 327 | var token *string 328 | var err error 329 | if server.Tenant == "" { 330 | tenant, token, err = auth.GetDefaultToken(server) 331 | if err != nil { 332 | return err 333 | } 334 | } else { 335 | tenant = server.Tenant 336 | token, err = auth.GetToken(server) 337 | if err != nil { 338 | return err 339 | } 340 | } 341 | if token == nil { 342 | return fmt.Errorf("was not able to get credentials for tenant %s", tenant) 343 | } 344 | request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *token)) 345 | 346 | return nil 347 | } 348 | 349 | func SendData(server auth.ServerConfig, apiPath string, data interface{}) ([]byte, error) { 350 | dataJSON, err := json.Marshal(data) 351 | if err != nil { 352 | return nil, err 353 | } 354 | log.Debug("Sending: ", string(dataJSON)) 355 | 356 | url, _ := url.Parse(server.Endpoint) 357 | url.Path = path.Join(url.Path, "api", apiPath) 358 | log.Debug("URL: ", url) 359 | 360 | if url.Scheme != "https" { 361 | log.Warnf("[WARNING] connection to \"%s\" is not secure, this could leak your credentials", server.Endpoint) 362 | } 363 | 364 | request, err := http.NewRequest("POST", url.String(), bytes.NewBuffer(dataJSON)) 365 | if err != nil { 366 | return nil, err 367 | } 368 | request.Header.Set("Content-Type", "application/json; charset=UTF-8") 369 | 370 | addAuthHeader(server, request) 371 | 372 | client := &http.Client{} 373 | response, err := client.Do(request) 374 | if err != nil { 375 | return nil, err 376 | } 377 | defer response.Body.Close() 378 | 379 | log.Debug("Response Status: ", response.Status) 380 | log.Debug("Response Headers: ", response.Header) 381 | body, _ := ioutil.ReadAll(response.Body) 382 | log.Debug("Response Body: ", string(body)) 383 | 384 | if response.StatusCode > 201 { 385 | errResponse := struct { 386 | Message string `json:"message"` 387 | }{ 388 | Message: "API request failed", 389 | } 390 | 391 | err := json.Unmarshal(body, &errResponse) 392 | if err != nil { 393 | log.Fatalf("failed to parse error response body: %s", body) 394 | } 395 | 396 | if response.StatusCode == 401 { 397 | return nil, fmt.Errorf("authentication failed with error message: %s\n\nTry again using this flag --tenant \n\nOr authenticate using\n\ndevx auth --tenant ", errResponse.Message) 398 | } 399 | 400 | return nil, errors.New(errResponse.Message) 401 | } 402 | 403 | return body, nil 404 | } 405 | 406 | func GetData(server auth.ServerConfig, apiPath string, id *string, query map[string]string) ([]byte, error) { 407 | url, _ := url.Parse(server.Endpoint) 408 | 409 | url.Path = path.Join(url.Path, "api", apiPath) 410 | if id != nil { 411 | url.Path = path.Join(url.Path, *id) 412 | } 413 | 414 | queryValues := url.Query() 415 | for key, value := range query { 416 | queryValues.Add(key, value) 417 | } 418 | url.RawQuery = queryValues.Encode() 419 | 420 | log.Debug("Getting: ", url) 421 | 422 | if url.Scheme != "https" { 423 | log.Warnf("[WARNING] connection to \"%s\" is not secure, this could leak your credentials", server.Endpoint) 424 | } 425 | 426 | request, err := http.NewRequest("GET", url.String(), nil) 427 | if err != nil { 428 | return nil, err 429 | } 430 | request.Header.Set("Content-Type", "application/json; charset=UTF-8") 431 | 432 | addAuthHeader(server, request) 433 | 434 | client := &http.Client{} 435 | response, err := client.Do(request) 436 | if err != nil { 437 | return nil, err 438 | } 439 | defer response.Body.Close() 440 | 441 | log.Debug("Response Status: ", response.Status) 442 | log.Debug("Response Headers: ", response.Header) 443 | body, _ := ioutil.ReadAll(response.Body) 444 | log.Debug("Response Body: ", string(body)) 445 | 446 | if response.StatusCode > 201 { 447 | errResponse := struct { 448 | Message string `json:"message"` 449 | }{ 450 | Message: "API request failed", 451 | } 452 | 453 | err := json.Unmarshal(body, &errResponse) 454 | if err != nil { 455 | log.Fatalf("failed to parse error response body: %s", body) 456 | } 457 | 458 | if response.StatusCode == 401 { 459 | return nil, fmt.Errorf("authentication failed with error message: %s\n\nTry again using this flag --tenant \n\nOr authenticate using\n\ndevx auth --tenant ", errResponse.Message) 460 | } 461 | 462 | return nil, errors.New(errResponse.Message) 463 | } 464 | 465 | return body, nil 466 | } 467 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cuelang.org/go v0.6.0 h1:dJhgKCog+FEZt7OwAYV1R+o/RZPmE8aqFoptmxSWyr8= 2 | cuelang.org/go v0.6.0/go.mod h1:9CxOX8aawrr3BgSdqPj7V0RYoXo7XIb+yDFC6uESrOQ= 3 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 4 | github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= 5 | github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= 6 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= 7 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= 8 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 9 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 10 | github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= 11 | github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= 12 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= 13 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 14 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 15 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 16 | github.com/cockroachdb/apd/v3 v3.2.0 h1:79kHCn4tO0VGu3W0WujYrMjBDk8a2H4KEUYcXf7whcg= 17 | github.com/cockroachdb/apd/v3 v3.2.0/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= 18 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 19 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 20 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/emicklei/proto v1.11.0 h1:XcDEsxxv5xBp0jeZ4rt7dj1wuv/GQ4cSAe4BHbhrRXY= 25 | github.com/emicklei/proto v1.11.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= 26 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 27 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 28 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 29 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 30 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 31 | github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= 32 | github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= 33 | github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 34 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 35 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 36 | github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 37 | github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= 38 | github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 39 | github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= 40 | github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= 41 | github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= 42 | github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= 43 | github.com/go-quicktest/qt v1.100.0 h1:I7iSLgIwNp0E0UnSvKJzs7ig0jg/Iq83zsZjtQNW7jY= 44 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= 45 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 46 | github.com/go-task/task/v3 v3.20.0 h1:pTavuhP+AiEpKLzh5I6Lja9Ux7ypYO5QMsEPTbhYEDc= 47 | github.com/go-task/task/v3 v3.20.0/go.mod h1:y7rWakbLR5gFElGgo6rA2dyr6vU/zNIDVfn3S4Of6OI= 48 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 49 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 50 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 51 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 52 | github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= 53 | github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 54 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 55 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 56 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 57 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 58 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 59 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 60 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 61 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 62 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= 63 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 64 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 65 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 66 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 67 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 68 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 69 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 70 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 71 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 72 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 73 | github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= 74 | github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= 75 | github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 76 | github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= 77 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 78 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 79 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 80 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 81 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 82 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 83 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 84 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 85 | github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= 86 | github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= 87 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 88 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 89 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 90 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 91 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 92 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 93 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 94 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 95 | github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto= 96 | github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY= 97 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 98 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 99 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 100 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= 101 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= 102 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 103 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 104 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 105 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 106 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 107 | github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4= 108 | github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= 109 | github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= 110 | github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= 111 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 112 | github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= 113 | github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 114 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 115 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 116 | github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= 117 | github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= 118 | github.com/schollz/progressbar/v3 v3.12.1 h1:JAhtIrLWAn6/p7i82SrpSG3fgAwlAxi+Sy12r4AzBvQ= 119 | github.com/schollz/progressbar/v3 v3.12.1/go.mod h1:g7QSuwyGpqCjVQPFZXA31MSxtrhka9Y9LMdF+XT77/Y= 120 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 121 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 122 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 123 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 124 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 125 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 126 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 127 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 128 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 129 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 130 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 131 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 132 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 133 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 134 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 135 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 136 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 137 | github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= 138 | github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= 139 | golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 140 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 141 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 142 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= 143 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 144 | golang.org/x/exp v0.0.0-20220930202632-ec3f01382ef9 h1:RjggHMcaTVp0LOVZcW0bo8alwHrOaCrGUDgfWUHhnN4= 145 | golang.org/x/exp v0.0.0-20220930202632-ec3f01382ef9/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 146 | golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= 147 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 148 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 149 | golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= 150 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 151 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 152 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 153 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 154 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 155 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 159 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 162 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 164 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 166 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 167 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 169 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 170 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 171 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 172 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 173 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 174 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 175 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 176 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 177 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 178 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 179 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 180 | golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= 181 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 182 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 183 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 184 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 185 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 186 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 187 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 188 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 189 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 190 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 191 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 192 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 193 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 194 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 195 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 196 | mvdan.cc/sh/v3 v3.6.0 h1:gtva4EXJ0dFNvl5bHjcUEvws+KRcDslT8VKheTYkbGU= 197 | mvdan.cc/sh/v3 v3.6.0/go.mod h1:U4mhtBLZ32iWhif5/lD+ygy1zrgaQhUu+XFy7C8+TTA= 198 | -------------------------------------------------------------------------------- /pkg/project/project.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "strings" 14 | 15 | "cuelang.org/go/cue" 16 | "cuelang.org/go/cue/cuecontext" 17 | "cuelang.org/go/cue/format" 18 | "github.com/go-git/go-billy/v5" 19 | "github.com/go-git/go-billy/v5/memfs" 20 | "github.com/go-git/go-git/v5" 21 | "github.com/go-git/go-git/v5/plumbing" 22 | "github.com/go-git/go-git/v5/plumbing/transport/http" 23 | "github.com/go-git/go-git/v5/plumbing/transport/ssh" 24 | "github.com/go-git/go-git/v5/storage/memory" 25 | log "github.com/sirupsen/logrus" 26 | "github.com/stakpak/devx/pkg/auth" 27 | "github.com/stakpak/devx/pkg/catalog" 28 | "github.com/stakpak/devx/pkg/gitrepo" 29 | "github.com/stakpak/devx/pkg/stack" 30 | "github.com/stakpak/devx/pkg/stackbuilder" 31 | "github.com/stakpak/devx/pkg/utils" 32 | "golang.org/x/mod/semver" 33 | ) 34 | 35 | const stakpakPrefix = "stakpak://" 36 | 37 | func Validate(configDir string, stackPath string, buildersPath string, noStrict bool) error { 38 | overlays, err := utils.GetOverlays(configDir) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | value, _, _ := utils.LoadProject(configDir, &overlays) 44 | if err := ValidateProject(value, stackPath, buildersPath, noStrict); err != nil { 45 | return err 46 | } 47 | 48 | log.Info("👌 All looks good") 49 | return nil 50 | } 51 | 52 | func ValidateProject(value cue.Value, stackPath string, buildersPath string, noStrict bool) error { 53 | err := value.Validate() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | stackValue := value.LookupPath(cue.ParsePath(stackPath)) 59 | if stackValue.Err() != nil { 60 | return stackValue.Err() 61 | } 62 | 63 | isValid := true 64 | err = errors.New("invalid Components") 65 | utils.Walk(stackValue, func(v cue.Value) bool { 66 | gukuAttr := v.Attribute("guku") 67 | 68 | isRequired, _ := gukuAttr.Flag(0, "required") 69 | if isRequired && !v.IsConcrete() && !utils.IsReference(v) { 70 | isValid = false 71 | err = fmt.Errorf("%w\n%s is a required field", err, v.Path()) 72 | } 73 | return true 74 | }, nil) 75 | 76 | if !isValid { 77 | return err 78 | } 79 | 80 | if noStrict { 81 | return nil 82 | } 83 | 84 | builders, err := stackbuilder.NewEnvironments(value.LookupPath(cue.ParsePath(buildersPath))) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | stack, err := stack.NewStack(stackValue, "", []string{}) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | return stackbuilder.CheckTraitFulfillment(builders, stack) 95 | } 96 | 97 | func Discover(configDir string, showDefs bool, showTransformers bool) error { 98 | overlays, err := utils.GetOverlays(configDir) 99 | if err != nil { 100 | return err 101 | } 102 | instances := utils.LoadInstances(configDir, &overlays) 103 | 104 | deps := instances[0].Dependencies() 105 | 106 | for _, dep := range deps { 107 | if strings.Contains(dep.ID(), "traits") { 108 | ctx := cuecontext.New() 109 | value := ctx.BuildInstance(dep) 110 | 111 | fieldIter, _ := value.Fields(cue.Definitions(true), cue.Docs(true)) 112 | message := fmt.Sprintf("[🏷️ traits] \"%s\"\n", dep.ID()) 113 | for fieldIter.Next() { 114 | traits := fieldIter.Value().LookupPath(cue.ParsePath("$metadata.traits")) 115 | if traits.Exists() && traits.IsConcrete() { 116 | message += fmt.Sprintf("%s.%s", dep.PkgName, fieldIter.Selector().String()) 117 | if utils.HasComments(fieldIter.Value()) { 118 | message += fmt.Sprintf("\t%s", utils.GetComments(fieldIter.Value())) 119 | } 120 | message += "\n" 121 | if showDefs { 122 | message += fmt.Sprintf("%s\n\n", fieldIter.Value()) 123 | } 124 | } 125 | } 126 | log.Info(message) 127 | } 128 | if showTransformers && strings.Contains(dep.ID(), "transformers") { 129 | ctx := cuecontext.New() 130 | value := ctx.BuildInstance(dep) 131 | 132 | fieldIter, _ := value.Fields(cue.Definitions(true), cue.Docs(true)) 133 | 134 | message := fmt.Sprintf("[🏭 transformers] \"%s\"\n", dep.ID()) 135 | for fieldIter.Next() { 136 | required := "" 137 | 138 | traits := fieldIter.Value().LookupPath(cue.ParsePath("input.$metadata.traits")) 139 | if traits.Exists() { 140 | traitIter, _ := traits.Fields() 141 | for traitIter.Next() { 142 | required = fmt.Sprintf("%s trait:%s", required, traitIter.Label()) 143 | } 144 | } 145 | 146 | message += fmt.Sprintf("%s.%s", dep.PkgName, fieldIter.Selector().String()) 147 | if utils.HasComments(fieldIter.Value()) { 148 | message += fmt.Sprintf("\t%s", utils.GetComments(fieldIter.Value())) 149 | } 150 | if len(required) > 0 { 151 | message += fmt.Sprintf(" (requires%s)", required) 152 | } 153 | message += "\n" 154 | if showDefs { 155 | message += fmt.Sprintf("%s\n\n", fieldIter.Value()) 156 | } 157 | } 158 | log.Info(message) 159 | } 160 | } 161 | 162 | return nil 163 | } 164 | 165 | func Generate(configDir string) error { 166 | appPath := path.Join(configDir, "stack.cue") 167 | 168 | os.WriteFile(appPath, []byte(`package main 169 | 170 | import ( 171 | "stakpak.dev/devx/v1" 172 | "stakpak.dev/devx/v1/traits" 173 | ) 174 | 175 | stack: v1.#Stack & { 176 | components: { 177 | cowsay: { 178 | traits.#Workload 179 | containers: default: { 180 | image: "docker/whalesay" 181 | command: ["cowsay"] 182 | args: ["Hello DevX!"] 183 | } 184 | } 185 | } 186 | } 187 | `), 0700) 188 | 189 | builderPath := path.Join(configDir, "builder.cue") 190 | os.WriteFile(builderPath, []byte(`package main 191 | 192 | import ( 193 | "stakpak.dev/devx/v2alpha1" 194 | "stakpak.dev/devx/v2alpha1/environments" 195 | ) 196 | 197 | builders: v2alpha1.#Environments & { 198 | dev: environments.#Compose 199 | } 200 | `), 0700) 201 | 202 | return nil 203 | } 204 | 205 | func Update(configDir string, server auth.ServerConfig) error { 206 | cuemodulePath := path.Join(configDir, "cue.mod", "module.cue") 207 | data, err := os.ReadFile(cuemodulePath) 208 | if err != nil { 209 | return err 210 | } 211 | 212 | ctx := cuecontext.New() 213 | cuemodule := ctx.CompileBytes(data) 214 | if cuemodule.Err() != nil { 215 | return cuemodule.Err() 216 | } 217 | 218 | deps := map[string]catalog.ModuleDependency{} 219 | 220 | oldPackages := cuemodule.LookupPath(cue.ParsePath("packages")) 221 | if oldPackages.Exists() { 222 | packages := []string{} 223 | err = oldPackages.Decode(&packages) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | for _, pkg := range packages { 229 | parts := strings.SplitN(pkg, "@", 2) 230 | name := parts[0] 231 | 232 | parts = strings.SplitN(parts[1], ":", 2) 233 | version := parts[0] 234 | dep := catalog.ModuleDependency{ 235 | V: nil, 236 | } 237 | 238 | if semver.IsValid(version) { 239 | dep.V = &version 240 | } 241 | deps[name] = dep 242 | } 243 | 244 | moduleName, err := cuemodule.LookupPath(cue.ParsePath("module")).String() 245 | if err != nil { 246 | return err 247 | } 248 | if err := updateModuleFile(configDir, ctx, moduleName, deps); err != nil { 249 | return err 250 | } 251 | 252 | log.Info("Updated module.cue format") 253 | } 254 | 255 | depsValue := cuemodule.LookupPath(cue.ParsePath("deps")) 256 | if depsValue.Exists() { 257 | if depsValue.Err() != nil { 258 | return depsValue.Err() 259 | } 260 | 261 | err = depsValue.Decode(&deps) 262 | if err != nil { 263 | return err 264 | } 265 | } 266 | 267 | allDeps, err := resolveNestedDependencies(server, deps, 0) 268 | if err != nil { 269 | return err 270 | } 271 | 272 | for name, pkg := range allDeps { 273 | if strings.HasPrefix(name, stakpakPrefix) { 274 | name = strings.TrimPrefix(name, stakpakPrefix) 275 | queryParams := map[string]string{ 276 | "name": name, 277 | } 278 | 279 | if pkg.V != nil { 280 | queryParams["version"] = *pkg.V 281 | } 282 | 283 | data, err := utils.GetData( 284 | server, 285 | path.Join("package", "fetch"), 286 | nil, 287 | queryParams, 288 | ) 289 | if err != nil { 290 | return err 291 | } 292 | packageItem := catalog.ModuleItem{} 293 | err = json.Unmarshal(data, &packageItem) 294 | if err != nil { 295 | return err 296 | } 297 | 298 | installedVersion := "" 299 | if len(packageItem.Tags) > 0 { 300 | installedVersion = packageItem.Tags[0] 301 | } 302 | 303 | log.Infof("📦 Updating %s@%s", name, installedVersion) 304 | 305 | pkgDir := path.Join(configDir, "cue.mod", "pkg", name) 306 | err = os.RemoveAll(pkgDir) 307 | if err != nil { 308 | return err 309 | } 310 | 311 | for path, content := range packageItem.Source { 312 | writePath := filepath.Join(pkgDir, path) 313 | writeDirPath := filepath.Dir(writePath) 314 | if err := os.MkdirAll(writeDirPath, 0755); err != nil { 315 | return err 316 | } 317 | if err != os.WriteFile(writePath, []byte(content), 0700) { 318 | return err 319 | } 320 | } 321 | continue 322 | } 323 | 324 | repoURL := "https://" + name 325 | repoRevision := "main" 326 | if pkg.V != nil { 327 | repoRevision = *pkg.V 328 | } 329 | repoPath := "" 330 | 331 | repo, mfs, err := getRepo(repoURL) 332 | if err != nil { 333 | return err 334 | } 335 | 336 | hash, err := repo.ResolveRevision(plumbing.Revision(repoRevision)) 337 | if err != nil { 338 | return err 339 | } 340 | 341 | log.Infof("📦 Updating %s@%s", name, hash) 342 | 343 | w, err := repo.Worktree() 344 | if err != nil { 345 | return err 346 | } 347 | 348 | err = w.Checkout(&git.CheckoutOptions{ 349 | Hash: *hash, 350 | }) 351 | if err != nil { 352 | return err 353 | } 354 | 355 | moduleFilePath := filepath.Join("cue.mod", "module.cue") 356 | _, err = (*mfs).Lstat(moduleFilePath) 357 | if err == nil { 358 | content, err := (*mfs).Open(moduleFilePath) 359 | if err != nil { 360 | return err 361 | } 362 | moduleData, err := io.ReadAll(bufio.NewReader(content)) 363 | if err != nil { 364 | return err 365 | } 366 | module := ctx.CompileBytes(moduleData) 367 | moduleName := module.LookupPath(cue.ParsePath("module")) 368 | if moduleName.Err() != nil { 369 | return moduleName.Err() 370 | } 371 | 372 | modulePrefix, err := moduleName.String() 373 | if err != nil { 374 | return err 375 | } 376 | 377 | log.Debug("Module prefix: ", modulePrefix) 378 | pkgDir := path.Join(configDir, "cue.mod", "pkg", modulePrefix) 379 | pkgSubDir := path.Join(pkgDir, repoPath) 380 | log.Debug("Updating package ", pkgSubDir) 381 | err = os.RemoveAll(pkgSubDir) 382 | if err != nil { 383 | return err 384 | } 385 | 386 | err = utils.FsWalk(*mfs, repoPath, func(file string, content []byte) error { 387 | if strings.HasPrefix(file, ".") || 388 | strings.HasPrefix(file, "cue.mod") || 389 | // strings.HasPrefix(file, "pkg") || 390 | !strings.HasSuffix(file, ".cue") { 391 | return nil 392 | } 393 | 394 | writePath := path.Join(pkgDir, file) 395 | if err := os.MkdirAll(filepath.Dir(writePath), 0755); err != nil { 396 | return err 397 | } 398 | return os.WriteFile(writePath, content, 0700) 399 | }) 400 | if err != nil { 401 | return err 402 | } 403 | 404 | log.Debugf("Updating packages %s dependencies", pkgDir) 405 | if strings.HasPrefix(modulePrefix, "stakpak.dev/devx") { 406 | moduleDepPkgPath := path.Join("cue.mod", "pkg") 407 | packageInfo, err := (*mfs).ReadDir(moduleDepPkgPath) 408 | if err != nil { 409 | return err 410 | } 411 | 412 | for _, info := range packageInfo { 413 | modPkgDir := path.Join(moduleDepPkgPath, info.Name()) 414 | pkgDir := path.Join(configDir, modPkgDir) 415 | log.Debug("Updating dependency ", modPkgDir) 416 | err = os.RemoveAll(pkgDir) 417 | if err != nil { 418 | return err 419 | } 420 | 421 | err = utils.FsWalk(*mfs, modPkgDir, func(file string, content []byte) error { 422 | writePath := path.Join(configDir, file) 423 | if err := os.MkdirAll(filepath.Dir(writePath), 0755); err != nil { 424 | return err 425 | } 426 | return os.WriteFile(writePath, content, 0700) 427 | }) 428 | if err != nil { 429 | return err 430 | } 431 | } 432 | } 433 | 434 | continue 435 | } 436 | 437 | // fallback to legacy package management 438 | packageInfo, err := (*mfs).ReadDir(repoPath) 439 | if err != nil { 440 | return err 441 | } 442 | 443 | for _, info := range packageInfo { 444 | pkgDir := path.Join(configDir, "cue.mod", repoPath, info.Name()) 445 | err = os.RemoveAll(pkgDir) 446 | if err != nil { 447 | return err 448 | } 449 | } 450 | 451 | err = utils.FsWalk(*mfs, repoPath, func(file string, content []byte) error { 452 | writePath := path.Join(configDir, "cue.mod", file) 453 | 454 | if err := os.MkdirAll(filepath.Dir(writePath), 0755); err != nil { 455 | return err 456 | } 457 | 458 | return os.WriteFile(writePath, content, 0700) 459 | }) 460 | 461 | if err != nil { 462 | return err 463 | } 464 | } 465 | 466 | return nil 467 | } 468 | 469 | func getRepo(repoURL string) (*git.Repository, *billy.Filesystem, error) { 470 | // try without auth 471 | mfs := memfs.New() 472 | storer := memory.NewStorage() 473 | repo, err := git.Clone(storer, mfs, &git.CloneOptions{ 474 | NoCheckout: true, 475 | URL: repoURL, 476 | Depth: 1, 477 | }) 478 | if err == nil { 479 | return repo, &mfs, nil 480 | } 481 | if err.Error() != "authentication required" { 482 | return nil, nil, err 483 | } 484 | 485 | // fetch credentials 486 | gitUsername := os.Getenv("GIT_USERNAME") 487 | gitPassword := os.Getenv("GIT_PASSWORD") 488 | gitPrivateKeyFile := os.Getenv("GIT_PRIVATE_KEY_FILE") 489 | gitPrivateKeyFilePassword := os.Getenv("GIT_PRIVATE_KEY_FILE_PASSWORD") 490 | 491 | if gitPrivateKeyFile == "" && gitPassword == "" { 492 | return nil, nil, fmt.Errorf(`To access private repos please provide 493 | GIT_USERNAME & GIT_PASSWORD 494 | or 495 | GIT_PRIVATE_KEY_FILE & GIT_PRIVATE_KEY_FILE_PASSWORD`) 496 | } 497 | 498 | if gitPassword != "" { 499 | auth := http.BasicAuth{ 500 | Username: gitUsername, 501 | Password: gitPassword, 502 | } 503 | 504 | mfs = memfs.New() 505 | storer = memory.NewStorage() 506 | repo, err = git.Clone(storer, mfs, &git.CloneOptions{ 507 | URL: repoURL, 508 | Auth: &auth, 509 | Depth: 1, 510 | }) 511 | if err != nil { 512 | return nil, nil, err 513 | } 514 | return repo, &mfs, nil 515 | } 516 | 517 | if gitPrivateKeyFile != "" { 518 | publicKeys, err := ssh.NewPublicKeysFromFile("git", gitPrivateKeyFile, gitPrivateKeyFilePassword) 519 | if err != nil { 520 | return nil, nil, fmt.Errorf("failed to use git private key %s: %s", gitPrivateKeyFile, err.Error()) 521 | } 522 | 523 | mfs = memfs.New() 524 | storer = memory.NewStorage() 525 | repo, err = git.Clone(storer, mfs, &git.CloneOptions{ 526 | URL: repoURL, 527 | Auth: publicKeys, 528 | Depth: 1, 529 | }) 530 | if err != nil { 531 | return nil, nil, err 532 | } 533 | return repo, &mfs, nil 534 | } 535 | 536 | return nil, nil, fmt.Errorf("Could not fetch repo") 537 | } 538 | 539 | func Init(ctx context.Context, parentDir, module string) error { 540 | absParentDir, err := filepath.Abs(parentDir) 541 | if err != nil { 542 | return err 543 | } 544 | 545 | modDir := path.Join(absParentDir, "cue.mod") 546 | if err := os.MkdirAll(modDir, 0755); err != nil { 547 | if !errors.Is(err, os.ErrExist) { 548 | return err 549 | } 550 | } 551 | 552 | modFile := path.Join(modDir, "module.cue") 553 | if _, err := os.Stat(modFile); err != nil { 554 | statErr, ok := err.(*os.PathError) 555 | if !ok { 556 | return statErr 557 | } 558 | 559 | ctx := cuecontext.New() 560 | if err := updateModuleFile(parentDir, ctx, module, map[string]catalog.ModuleDependency{ 561 | "github.com/stakpak/devx-catalog": { 562 | V: nil, 563 | }, 564 | }); err != nil { 565 | return err 566 | } 567 | } 568 | 569 | if err := os.Mkdir(path.Join(modDir, "pkg"), 0755); err != nil { 570 | if !errors.Is(err, os.ErrExist) { 571 | return err 572 | } 573 | } 574 | 575 | return nil 576 | } 577 | 578 | type ProjectData struct { 579 | Stack string `json:"stack"` 580 | Environments []string `json:"environments"` 581 | Imports []string `json:"imports"` 582 | Git *gitrepo.ProjectGitData `json:"git"` 583 | } 584 | 585 | func Publish(configDir string, stackPath string, buildersPath string, server auth.ServerConfig) error { 586 | project := ProjectData{} 587 | 588 | overlays, err := utils.GetOverlays(configDir) 589 | if err != nil { 590 | return err 591 | } 592 | 593 | value, stackId, depIds := utils.LoadProject(configDir, &overlays) 594 | if err := ValidateProject(value, stackPath, buildersPath, false); err != nil { 595 | return err 596 | } 597 | 598 | if stackId == "" { 599 | return fmt.Errorf("cannot publish this stack without a module id. please set the \"module\" key to a unique value in \"cue.mod/module.cue\"") 600 | } 601 | 602 | project.Stack = stackId 603 | project.Imports = depIds 604 | 605 | _, err = stack.NewStack(value.LookupPath(cue.ParsePath(stackPath)), stackId, depIds) 606 | if err != nil { 607 | return err 608 | } 609 | 610 | builders, err := stackbuilder.NewEnvironments(value.LookupPath(cue.ParsePath(buildersPath))) 611 | if err != nil { 612 | return err 613 | } 614 | environments := make([]string, 0, len(builders)) 615 | for k := range builders { 616 | environments = append(environments, k) 617 | } 618 | project.Environments = environments 619 | 620 | gitData, err := gitrepo.GetProjectGitData(configDir) 621 | if err != nil { 622 | return err 623 | } 624 | project.Git = gitData 625 | 626 | _, err = utils.SendData(server, "stacks", &project) 627 | if err != nil { 628 | return err 629 | } 630 | 631 | log.Infof("🚀 Published %s successfully", stackId) 632 | 633 | return nil 634 | } 635 | 636 | func Import(newPackage string, configDir string, server auth.ServerConfig) error { 637 | pkgParts := strings.Split(newPackage, "@") 638 | if len(pkgParts) < 2 { 639 | return fmt.Errorf("invalid package format, expected \"@\"") 640 | } 641 | if len(pkgParts[0]) == 0 { 642 | return fmt.Errorf("invalid package format, git repo should not be empty") 643 | } 644 | if len(pkgParts[1]) == 0 { 645 | return fmt.Errorf("invalid package format, git revision should not be empty") 646 | } 647 | gitRepo := pkgParts[0] 648 | gitRevision := pkgParts[1] 649 | 650 | cuemodulePath := path.Join(configDir, "cue.mod", "module.cue") 651 | data, err := os.ReadFile(cuemodulePath) 652 | if err != nil { 653 | return err 654 | } 655 | 656 | ctx := cuecontext.New() 657 | cuemodule := ctx.CompileBytes(data) 658 | if cuemodule.Err() != nil { 659 | return cuemodule.Err() 660 | } 661 | 662 | moduleName, err := cuemodule.LookupPath(cue.ParsePath("module")).String() 663 | if err != nil { 664 | return err 665 | } 666 | 667 | deps := map[string]catalog.ModuleDependency{} 668 | depsValue := cuemodule.LookupPath(cue.ParsePath("deps")) 669 | if depsValue.Exists() { 670 | err = depsValue.Decode(&deps) 671 | if err != nil { 672 | return err 673 | } 674 | } 675 | 676 | for name := range deps { 677 | if strings.HasPrefix(name, gitRepo) { 678 | log.Infof("Module %s already exists", gitRepo) 679 | return nil 680 | } 681 | } 682 | 683 | deps[gitRepo] = catalog.ModuleDependency{ 684 | V: &gitRevision, 685 | } 686 | 687 | if err := updateModuleFile(configDir, ctx, moduleName, deps); err != nil { 688 | return err 689 | } 690 | 691 | err = Update(configDir, server) 692 | if err != nil { 693 | log.Error(err.Error()) 694 | return errors.New("failed to update packages, fix this issue and re-run devx project update") 695 | } 696 | 697 | return nil 698 | } 699 | 700 | func updateModuleFile(configDir string, ctx *cue.Context, module string, deps map[string]catalog.ModuleDependency) error { 701 | cuemodulePath := path.Join(configDir, "cue.mod", "module.cue") 702 | newcuemodule := ctx.CompileString("") 703 | newcuemodule = newcuemodule.FillPath(cue.ParsePath("module"), module) 704 | newcuemodule = newcuemodule.FillPath(cue.ParsePath("cue.lang"), "v0.6.0-alpha.1") 705 | newcuemodule = newcuemodule.FillPath(cue.ParsePath("deps"), deps) 706 | bytes, err := format.Node(newcuemodule.Syntax(cue.Concrete(true), cue.Final()), format.Simplify()) 707 | if err != nil { 708 | return err 709 | } 710 | err = os.WriteFile(cuemodulePath, bytes, 0600) 711 | if err != nil { 712 | return err 713 | } 714 | return nil 715 | } 716 | 717 | func resolveNestedDependencies(server auth.ServerConfig, deps map[string]catalog.ModuleDependency, depth uint) (map[string]catalog.ModuleDependency, error) { 718 | if depth > 10 { 719 | return nil, errors.New("exceeded allowed dependency resolution depth") 720 | } 721 | 722 | newDeps := map[string]catalog.ModuleDependency{} 723 | for name, pkg := range deps { 724 | newDeps[name] = pkg 725 | } 726 | 727 | for name, pkg := range deps { 728 | if !strings.HasPrefix(name, stakpakPrefix) { 729 | continue 730 | } 731 | name = strings.TrimPrefix(name, stakpakPrefix) 732 | queryParams := map[string]string{ 733 | "name": name, 734 | } 735 | 736 | if pkg.V != nil { 737 | queryParams["version"] = *pkg.V 738 | } 739 | 740 | data, err := utils.GetData( 741 | server, 742 | path.Join("package", "fetch"), 743 | nil, 744 | queryParams, 745 | ) 746 | if err != nil { 747 | return nil, err 748 | } 749 | packageItem := catalog.ModuleItem{} 750 | err = json.Unmarshal(data, &packageItem) 751 | if err != nil { 752 | return nil, err 753 | } 754 | 755 | nestedDeps, err := resolveNestedDependencies(server, packageItem.Dependencies, depth+1) 756 | if err != nil { 757 | return nil, err 758 | } 759 | 760 | for nestedName, nestedPkg := range nestedDeps { 761 | if duplicateNestedPkg, ok := newDeps[nestedName]; ok { 762 | // if no collision skip and keep existing one 763 | if duplicateNestedPkg.V == nestedPkg.V { 764 | continue 765 | } 766 | 767 | va, vb := "", "" 768 | if duplicateNestedPkg.V != nil { 769 | va = *duplicateNestedPkg.V 770 | } 771 | if nestedPkg.V != nil { 772 | vb = *nestedPkg.V 773 | } 774 | log.Warnf( 775 | "possible dependency collision for %s between %s and %s", 776 | nestedName, va, vb, 777 | ) 778 | 779 | // if existing package has no version, replace it by new dependency version 780 | if duplicateNestedPkg.V == nil { 781 | newDeps[nestedName] = nestedPkg 782 | log.Warnf( 783 | "using %s", 784 | vb, 785 | ) 786 | continue 787 | } 788 | 789 | // if new package has no version, keep the old version 790 | if nestedPkg.V == nil { 791 | log.Warnf( 792 | "using %s", 793 | va, 794 | ) 795 | continue 796 | } 797 | 798 | return nil, fmt.Errorf( 799 | "dependency collision for module %s: between %s@%s and %s@%s", 800 | packageItem.Module, 801 | nestedName, va, 802 | nestedName, vb, 803 | ) 804 | } 805 | newDeps[nestedName] = nestedPkg 806 | } 807 | } 808 | 809 | return newDeps, nil 810 | } 811 | --------------------------------------------------------------------------------