├── .gitignore ├── brewkit.jsonnet ├── Dockerfile ├── docs ├── img │ ├── architecture-pkg.png │ └── architecture-overview.png ├── architecture.md ├── readme.md ├── config │ └── overview.md ├── cli │ └── overview.md ├── build-definition │ ├── jsonnet-extensions.md │ ├── overview.md │ └── reference.md ├── diagrams │ └── architecture.drawio.xml ├── adr │ ├── 001-buildkit.md │ └── 002-jsonnet.md └── features.md ├── internal ├── frontend │ ├── app │ │ ├── version │ │ │ └── version.go │ │ ├── buildconfig │ │ │ ├── constants.go │ │ │ ├── parser.go │ │ │ └── config.go │ │ ├── reporter │ │ │ └── reporter.go │ │ ├── config │ │ │ ├── config.go │ │ │ ├── parser.go │ │ │ └── defaults.go │ │ ├── builddefinition │ │ │ ├── errors.go │ │ │ ├── model.go │ │ │ ├── trace.go │ │ │ ├── builder.go │ │ │ └── vertex.go │ │ └── service │ │ │ ├── cache.go │ │ │ └── build.go │ └── infrastructure │ │ ├── config │ │ ├── model.go │ │ └── parser.go │ │ ├── jsonnet │ │ └── formatter.go │ │ └── builddefinition │ │ ├── funcs.go │ │ ├── model.go │ │ ├── nativefunc.go │ │ └── parser.go ├── backend │ ├── app │ │ ├── ssh │ │ │ └── agentprovider.go │ │ ├── reporter │ │ │ └── reporter.go │ │ ├── docker │ │ │ ├── errors.go │ │ │ └── client.go │ │ ├── cache │ │ │ └── service.go │ │ ├── dockerfile │ │ │ ├── vargenerator.go │ │ │ └── targetgenerator.go │ │ └── build │ │ │ └── service.go │ ├── api │ │ ├── api.go │ │ └── model.go │ └── infrastructure │ │ ├── ssh │ │ └── agentprovider.go │ │ └── docker │ │ ├── outputparser.go │ │ └── client.go ├── common │ ├── infrastructure │ │ ├── executor │ │ │ ├── logger.go │ │ │ ├── args.go │ │ │ ├── opt.go │ │ │ └── executor.go │ │ └── logger │ │ │ ├── executorlogger.go │ │ │ └── logger.go │ ├── maybe │ │ ├── fmt.go │ │ ├── json.go │ │ └── maybe.go │ ├── slices │ │ ├── index.go │ │ ├── diff.go │ │ └── map.go │ ├── maps │ │ ├── maps.go │ │ └── set.go │ └── either │ │ ├── json.go │ │ └── either.go └── dockerfile │ └── dockerfile.go ├── brewkit ├── images.libsonnet └── project.libsonnet ├── go.mod ├── data └── specification │ ├── config │ └── v1.json │ └── build-definition │ └── v1.json ├── cmd └── brewkit │ ├── fmt.go │ ├── version.go │ ├── cache.go │ ├── common.go │ ├── config.go │ ├── main.go │ └── build.go ├── .golangci.yml ├── LICENSE ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.vscode/ 3 | /bin/brewkit 4 | vendor 5 | 6 | -------------------------------------------------------------------------------- /brewkit.jsonnet: -------------------------------------------------------------------------------- 1 | local project = import 'brewkit/project.libsonnet'; 2 | 3 | project.project() -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker:20.10 2 | 3 | COPY bin/brewkit /usr/local/bin/ 4 | 5 | ENTRYPOINT ["brewkit"] -------------------------------------------------------------------------------- /docs/img/architecture-pkg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ispringtech/brewkit/HEAD/docs/img/architecture-pkg.png -------------------------------------------------------------------------------- /internal/frontend/app/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const ( 4 | APIVersionV1 = "brewkit/v1" 5 | ) 6 | -------------------------------------------------------------------------------- /docs/img/architecture-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ispringtech/brewkit/HEAD/docs/img/architecture-overview.png -------------------------------------------------------------------------------- /internal/backend/app/ssh/agentprovider.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | type AgentProvider interface { 4 | Default() string 5 | } 6 | -------------------------------------------------------------------------------- /internal/frontend/app/buildconfig/constants.go: -------------------------------------------------------------------------------- 1 | package buildconfig 2 | 3 | const ( 4 | DefaultName = "brewkit.jsonnet" 5 | ) 6 | -------------------------------------------------------------------------------- /internal/common/infrastructure/executor/logger.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | type Logger interface { 4 | Info(s string) 5 | Debug(s string) 6 | } 7 | -------------------------------------------------------------------------------- /brewkit/images.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | golang: "golang:1.20", 3 | golangcilint: "golangci/golangci-lint:v1.53", 4 | 5 | dockerfile: "docker/dockerfile:1.4" 6 | } -------------------------------------------------------------------------------- /internal/backend/app/reporter/reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | type Reporter interface { 4 | Logf(format string, a ...any) 5 | Debugf(format string, a ...any) 6 | } 7 | -------------------------------------------------------------------------------- /internal/frontend/app/reporter/reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | type Reporter interface { 4 | Logf(format string, a ...any) 5 | Debugf(format string, a ...any) 6 | } 7 | -------------------------------------------------------------------------------- /internal/frontend/app/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Secrets []Secret 5 | } 6 | 7 | type Secret struct { 8 | ID string 9 | Path string 10 | } 11 | -------------------------------------------------------------------------------- /internal/frontend/app/builddefinition/errors.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | var ( 8 | ErrUnsupportedAPIVersion = errors.New("unsupported API version") 9 | ) 10 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/config/model.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Secrets []Secret `json:"secrets"` 5 | } 6 | 7 | type Secret struct { 8 | ID string `json:"id"` 9 | Path string `json:"path"` 10 | } 11 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## BrewKit context level architecture overview 4 | 5 | ![brewkit-arc-overview](img/architecture-overview.png) 6 | 7 | ## Brewkit component level architecture 8 | 9 | ![brewkit-arc-pkg](img/architecture-pkg.png) 10 | -------------------------------------------------------------------------------- /internal/frontend/app/buildconfig/parser.go: -------------------------------------------------------------------------------- 1 | package buildconfig 2 | 3 | type Parser interface { 4 | Parse(path string) (Config, error) 5 | // CompileConfig templates config file and returns it raw without parsing 6 | CompileConfig(configPath string) (string, error) 7 | } 8 | -------------------------------------------------------------------------------- /internal/common/infrastructure/executor/args.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | type Args []string 4 | 5 | func (args *Args) AddKV(key, value string) { 6 | *args = append(*args, key, value) 7 | } 8 | 9 | func (args *Args) AddArgs(arguments ...string) { 10 | *args = append(*args, arguments...) 11 | } 12 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # BrewKit documentation 2 | 3 | * [**Build definition overview**](build-definition/overview.md) 4 | * [**Build definition reference**](build-definition/reference.md) 5 | 6 | * [**Host config**](config/overview.md) 7 | * [**CLI**](cli/overview.md) 8 | 9 | * [Architecture](architecture.md) 10 | -------------------------------------------------------------------------------- /internal/frontend/app/config/parser.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | stderrors "errors" 5 | ) 6 | 7 | var ( 8 | ErrConfigNotFound = stderrors.New("config not found") 9 | ) 10 | 11 | type Parser interface { 12 | Config(configPath string) (Config, error) 13 | Dump(config Config) ([]byte, error) 14 | } 15 | -------------------------------------------------------------------------------- /internal/common/maybe/fmt.go: -------------------------------------------------------------------------------- 1 | package maybe 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (m Maybe[T]) String() string { 8 | if !Valid(m) { 9 | return "" 10 | } 11 | 12 | // Convert to empty interface to make the go compiler satisfied 13 | var v interface{} = Just(m) 14 | 15 | return fmt.Sprintf("%s", v) 16 | } 17 | -------------------------------------------------------------------------------- /internal/common/slices/index.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | import ( 4 | "github.com/ispringtech/brewkit/internal/common/maybe" 5 | ) 6 | 7 | func Find[T any](s []T, f func(T) bool) maybe.Maybe[T] { 8 | for _, v := range s { 9 | if f(v) { 10 | return maybe.NewJust(v) 11 | } 12 | } 13 | 14 | return maybe.NewNone[T]() 15 | } 16 | -------------------------------------------------------------------------------- /internal/backend/app/docker/errors.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type RequestError struct { 8 | Output string 9 | Code int 10 | } 11 | 12 | func (e RequestError) Error() string { 13 | msg := fmt.Sprintf("request to docker client failed: code %d\n", e.Code) 14 | if e.Output != "" { 15 | msg += fmt.Sprintf("%s\n", e.Output) 16 | } 17 | 18 | return msg 19 | } 20 | -------------------------------------------------------------------------------- /internal/common/maps/maps.go: -------------------------------------------------------------------------------- 1 | package maps 2 | 3 | func FromSlice[K comparable, V, E any](s []V, f func(V) (K, E)) map[K]E { 4 | res := map[K]E{} 5 | for _, v := range s { 6 | k, newV := f(v) 7 | res[k] = newV 8 | } 9 | return res 10 | } 11 | 12 | func ToSlice[K comparable, V, E any](m map[K]E, f func(K, E) V) []V { 13 | res := make([]V, 0, len(m)) 14 | for k, e := range m { 15 | res = append(res, f(k, e)) 16 | } 17 | return res 18 | } 19 | -------------------------------------------------------------------------------- /internal/backend/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type BuildParams struct { 8 | ForcePull bool 9 | } 10 | 11 | type ClearParams struct { 12 | All bool 13 | } 14 | 15 | type BuilderAPI interface { 16 | Build(ctx context.Context, v Vertex, vars []Var, secretsSrc []SecretSrc, params BuildParams) error 17 | } 18 | 19 | type CacheAPI interface { 20 | ClearCache(ctx context.Context, params ClearParams) error 21 | } 22 | -------------------------------------------------------------------------------- /internal/common/maybe/json.go: -------------------------------------------------------------------------------- 1 | package maybe 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | func (m *Maybe[T]) UnmarshalJSON(bytes []byte) error { 8 | var v T 9 | err := json.Unmarshal(bytes, &v) 10 | if err != nil { 11 | return err 12 | } 13 | 14 | m.v = v 15 | m.valid = true 16 | 17 | return nil 18 | } 19 | 20 | func (m Maybe[T]) MarshalJSON() ([]byte, error) { 21 | if !m.valid { 22 | return []byte("null"), nil 23 | } 24 | return json.Marshal(m.v) 25 | } 26 | -------------------------------------------------------------------------------- /internal/frontend/app/config/defaults.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | const ( 11 | defaultConfigPath = "%s/.brewkit/config" 12 | ) 13 | 14 | var ( 15 | DefaultConfig = Config{} 16 | ) 17 | 18 | func DefaultConfigPath() (string, error) { 19 | homeDir, err := os.UserHomeDir() 20 | if err != nil { 21 | return "", errors.Wrap(err, "failed to receive user home dir") 22 | } 23 | 24 | return fmt.Sprintf(defaultConfigPath, homeDir), nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/common/maps/set.go: -------------------------------------------------------------------------------- 1 | package maps 2 | 3 | // Set on map with empty struct as value 4 | type Set[T comparable] map[T]struct{} 5 | 6 | func (s *Set[T]) Add(v T) { 7 | (*s)[v] = struct{}{} 8 | } 9 | 10 | func (s *Set[T]) Remove(v T) { 11 | delete(*s, v) 12 | } 13 | 14 | func (s *Set[T]) Has(v T) bool { 15 | _, has := (*s)[v] 16 | return has 17 | } 18 | 19 | func SetFromSlice[T any, E comparable](s []T, f func(T) E) Set[E] { 20 | return FromSlice(s, func(v T) (E, struct{}) { 21 | return f(v), struct{}{} 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /internal/common/infrastructure/logger/executorlogger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "github.com/ispringtech/brewkit/internal/common/infrastructure/executor" 5 | ) 6 | 7 | func NewExecutorLogger(logger Logger) executor.Logger { 8 | return &executorLogger{logger: logger} 9 | } 10 | 11 | type executorLogger struct { 12 | logger Logger 13 | } 14 | 15 | func (logger *executorLogger) Info(s string) { 16 | logger.logger.Logf(s) 17 | } 18 | 19 | func (logger *executorLogger) Debug(s string) { 20 | logger.logger.Debugf(s) 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ispringtech/brewkit 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/google/go-jsonnet v0.20.0 7 | github.com/pkg/errors v0.9.1 8 | github.com/urfave/cli/v2 v2.25.1 9 | golang.org/x/exp v0.0.0-20230420155640-133eef4313cb 10 | ) 11 | 12 | require ( 13 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 14 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 15 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 16 | gopkg.in/yaml.v2 v2.2.7 // indirect 17 | sigs.k8s.io/yaml v1.1.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /internal/backend/app/cache/service.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ispringtech/brewkit/internal/backend/api" 7 | "github.com/ispringtech/brewkit/internal/backend/app/docker" 8 | ) 9 | 10 | func NewCacheService(dockerClient docker.Client) api.CacheAPI { 11 | return &cacheService{dockerClient: dockerClient} 12 | } 13 | 14 | type cacheService struct { 15 | dockerClient docker.Client 16 | } 17 | 18 | func (service *cacheService) ClearCache(ctx context.Context, params api.ClearParams) error { 19 | return service.dockerClient.ClearCache(ctx, docker.ClearCacheParams{ 20 | All: params.All, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /internal/frontend/app/builddefinition/model.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | stdslices "golang.org/x/exp/slices" 5 | 6 | "github.com/ispringtech/brewkit/internal/backend/api" 7 | "github.com/ispringtech/brewkit/internal/common/maybe" 8 | ) 9 | 10 | type Definition struct { 11 | Vertexes []api.Vertex 12 | Vars []api.Var 13 | } 14 | 15 | func (d Definition) Vertex(name string) maybe.Maybe[api.Vertex] { 16 | i := stdslices.IndexFunc(d.Vertexes, func(vertex api.Vertex) bool { 17 | return vertex.Name == name 18 | }) 19 | if i == -1 { 20 | return maybe.Maybe[api.Vertex]{} 21 | } 22 | 23 | return maybe.NewJust(d.Vertexes[i]) 24 | } 25 | -------------------------------------------------------------------------------- /internal/common/slices/diff.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | // Diff return diff elements from slices 4 | func Diff[T comparable](s1, s2 []T) []T { 5 | s1Map := map[T]struct{}{} 6 | for _, element := range s1 { 7 | s1Map[element] = struct{}{} 8 | } 9 | s2Map := map[T]struct{}{} 10 | for _, element := range s2 { 11 | s2Map[element] = struct{}{} 12 | } 13 | 14 | var res []T 15 | for _, element := range s2 { 16 | if _, exists := s1Map[element]; !exists { 17 | res = append(res, element) 18 | } 19 | } 20 | for _, element := range s1 { 21 | if _, exists := s2Map[element]; !exists { 22 | res = append(res, element) 23 | } 24 | } 25 | return res 26 | } 27 | -------------------------------------------------------------------------------- /internal/frontend/app/service/cache.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ispringtech/brewkit/internal/backend/api" 7 | ) 8 | 9 | type ClearCacheParam struct { 10 | All bool 11 | } 12 | 13 | type Cache interface { 14 | ClearCache(ctx context.Context, param ClearCacheParam) error 15 | } 16 | 17 | func NewCacheService(cacheAPI api.CacheAPI) Cache { 18 | return &cacheService{cacheAPI: cacheAPI} 19 | } 20 | 21 | type cacheService struct { 22 | cacheAPI api.CacheAPI 23 | } 24 | 25 | func (service *cacheService) ClearCache(ctx context.Context, param ClearCacheParam) error { 26 | return service.cacheAPI.ClearCache(ctx, api.ClearParams{ 27 | All: param.All, 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /internal/backend/infrastructure/ssh/agentprovider.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pkg/errors" 7 | 8 | "github.com/ispringtech/brewkit/internal/backend/app/ssh" 9 | ) 10 | 11 | const ( 12 | sshAuthSock = "SSH_AUTH_SOCK" 13 | ) 14 | 15 | func NewAgentProvider() (ssh.AgentProvider, error) { 16 | socket, found := os.LookupEnv(sshAuthSock) 17 | if !found { 18 | return nil, errors.Errorf("ssh auth socket via env %s not found", sshAuthSock) 19 | } 20 | 21 | return &agentProvider{defaultAgent: socket}, nil 22 | } 23 | 24 | type agentProvider struct { 25 | defaultAgent string 26 | } 27 | 28 | func (provider agentProvider) Default() string { 29 | return provider.defaultAgent 30 | } 31 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/jsonnet/formatter.go: -------------------------------------------------------------------------------- 1 | package jsonnet 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | jsonnetformatter "github.com/google/go-jsonnet/formatter" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type Formatter struct{} 12 | 13 | func (formatter Formatter) Format(configPath string) (string, error) { 14 | filename := path.Base(configPath) 15 | configData, err := os.ReadFile(configPath) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | options := jsonnetformatter.DefaultOptions() 21 | options.Indent = 4 22 | options.StringStyle = jsonnetformatter.StringStyleLeave 23 | 24 | data, err := jsonnetformatter.Format(filename, string(configData), options) 25 | return data, errors.Wrap(err, "failed to format config file") 26 | } 27 | -------------------------------------------------------------------------------- /internal/common/either/json.go: -------------------------------------------------------------------------------- 1 | package either 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func (e *Either[L, R]) UnmarshalJSON(bytes []byte) error { 9 | l := new(L) 10 | lErr := json.Unmarshal(bytes, &l) 11 | if lErr == nil { 12 | *e = NewLeft[L, R](*l) 13 | return nil 14 | } 15 | 16 | r := new(R) 17 | rErr := json.Unmarshal(bytes, &r) 18 | if rErr == nil { 19 | *e = NewRight[L, R](*r) 20 | return nil 21 | } 22 | 23 | return fmt.Errorf("failed to unmarshal either to %T or %T", l, r) 24 | } 25 | 26 | func (e Either[L, R]) MarshalJSON() (res []byte, err error) { 27 | e. 28 | MapLeft(func(l L) { 29 | res, err = json.Marshal(l) 30 | }). 31 | MapRight(func(r R) { 32 | res, err = json.Marshal(r) 33 | }) 34 | 35 | return res, err 36 | } 37 | -------------------------------------------------------------------------------- /data/specification/config/v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "BrewKit config", 3 | "type": "object", 4 | "properties": { 5 | "secrets": { 6 | "type": "array", 7 | "items": { 8 | "$ref": "#/$defs/secret" 9 | } 10 | } 11 | }, 12 | 13 | "$defs": { 14 | "secret": { 15 | "type": "object", 16 | "properties": { 17 | "id": { 18 | "description": "Unique secret id", 19 | "type": "string" 20 | }, 21 | "path": { 22 | "description": "Path to secret on host", 23 | "type": "string" 24 | } 25 | }, 26 | "required": [ "id", "path" ] 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /docs/config/overview.md: -------------------------------------------------------------------------------- 1 | # BrewKit host config 2 | 3 | Config used to configure brewkit host preferences 4 | 5 | ## Location 6 | 7 | By default, brewkit trying to find config in `${HOME}/.brewkit/config` or in `$BREWKIT_CONFIG`. If there is no such path, used empty config. 8 | There is no config auto creation 9 | 10 | 11 | ## Reference 12 | 13 | [Schema v1](/data/specification/config/v1.json) 14 | 15 | ### Secrets 16 | 17 | Define secret to use in build-definition. See [secrets in build-definition](/docs/build-definition/reference.md#secrets) 18 | 19 | ```jsonnet 20 | { 21 | "secrets": [ 22 | { 23 | // unique id for secret 24 | "id": "aws", 25 | // path may contain env variables 26 | "path": "${HOME}/.aws/credentials" 27 | }, 28 | ] 29 | } 30 | ``` -------------------------------------------------------------------------------- /cmd/brewkit/fmt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | "github.com/ispringtech/brewkit/internal/frontend/infrastructure/jsonnet" 9 | ) 10 | 11 | func fmtCommand() *cli.Command { 12 | return &cli.Command{ 13 | Name: "fmt", 14 | Usage: "jsonnetfmt passed files", 15 | Action: executeFmt, 16 | } 17 | } 18 | 19 | func executeFmt(ctx *cli.Context) error { 20 | formatter := jsonnet.Formatter{} 21 | 22 | for _, filepath := range ctx.Args().Slice() { 23 | fileInfo, err := os.Stat(filepath) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | format, err := formatter.Format(filepath) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | err = os.WriteFile(filepath, []byte(format), fileInfo.Mode()) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /cmd/brewkit/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | appversion "github.com/ispringtech/brewkit/internal/frontend/app/version" 9 | ) 10 | 11 | func version() *cli.Command { 12 | return &cli.Command{ 13 | Name: "version", 14 | Usage: "Show brewkit version info", 15 | Action: executeVersion, 16 | } 17 | } 18 | 19 | func executeVersion(ctx *cli.Context) error { 20 | var opt commonOpt 21 | opt.scan(ctx) 22 | 23 | logger := makeLogger(opt.verbose) 24 | 25 | v := struct { 26 | APIVersion string `json:"apiVersion"` 27 | Commit string `json:"commit"` 28 | Dockerfile string `json:"dockerfile"` 29 | }{ 30 | APIVersion: appversion.APIVersionV1, 31 | Commit: Commit, 32 | Dockerfile: DockerfileImage, 33 | } 34 | 35 | bytes, err := json.Marshal(v) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | logger.Outputf(string(bytes) + "\n") 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/common/infrastructure/executor/opt.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type options struct { 8 | env []string 9 | logger Logger 10 | } 11 | 12 | type Opt interface { 13 | apply(*options) 14 | } 15 | 16 | type optFunc func(*options) 17 | 18 | func (f optFunc) apply(o *options) { 19 | f(o) 20 | } 21 | 22 | func WithEnv(env []string) Opt { 23 | return optFunc(func(o *options) { 24 | o.env = append(o.env, env...) 25 | }) 26 | } 27 | 28 | func WithEnvMap(envMap EnvMap) Opt { 29 | return optFunc(func(o *options) { 30 | o.env = append(o.env, envMap.slice()...) 31 | }) 32 | } 33 | 34 | func WithLogger(logger Logger) Opt { 35 | return optFunc(func(o *options) { 36 | o.logger = logger 37 | }) 38 | } 39 | 40 | type EnvMap map[string]string 41 | 42 | func (e EnvMap) slice() []string { 43 | res := make([]string, 0, len(e)) 44 | for k, v := range e { 45 | res = append(res, fmt.Sprintf("%s=%s", k, v)) 46 | } 47 | return res 48 | } 49 | -------------------------------------------------------------------------------- /internal/common/infrastructure/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type Logger interface { 9 | Logf(format string, a ...any) 10 | Outputf(format string, a ...any) // Used to output info to user 11 | Debugf(format string, a ...any) 12 | } 13 | 14 | func NewLogger(outputWriter, logWriter io.Writer, debug bool) Logger { 15 | return &logger{ 16 | outputWriter: outputWriter, 17 | logWriter: logWriter, 18 | debug: debug, 19 | } 20 | } 21 | 22 | type logger struct { 23 | outputWriter io.Writer 24 | logWriter io.Writer 25 | debug bool 26 | } 27 | 28 | func (l *logger) Logf(format string, a ...any) { 29 | _, _ = fmt.Fprintf(l.logWriter, format, a...) 30 | } 31 | 32 | func (l *logger) Outputf(format string, a ...any) { 33 | _, _ = fmt.Fprintf(l.outputWriter, format, a...) 34 | } 35 | 36 | func (l *logger) Debugf(format string, a ...any) { 37 | if l.debug { 38 | _, _ = fmt.Fprintf(l.logWriter, format, a...) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 5m 3 | issues-exit-code: 1 4 | 5 | linters: 6 | enable: 7 | - errcheck 8 | - gosimple 9 | - govet 10 | - ineffassign 11 | - staticcheck 12 | - unused 13 | - typecheck 14 | - unused 15 | - bodyclose 16 | - importas 17 | - dogsled 18 | - dupl 19 | - gochecknoinits 20 | - gocognit 21 | - gocritic 22 | - gocyclo 23 | - gofmt 24 | - goimports 25 | - revive 26 | - gosec 27 | - misspell 28 | - nakedret 29 | - prealloc 30 | - exportloopref 31 | - stylecheck 32 | - unconvert 33 | - whitespace 34 | - rowserrcheck 35 | - goconst 36 | - asciicheck 37 | - nestif 38 | - exportloopref 39 | - sqlclosecheck 40 | 41 | linters-settings: 42 | govet: 43 | check-shadowing: true 44 | goimports: 45 | local-prefixes: brewkit 46 | gocritic: 47 | disabled-checks: 48 | - sloppyReassign 49 | - whyNoLint 50 | enabled-tags: 51 | - experimental 52 | - opinionated 53 | wrapcheck: 54 | ignorePackageGlobs: 55 | - brewkit/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Richmedia Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the “Software”), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /internal/frontend/app/builddefinition/trace.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | stdslices "golang.org/x/exp/slices" 8 | 9 | "github.com/ispringtech/brewkit/internal/common/slices" 10 | ) 11 | 12 | const ( 13 | from = "from" 14 | deps = "deps" 15 | copyDirective = "copy" 16 | ) 17 | 18 | type traceEntry struct { 19 | name string 20 | directive string 21 | } 22 | 23 | type trace []traceEntry 24 | 25 | func (t *trace) push(s traceEntry) { 26 | *t = append(*t, s) 27 | } 28 | 29 | func (t *trace) pop() { 30 | *t = (*t)[:len(*t)-1] 31 | } 32 | 33 | func (t *trace) has(s string) bool { 34 | return stdslices.ContainsFunc(*t, func(entry traceEntry) bool { 35 | return entry.name == s 36 | }) 37 | } 38 | 39 | func (t *trace) String() string { 40 | if len(*t) == 0 { 41 | return "" 42 | } 43 | 44 | res := make([]traceEntry, 0, len(*t)) 45 | for i := len(*t) - 1; i >= 0; i-- { 46 | res = append(res, (*t)[i]) 47 | } 48 | 49 | return strings.Join(slices.Map(res, func(e traceEntry) string { 50 | return fmt.Sprintf("%s(%s)", e.name, e.directive) 51 | }), "->") 52 | } 53 | -------------------------------------------------------------------------------- /internal/backend/app/docker/client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ispringtech/brewkit/internal/common/maybe" 7 | "github.com/ispringtech/brewkit/internal/dockerfile" 8 | ) 9 | 10 | type BuildParams struct { 11 | Target string 12 | SSHAgent maybe.Maybe[string] 13 | Secrets []SecretData 14 | Output maybe.Maybe[string] 15 | } 16 | 17 | type ValueParams struct { 18 | Var string 19 | SSHAgent maybe.Maybe[string] 20 | Secrets []SecretData 21 | UseCache bool 22 | } 23 | 24 | type ClearCacheParams struct { 25 | All bool 26 | } 27 | 28 | type SecretData struct { 29 | ID string 30 | Path string 31 | } 32 | 33 | type Image struct { 34 | Img string // Image with repository and tag 35 | } 36 | 37 | type Client interface { 38 | Build(ctx context.Context, dockerfile dockerfile.Dockerfile, params BuildParams) error 39 | Value(ctx context.Context, dockerfile dockerfile.Dockerfile, params ValueParams) ([]byte, error) 40 | PullImage(ctx context.Context, img string) error 41 | ListImages(ctx context.Context, images []string) ([]Image, error) 42 | BuildImage(ctx context.Context, dockerfilePath string) error 43 | 44 | ClearCache(ctx context.Context, params ClearCacheParams) error 45 | } 46 | -------------------------------------------------------------------------------- /internal/frontend/app/buildconfig/config.go: -------------------------------------------------------------------------------- 1 | package buildconfig 2 | 3 | import ( 4 | "github.com/ispringtech/brewkit/internal/common/maybe" 5 | ) 6 | 7 | type Config struct { 8 | APIVersion string 9 | Vars []VarData 10 | Targets []TargetData 11 | } 12 | 13 | type VarData struct { 14 | Name string 15 | From string 16 | Platform maybe.Maybe[string] 17 | WorkDir string 18 | Env map[string]string 19 | Cache []Cache 20 | Copy []Copy 21 | Secrets []Secret 22 | Network maybe.Maybe[string] 23 | SSH maybe.Maybe[SSH] 24 | Command string 25 | } 26 | 27 | type TargetData struct { 28 | Name string 29 | DependsOn []string 30 | Stage maybe.Maybe[StageData] 31 | } 32 | 33 | type StageData struct { 34 | From string 35 | Env map[string]string 36 | Command maybe.Maybe[string] 37 | SSH maybe.Maybe[SSH] 38 | Cache []Cache 39 | Copy []Copy 40 | Secrets []Secret 41 | Platform maybe.Maybe[string] 42 | WorkDir string 43 | Network maybe.Maybe[string] 44 | Output maybe.Maybe[Output] 45 | } 46 | 47 | type SSH struct{} 48 | 49 | type Cache struct { 50 | ID string 51 | Path string 52 | } 53 | 54 | type Copy struct { 55 | From maybe.Maybe[string] 56 | Src string 57 | Dst string 58 | } 59 | 60 | type Secret struct { 61 | ID string 62 | Path string 63 | } 64 | 65 | type Output struct { 66 | Artifact string 67 | Local string 68 | } 69 | -------------------------------------------------------------------------------- /cmd/brewkit/cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | 6 | backendcache "github.com/ispringtech/brewkit/internal/backend/app/cache" 7 | "github.com/ispringtech/brewkit/internal/backend/infrastructure/docker" 8 | "github.com/ispringtech/brewkit/internal/frontend/app/service" 9 | ) 10 | 11 | func cache() *cli.Command { 12 | return &cli.Command{ 13 | Name: "cache", 14 | Usage: "Manipulate brewkit docker cache", 15 | Subcommands: []*cli.Command{ 16 | cacheClear(), 17 | }, 18 | } 19 | } 20 | 21 | func cacheClear() *cli.Command { 22 | return &cli.Command{ 23 | Name: "clear", 24 | Usage: "Clear docker builder cache", 25 | Flags: []cli.Flag{ 26 | &cli.BoolFlag{ 27 | Name: "all", 28 | Aliases: []string{"a"}, 29 | Usage: "Clear all cache, not just dangling ones", 30 | }, 31 | }, 32 | Action: func(ctx *cli.Context) error { 33 | var opts commonOpt 34 | opts.scan(ctx) 35 | clearAll := ctx.Bool("all") 36 | 37 | logger := makeLogger(opts.verbose) 38 | 39 | dockerClient, err := docker.NewClient(opts.dockerClientConfigPath, logger) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | cacheAPI := backendcache.NewCacheService(dockerClient) 45 | cacheService := service.NewCacheService(cacheAPI) 46 | 47 | return cacheService.ClearCache(ctx.Context, service.ClearCacheParam{ 48 | All: clearAll, 49 | }) 50 | }, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/brewkit/common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/urfave/cli/v2" 8 | 9 | "github.com/ispringtech/brewkit/internal/common/infrastructure/logger" 10 | "github.com/ispringtech/brewkit/internal/common/maybe" 11 | appconfig "github.com/ispringtech/brewkit/internal/frontend/app/config" 12 | infraconfig "github.com/ispringtech/brewkit/internal/frontend/infrastructure/config" 13 | ) 14 | 15 | type commonOpt struct { 16 | configPath string 17 | verbose bool 18 | dockerClientConfigPath maybe.Maybe[string] 19 | } 20 | 21 | func (o *commonOpt) scan(ctx *cli.Context) { 22 | o.configPath = ctx.String("config") 23 | o.verbose = ctx.Bool("verbose") 24 | dockerConfigPath := ctx.String("docker-config") 25 | if dockerConfigPath != "" { 26 | o.dockerClientConfigPath = maybe.NewJust(dockerConfigPath) 27 | } 28 | } 29 | 30 | func makeLogger(verbose bool) logger.Logger { 31 | return logger.NewLogger(os.Stdout, os.Stderr, verbose) 32 | } 33 | 34 | func parseConfig(configPath string, log logger.Logger) (appconfig.Config, error) { 35 | c, err := infraconfig.Parser{}.Config(configPath) 36 | if err != nil { 37 | if !errors.Is(err, appconfig.ErrConfigNotFound) { 38 | return appconfig.Config{}, err 39 | } 40 | log.Debugf("config not found in %s: default config will be used\n", configPath) 41 | 42 | return appconfig.DefaultConfig, nil 43 | } 44 | return c, err 45 | } 46 | -------------------------------------------------------------------------------- /internal/common/either/either.go: -------------------------------------------------------------------------------- 1 | package either 2 | 3 | type discriminator uint 4 | 5 | const ( 6 | left discriminator = iota 7 | right 8 | ) 9 | 10 | func NewLeft[L any, R any](l L) Either[L, R] { 11 | return Either[L, R]{ 12 | l: l, 13 | d: left, 14 | } 15 | } 16 | 17 | func NewEitherLeft[T Either[L, R], L any, R any](l L) T { 18 | return T(Either[L, R]{ 19 | l: l, 20 | d: left, 21 | }) 22 | } 23 | 24 | func NewRight[L any, R any](r R) Either[L, R] { 25 | return Either[L, R]{ 26 | r: r, 27 | d: right, 28 | } 29 | } 30 | 31 | func NewEitherRight[T Either[L, R], L any, R any](r R) T { 32 | return T(Either[L, R]{ 33 | r: r, 34 | d: right, 35 | }) 36 | } 37 | 38 | type Either[L any, R any] struct { 39 | l L 40 | r R 41 | d discriminator 42 | } 43 | 44 | func (e Either[L, R]) MapLeft(f func(l L)) Either[L, R] { 45 | if e.d == left { 46 | f(e.l) 47 | } 48 | return e 49 | } 50 | 51 | func (e Either[L, R]) MapRight(f func(r R)) Either[L, R] { 52 | if e.d == right { 53 | f(e.r) 54 | } 55 | return e 56 | } 57 | 58 | func (e Either[T, E]) IsLeft() bool { 59 | return e.d == left 60 | } 61 | 62 | func (e Either[T, E]) IsRight() bool { 63 | return e.d == right 64 | } 65 | 66 | func (e Either[T, E]) Left() T { 67 | if e.d != left { 68 | panic("violated usage of either") 69 | } 70 | return e.l 71 | } 72 | 73 | func (e Either[T, E]) Right() E { 74 | if e.d != right { 75 | panic("violated usage of either") 76 | } 77 | return e.r 78 | } 79 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/builddefinition/funcs.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | var funcs = []nativeFunc{ 4 | nativeFunc2[string, string]{ 5 | name: "cache", 6 | v1: argDesc{ 7 | name: "id", 8 | }, 9 | v2: argDesc{ 10 | name: "path", 11 | }, 12 | f: func(id string, path string) (interface{}, error) { 13 | return map[string]interface{}{ 14 | "id": id, 15 | "path": path, 16 | }, nil 17 | }, 18 | }, 19 | nativeFunc2[string, string]{ 20 | name: "copy", 21 | v1: argDesc{ 22 | name: "src", 23 | }, 24 | v2: argDesc{ 25 | name: "dst", 26 | }, 27 | f: func(src string, dst string) (interface{}, error) { 28 | return map[string]interface{}{ 29 | "src": src, 30 | "dst": dst, 31 | }, nil 32 | }, 33 | }, 34 | nativeFunc3[string, string, string]{ 35 | name: "copyFrom", 36 | v1: argDesc{ 37 | name: "from", 38 | }, 39 | v2: argDesc{ 40 | name: "src", 41 | }, 42 | v3: argDesc{ 43 | name: "dst", 44 | }, 45 | f: func(from string, src string, dst string) (interface{}, error) { 46 | return map[string]interface{}{ 47 | "from": from, 48 | "src": src, 49 | "dst": dst, 50 | }, nil 51 | }, 52 | }, 53 | nativeFunc2[string, string]{ 54 | name: "secret", 55 | v1: argDesc{ 56 | name: "id", 57 | }, 58 | v2: argDesc{ 59 | name: "path", 60 | }, 61 | f: func(id string, path string) (interface{}, error) { 62 | return map[string]interface{}{ 63 | "id": id, 64 | "path": path, 65 | }, nil 66 | }, 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /internal/common/maybe/maybe.go: -------------------------------------------------------------------------------- 1 | package maybe 2 | 3 | type Maybe[T any] struct { 4 | v T 5 | valid bool 6 | } 7 | 8 | func NewJust[T any](v T) Maybe[T] { 9 | return Maybe[T]{ 10 | v: v, 11 | valid: true, 12 | } 13 | } 14 | 15 | // NewNone used for explicit none value 16 | func NewNone[T any]() Maybe[T] { 17 | return Maybe[T]{} 18 | } 19 | 20 | func Valid[T any](maybe Maybe[T]) bool { 21 | return maybe.valid 22 | } 23 | 24 | func Just[T any](maybe Maybe[T]) T { 25 | if !Valid(maybe) { 26 | panic("violated usage of maybe: Just on non Valid Maybe") 27 | } 28 | return maybe.v 29 | } 30 | 31 | // MapNone returns underlying value on Valid Maybe or value from f 32 | func MapNone[T any](m Maybe[T], f func() T) T { 33 | if !Valid(m) { 34 | return f() 35 | } 36 | return Just(m) 37 | } 38 | 39 | func FromPtr[T any](t *T) Maybe[T] { 40 | if t == nil { 41 | return NewNone[T]() 42 | } 43 | return NewJust[T](*t) 44 | } 45 | 46 | func ToPtr[T any](m Maybe[T]) *T { 47 | if m.valid { 48 | return &m.v 49 | } 50 | return nil 51 | } 52 | 53 | func Map[T any, E any](m Maybe[T], f func(T) E) Maybe[E] { 54 | if !Valid(m) { 55 | return Maybe[E]{} 56 | } 57 | 58 | return Maybe[E]{ 59 | v: f(Just(m)), 60 | valid: true, 61 | } 62 | } 63 | 64 | func MapErr[T any, E any](m Maybe[T], f func(T) (E, error)) (Maybe[E], error) { 65 | if !Valid(m) { 66 | return Maybe[E]{}, nil 67 | } 68 | 69 | e, err := f(Just(m)) 70 | if err != nil { 71 | return Maybe[E]{}, err 72 | } 73 | 74 | return Maybe[E]{ 75 | v: e, 76 | valid: true, 77 | }, nil 78 | } 79 | -------------------------------------------------------------------------------- /docs/cli/overview.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | BrewKit has command-line-interface for running build, manipulate cache and config. 4 |
5 | You can learn more about each command by running command with `-h` flag 6 | 7 | ## build 8 | 9 | Manipulates builds 10 | 11 | | Command | Description | 12 | |------------------|---------------------------------------------------------------------------------------------| 13 | | | Runs specified target | 14 | | definition | Print full parsed and verified build-definition in JSON to stdout | 15 | | definition-debug | Print compiled build definition in raw JSON, useful for debugging complex build definitions | 16 | 17 | Examples: 18 | 19 | Build concrete targets 20 | ```shell 21 | brewkit build generate compile 22 | ``` 23 | 24 | ## config 25 | 26 | Manipulate host config 27 | 28 | | Command | Description | 29 | |---------|-------------------------------| 30 | | init | Create default brewkit config | 31 | 32 | ## version 33 | 34 | Print BrewKit version 35 | 36 | ```shell 37 | brewkit version 38 | ``` 39 | 40 | ## cache 41 | 42 | Manipulate buildkit cache 43 | 44 | | Command | Description | 45 | |----------|-----------------------------------------| 46 | | clear | Clear docker builder cache | 47 | | clear -a | Clear all cache, not just dangling ones | 48 | 49 | ## fmt 50 | 51 | Pretty format jsonnet files with jsonnetfmt 52 | 53 | ```shell 54 | brewkit fmt brewkit.jsonnet 55 | ``` -------------------------------------------------------------------------------- /cmd/brewkit/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "path" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/urfave/cli/v2" 11 | 12 | appconfig "github.com/ispringtech/brewkit/internal/frontend/app/config" 13 | infraconfig "github.com/ispringtech/brewkit/internal/frontend/infrastructure/config" 14 | ) 15 | 16 | func config() *cli.Command { 17 | return &cli.Command{ 18 | Name: "config", 19 | Usage: "Manipulate brewkit config", 20 | Subcommands: []*cli.Command{ 21 | configInit(), 22 | }, 23 | } 24 | } 25 | 26 | func configInit() *cli.Command { 27 | return &cli.Command{ 28 | Name: "init", 29 | Usage: "Create default brewkit config", 30 | Action: func(ctx *cli.Context) error { 31 | var opts commonOpt 32 | opts.scan(ctx) 33 | 34 | logger := makeLogger(opts.verbose) 35 | 36 | defaultConfig, err := infraconfig.Parser{}.Dump(appconfig.DefaultConfig) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | defaultConfigBuffer := &bytes.Buffer{} 42 | err = json.Indent(defaultConfigBuffer, defaultConfig, "", " ") 43 | if err != nil { 44 | return err 45 | } 46 | 47 | configPath := opts.configPath 48 | configDir := path.Dir(configPath) 49 | 50 | err = os.MkdirAll(configDir, 0o755) 51 | if err != nil { 52 | return errors.Wrapf(err, "failed to create folder for config %s", configDir) 53 | } 54 | 55 | err = os.WriteFile(configPath, defaultConfigBuffer.Bytes(), 0o600) 56 | if err != nil { 57 | return errors.Wrapf(err, "failed to write file for config %s", configDir) 58 | } 59 | 60 | logger.Outputf("Default config created in %s\n", configPath) 61 | 62 | return nil 63 | }, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/config/parser.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | 8 | "github.com/google/go-jsonnet" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ispringtech/brewkit/internal/common/slices" 12 | "github.com/ispringtech/brewkit/internal/frontend/app/config" 13 | ) 14 | 15 | type Parser struct{} 16 | 17 | func (p Parser) Config(configPath string) (config.Config, error) { 18 | fileBytes, err := os.ReadFile(configPath) 19 | if err != nil { 20 | if errors.Is(err, os.ErrNotExist) { 21 | return config.Config{}, errors.WithStack(config.ErrConfigNotFound) 22 | } 23 | 24 | return config.Config{}, errors.Wrap(err, "failed to read config file") 25 | } 26 | 27 | vm := jsonnet.MakeVM() 28 | 29 | jsonnet.Version() 30 | 31 | data, err := vm.EvaluateAnonymousSnippet(path.Base(configPath), string(fileBytes)) 32 | if err != nil { 33 | return config.Config{}, errors.Wrap(err, "failed to compile jsonnet for config") 34 | } 35 | 36 | var c Config 37 | 38 | err = json.Unmarshal([]byte(data), &c) 39 | if err != nil { 40 | return config.Config{}, errors.Wrap(err, "failed to parse json config") 41 | } 42 | 43 | return config.Config{ 44 | Secrets: slices.Map(c.Secrets, func(s Secret) config.Secret { 45 | return config.Secret{ 46 | ID: s.ID, 47 | Path: os.ExpandEnv(s.Path), 48 | } 49 | }), 50 | }, nil 51 | } 52 | 53 | func (p Parser) Dump(srcConfig config.Config) ([]byte, error) { 54 | c := Config{ 55 | Secrets: slices.Map(srcConfig.Secrets, func(s config.Secret) Secret { 56 | return Secret{ 57 | ID: s.ID, 58 | Path: s.Path, 59 | } 60 | }), 61 | } 62 | 63 | data, err := json.Marshal(c) 64 | return data, errors.Wrap(err, "failed to marshal config to json") 65 | } 66 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 3 | github.com/google/go-jsonnet v0.20.0 h1:WG4TTSARuV7bSm4PMB4ohjxe33IHT5WVTrJSU33uT4g= 4 | github.com/google/go-jsonnet v0.20.0/go.mod h1:VbgWF9JX7ztlv770x/TolZNGGFfiHEVx9G6ca2eUmeA= 5 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 6 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 7 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 8 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 9 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 10 | github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw= 11 | github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= 12 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 13 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 14 | golang.org/x/exp v0.0.0-20230420155640-133eef4313cb h1:rhjz/8Mbfa8xROFiH+MQphmAmgqRM0bOMnytznhWEXk= 15 | golang.org/x/exp v0.0.0-20230420155640-133eef4313cb/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 18 | gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= 19 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 20 | sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= 21 | sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= 22 | -------------------------------------------------------------------------------- /docs/build-definition/jsonnet-extensions.md: -------------------------------------------------------------------------------- 1 | # Jsonnet extensions 2 | 3 | ## cache 4 | 5 | Allows to write cache definition as one-liner 6 | 7 | See [build-definition reference](reference.md#cache) 8 | 9 | ```jsonnet 10 | local cache = std.native('cache'); 11 | // 12 | targets: { 13 | gobuild: { 14 | // ... 15 | cache: cache("go-build", "/app/cache"), 16 | // ... 17 | } 18 | } 19 | // 20 | ``` 21 | 22 | ## copy 23 | 24 | Allows to write copy definition as one-liner 25 | 26 | See [build-definition reference](reference.md#copy) 27 | 28 | ```jsonnet 29 | local copy = std.native('copy'); 30 | // 31 | targets: { 32 | gobuild: { 33 | // ... 34 | copy: [ 35 | copy('cmd', 'cmd'), 36 | copy('pkg', 'pkg'), 37 | ], 38 | // ... 39 | } 40 | } 41 | // 42 | ``` 43 | 44 | ## copyFrom 45 | 46 | Allows to write copyFrom definition as one-liner 47 | 48 | See [build-definition reference](reference.md#copy) 49 | 50 | ```jsonnet 51 | local copyFrom = std.native('copyFrom'); 52 | // 53 | targets: { 54 | gobuild: { 55 | // ... 56 | copy: [ 57 | copyFrom('prebuild', 'artifacts', 'artifacts'), 58 | copy('cmd', 'cmd'), 59 | copy('pkg', 'pkg'), 60 | ], 61 | // ... 62 | } 63 | } 64 | // 65 | ``` 66 | 67 | ## secret 68 | 69 | Allows to write secret definition as one-liner 70 | 71 | See [build-definition reference](reference.md#secrets) 72 | 73 | ```jsonnet 74 | local secret = std.native('secret'); 75 | // 76 | targets: { 77 | gobuild: { 78 | // ... 79 | secret: secret("aws", '/root/.aws/credentials'), 80 | // ... 81 | } 82 | } 83 | // 84 | ``` -------------------------------------------------------------------------------- /internal/backend/api/model.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/ispringtech/brewkit/internal/common/either" 5 | "github.com/ispringtech/brewkit/internal/common/maybe" 6 | ) 7 | 8 | type Vertex struct { 9 | Name string // Unique Vertex name 10 | 11 | Stage maybe.Maybe[Stage] 12 | 13 | From maybe.Maybe[*Vertex] // Parent vertex 14 | DependsOn []Vertex 15 | } 16 | 17 | type Stage struct { 18 | From string // From for stage 19 | Platform maybe.Maybe[string] // Platform for image 20 | WorkDir string // Working directory for stage 21 | Env map[string]string // Stage env 22 | Cache []Cache // Pluggable cache for build systems 23 | Copy []Copy // Copy local or build stages artifacts 24 | Network maybe.Maybe[Network] // Network options 25 | SSH maybe.Maybe[SSH] // SSH access options 26 | Secrets []Secret 27 | Command maybe.Maybe[string] // Command for stage 28 | Output maybe.Maybe[Output] // Output artifacts from builder 29 | } 30 | 31 | type Image struct { 32 | From string 33 | Dockerfile string 34 | } 35 | 36 | type Var struct { 37 | Name string // Unique Var name 38 | From string 39 | Platform maybe.Maybe[string] 40 | WorkDir string 41 | Env map[string]string 42 | Cache []Cache 43 | Copy []CopyVar 44 | Secrets []Secret 45 | Network maybe.Maybe[Network] 46 | SSH maybe.Maybe[SSH] 47 | Command string 48 | } 49 | 50 | type Copy struct { 51 | From maybe.Maybe[either.Either[*Vertex, string]] 52 | Src string 53 | Dst string 54 | } 55 | 56 | // CopyVar is Copy instruction for var 57 | type CopyVar struct { 58 | From maybe.Maybe[string] 59 | Src string 60 | Dst string 61 | } 62 | 63 | type Cache struct { 64 | ID string 65 | Path string 66 | } 67 | 68 | type Secret struct { 69 | ID string 70 | MountPath string 71 | } 72 | 73 | type SecretSrc struct { 74 | ID string 75 | SourcePath string 76 | } 77 | 78 | type SSH struct{} 79 | 80 | type Network struct { 81 | Network string // It may be Host and other docker networks 82 | } 83 | 84 | type Output struct { 85 | Artifact string 86 | Local string 87 | } 88 | -------------------------------------------------------------------------------- /internal/frontend/app/builddefinition/builder.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | 6 | "github.com/ispringtech/brewkit/internal/backend/api" 7 | "github.com/ispringtech/brewkit/internal/common/maybe" 8 | "github.com/ispringtech/brewkit/internal/common/slices" 9 | "github.com/ispringtech/brewkit/internal/frontend/app/buildconfig" 10 | "github.com/ispringtech/brewkit/internal/frontend/app/config" 11 | "github.com/ispringtech/brewkit/internal/frontend/app/version" 12 | ) 13 | 14 | type Builder interface { 15 | Build(config buildconfig.Config, secrets []config.Secret) (Definition, error) 16 | } 17 | 18 | func NewBuilder() Builder { 19 | return &builder{} 20 | } 21 | 22 | type builder struct{} 23 | 24 | func (builder builder) Build(c buildconfig.Config, secrets []config.Secret) (Definition, error) { 25 | if c.APIVersion != version.APIVersionV1 { 26 | return Definition{}, errors.Wrapf(ErrUnsupportedAPIVersion, "version: %s", c.APIVersion) 27 | } 28 | 29 | vertexes, err := newVertexGraphBuilder(secrets, c.Targets).graphVertexes() 30 | if err != nil { 31 | return Definition{}, err 32 | } 33 | 34 | vars, err := builder.variables(c.Vars, secrets) 35 | if err != nil { 36 | return Definition{}, err 37 | } 38 | 39 | return Definition{ 40 | Vertexes: vertexes, 41 | Vars: vars, 42 | }, err 43 | } 44 | 45 | func (builder builder) variables(vars []buildconfig.VarData, secrets []config.Secret) ([]api.Var, error) { 46 | return slices.MapErr(vars, func(v buildconfig.VarData) (api.Var, error) { 47 | mappedSecrets, err := mapSecrets(v.Secrets, secrets) 48 | if err != nil { 49 | return api.Var{}, errors.Wrapf(err, "failed to map secrets in %s variable", v.Name) 50 | } 51 | 52 | return api.Var{ 53 | Name: v.Name, 54 | From: v.From, 55 | Platform: maybe.Map(v.Platform, func(p string) string { 56 | return p 57 | }), 58 | WorkDir: v.WorkDir, 59 | Env: v.Env, 60 | Cache: slices.Map(v.Cache, mapCache), 61 | Copy: slices.Map(v.Copy, mapCopy), 62 | Network: maybe.Map(v.Network, func(n string) api.Network { 63 | return api.Network{ 64 | Network: n, 65 | } 66 | }), 67 | SSH: maybe.Map(v.SSH, func(s buildconfig.SSH) api.SSH { 68 | return api.SSH{} 69 | }), 70 | Secrets: mappedSecrets, 71 | Command: v.Command, 72 | }, nil 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /internal/common/slices/map.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | import ( 4 | "github.com/ispringtech/brewkit/internal/common/maybe" 5 | ) 6 | 7 | // Map iterates through slice and maps values 8 | func Map[T, TResult any](s []T, f func(T) TResult) []TResult { 9 | result := make([]TResult, 0, len(s)) 10 | for _, t := range s { 11 | result = append(result, f(t)) 12 | } 13 | return result 14 | } 15 | 16 | // MapErr iterates through slice and maps values and stops on any error 17 | func MapErr[T, TResult any](s []T, f func(T) (TResult, error)) ([]TResult, error) { 18 | result := make([]TResult, 0, len(s)) 19 | for _, t := range s { 20 | e, err := f(t) 21 | if err != nil { 22 | return nil, err 23 | } 24 | result = append(result, e) 25 | } 26 | return result, nil 27 | } 28 | 29 | // Filter iterates and adds to result slice elements that satisfied predicate 30 | func Filter[T any](s []T, f func(T) bool) []T { 31 | var result []T 32 | for _, t := range s { 33 | if f(t) { 34 | result = append(result, t) 35 | } 36 | } 37 | return result 38 | } 39 | 40 | // FilterErr iterates and adds to result slice elements that satisfied predicate and stop on any error 41 | func FilterErr[T any](s []T, f func(T) (bool, error)) ([]T, error) { 42 | var result []T 43 | for _, t := range s { 44 | accepted, err := f(t) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if accepted { 50 | result = append(result, t) 51 | } 52 | } 53 | 54 | return result, nil 55 | } 56 | 57 | func MapMaybe[T, TResult any](s []T, f func(T) maybe.Maybe[TResult]) (res []TResult) { 58 | for _, t := range s { 59 | m := f(t) 60 | if maybe.Valid(m) { 61 | res = append(res, maybe.Just(m)) 62 | } 63 | } 64 | return res 65 | } 66 | 67 | func MapMaybeErr[T, TResult any](s []T, f func(T) (maybe.Maybe[TResult], error)) (res []TResult, err error) { 68 | for _, t := range s { 69 | var m maybe.Maybe[TResult] 70 | m, err = f(t) 71 | if err != nil { 72 | return nil, err 73 | } 74 | if maybe.Valid(m) { 75 | res = append(res, maybe.Just(m)) 76 | } 77 | } 78 | return res, nil 79 | } 80 | 81 | // Merge slices into one 82 | func Merge[T any](slices ...[]T) []T { 83 | if len(slices) == 0 { 84 | return nil 85 | } 86 | 87 | result := slices[0] 88 | for i := 1; i < len(slices); i++ { 89 | s := slices[i] 90 | 91 | result = append(result, s...) 92 | } 93 | 94 | return result 95 | } 96 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/builddefinition/model.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "github.com/ispringtech/brewkit/internal/common/either" 5 | "github.com/ispringtech/brewkit/internal/common/maybe" 6 | ) 7 | 8 | type Config struct { 9 | APIVersion string `json:"apiVersion"` 10 | Targets map[string]either.Either[[]string, Target] `json:"targets"` 11 | Vars map[string]Var `json:"vars"` 12 | } 13 | 14 | type Target struct { 15 | DependsOn []string `json:"dependsOn"` 16 | *Stage `json:",inline"` 17 | } 18 | 19 | type Stage struct { 20 | From string `json:"from"` 21 | Env map[string]string `json:"env"` 22 | SSH maybe.Maybe[SSH] `json:"ssh"` 23 | Cache []Cache `json:"cache"` 24 | Copy either.Either[[]Copy, Copy] `json:"copy"` 25 | Secrets either.Either[[]Secret, Secret] `json:"secret"` 26 | Platform maybe.Maybe[string] `json:"platform"` 27 | WorkDir string `json:"workdir"` 28 | Network maybe.Maybe[string] `json:"network"` 29 | Command maybe.Maybe[string] `json:"command"` 30 | Output maybe.Maybe[Output] `json:"output"` 31 | } 32 | 33 | type Var struct { 34 | From string `json:"from"` 35 | Platform maybe.Maybe[string] `json:"platform"` 36 | WorkDir string `json:"workdir"` 37 | Env map[string]string `json:"env"` 38 | Cache []Cache `json:"cache"` 39 | Copy either.Either[[]Copy, Copy] `json:"copy"` 40 | Secrets either.Either[[]Secret, Secret] `json:"secrets"` 41 | Network maybe.Maybe[string] `json:"network"` 42 | SSH maybe.Maybe[SSH] `json:"ssh"` 43 | Command string `json:"command"` 44 | } 45 | 46 | type Cache struct { 47 | ID string `yaml:"id"` 48 | Path string `yaml:"path"` 49 | } 50 | 51 | type Copy struct { 52 | From maybe.Maybe[string] `json:"from"` 53 | Src string `json:"src"` 54 | Dst string `json:"dst"` 55 | } 56 | 57 | type SSH struct{} 58 | 59 | type Secret struct { 60 | ID string `yaml:"id"` 61 | Path string `yaml:"path"` 62 | } 63 | 64 | type Output struct { 65 | Artifact string `json:"artifact"` 66 | Local string `json:"local"` 67 | } 68 | -------------------------------------------------------------------------------- /docs/diagrams/architecture.drawio.xml: -------------------------------------------------------------------------------- 1 | 2 | 5VjbUtswEP0az9AHGMcmIXnMBdpOactMphceFXuxVWQrI69Jwtd3FcnxLUAayKSdvoB0vFrLZ8/uSnH8cbJ8r9g8/ixDEI7nhkvHnzie1/Vd+quBlQG8wYUBIsVDA3VKYMofwYJ2XZTzELKaIUopkM/rYCDTFAKsYUwpuaib3UlRf+ucRdACpgETbfQHDzE2aL/rlvgH4FFcvLnj2icJK4wtkMUslIsK5F86/lhJiWaULMcgNHcFL2bd1RNPNxtTkOIuCwb5l1O5HEdfe/zjENj0Pv3+cGq9PDCR2w+eyOAelOP1BHkdzfQo0qOTUc5F+InjO/s5uCo4ijERNOqQ1SLmCNM5C/SjBSmCMPsKUAjLJ/fe2TBCSgKZAKoVmdgF531LolVRpyB1UcakU0gmrsSjiBOzMog2rkumaGDJ+hPi3BYNEJJy7FQqjGUkUyYuS3SkZJ6GoN26NCttrqWcWwJ/AeLKpgHLURJUode8U7/oeSJpXzJXATzzAZ5NJqYiwJcU0g6MAsGQP9T38eYsey15jhQs7jkeQYObkvTXaLB/DAkSW2r1U68/6xbT2+qzydI6N7OVnb2hdP0dpeu9UrrrpUOl2KpiMJc8xazi+UYDFZn0ujWZeOduI9DGYxn2zdb2V4LfypNvmS7iDXlQC5rrYZ6IYYBSUVh0SnBqd9dsBuJGZhy5TMlkJhFlUjEYCh7pB6h10q5Jr0otv1HeN6lWSS1/S2b1DpVZg3bd0d2vRSh9MtbZYJamgD4d1Bb+Eh6GJhMh449stnalE8QKi/x2R053on1R8mUmD7XrDJW8h7EUOnCTVKbayx0XogG9Ra0bvByQ7paA+Adrt+2DyjoiJ46uws2zCv1niZZpOstM9XebkDnmEHvgeOO9PNCJcx3+PZfP9PZPqYTlCawLStNH+5z1tNoE3OG/qrVm8ve2tFVvi9YGB9Oad5S+uuRo2qrXtdPbopHSuOyqelI01Uozvqh147L/Hrwb73qQPE439vs1cfUbd6Sm+cB/jXlx/Dtor++0m/1UCiLQc2emSdFVmq7i7YpyBRjEdJmmoEKgQPO2pXbBGZ4F/0+ncxvHtR2rzx5HD5qWt32jhvInE//yNw==7Vffb5swEP5rkLaHSQRClD6GtF26tdKkatqPNwdfwIvhmDEh6V+/M5gQQhs1Vaf1YS8EPt+dfd99PjuOP0+3HxXLkzvkIB3P5VvHv3Q8LxhN6GmAXQN4wbQBYiV4A4064F48gAVdi5aCQ9Ez1IhSi7wPRphlEOkexpTCqm+2QtmfNWcxDID7iMkh+k1wnTToNHA7fAEiTtqZR64dSVlrbIEiYRyrA8i/cvy5QtTNW7qdgzTctbw0ftdPjO4XpiDTz3G4u11fBJPNzzhZ5DBlmsc3tx98uza9axMGTvnbT1Q6wRgzJq86NFRYZhxMVJe+OptbxJzAEYG/QOudLSYrNRKU6FTa0WZOM9GTqViowFJFcGL9rSSYikGfsPP2hJNQAVPQakd+CiTTYtNfB7OSifd2Hav0Yok9g2Qbd8NkaWe6VphpyPiA/QOWqkRouM9ZnX9Fm2vP3AaUhu1p7oa5WgdvbPXYbshWsFUn71G7+5IDabeSf3V6vAE9IYvW/4ad8fStsTMesON4E0mzhiX5TmJdJ94gUrTI1wKUiUwqUyzSArPWhhbRmQ0dQwXVZ6FNdljoprGuRFwqdkaQOZOyIN9lW0d39uXmpC+Bh+kc1Z3qqfsthEkRZ/QuYWVGTM0FNe2ZhbXpRJRKIR7Yso5iWlWOxEddnyB0gksThnpT0bQpE7XQCtcwR4mKkAwz0+5WQsoj6DW2oXcktGAotL34DoU2/VtCC14kNPr9XWJddFRA/riiR1gKacpe7AoNaWfzTP1cIulGvVuaKGuh379IyQtWtMJzOaxEBry+AJh9sTxe339ldi1w8gxljv+SMnFxMU+z3Y/QjT+F32cpTzbVI+en6VLrukvVuqDLR10WVUa6rFXIxUbwuuBkgOaGUGHdzNKciKq5np1RzAjMNI+UMxWcN5eiN1zR4yN/5A8r6j92qPnnV9Qxd5v2SluPHfwv8K/+AA== -------------------------------------------------------------------------------- /cmd/brewkit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | stdlog "log" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/urfave/cli/v2" 11 | 12 | "github.com/ispringtech/brewkit/internal/dockerfile" 13 | appconfig "github.com/ispringtech/brewkit/internal/frontend/app/config" 14 | ) 15 | 16 | const ( 17 | appID = "brewkit" 18 | ) 19 | 20 | // These variables come from -ldflags settings 21 | // Here also setup their fallback values 22 | var ( 23 | Commit = "UNKNOWN" 24 | DockerfileImage = string(dockerfile.Dockerfile14) // default image for dockerfile 25 | ) 26 | 27 | func main() { 28 | ctx := context.Background() 29 | 30 | ctx = subscribeForKillSignals(ctx) 31 | 32 | err := runApp(ctx, os.Args) 33 | if err != nil { 34 | stdlog.Fatal(err) 35 | } 36 | } 37 | 38 | func runApp(ctx context.Context, args []string) error { 39 | ctx, cancel := context.WithCancel(ctx) 40 | defer cancel() 41 | 42 | configPath, err := appconfig.DefaultConfigPath() 43 | if err != nil { 44 | configPath = "" // Ignore err if default path unavailable 45 | } 46 | 47 | workdir, err := os.Getwd() 48 | if err != nil { 49 | return err 50 | } 51 | 52 | app := &cli.App{ 53 | Name: appID, 54 | Usage: "Container-native build system", 55 | Commands: []*cli.Command{ 56 | build(workdir), 57 | config(), 58 | version(), 59 | cache(), 60 | fmtCommand(), 61 | }, 62 | Flags: []cli.Flag{ 63 | &cli.StringFlag{ 64 | Name: "config", 65 | Usage: "brewkit config", 66 | Aliases: []string{"c"}, 67 | EnvVars: []string{"BREWKIT_CONFIG"}, 68 | Value: configPath, 69 | }, 70 | &cli.BoolFlag{ 71 | Name: "verbose", 72 | Usage: "Verbose output to stderr", 73 | Aliases: []string{"v"}, 74 | }, 75 | &cli.StringFlag{ 76 | Name: "docker-config", 77 | Usage: "Path to docker client config", 78 | Aliases: []string{"dc"}, 79 | EnvVars: []string{"BREWKIT_DOCKER_CONFIG"}, 80 | }, 81 | }, 82 | } 83 | 84 | return app.RunContext(ctx, args) 85 | } 86 | 87 | func subscribeForKillSignals(ctx context.Context) context.Context { 88 | ctx, cancel := context.WithCancel(ctx) 89 | 90 | ch := make(chan os.Signal, 1) 91 | signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) 92 | 93 | go func() { 94 | defer cancel() 95 | select { 96 | case <-ctx.Done(): 97 | signal.Stop(ch) 98 | case <-ch: 99 | } 100 | }() 101 | 102 | return ctx 103 | } 104 | -------------------------------------------------------------------------------- /docs/adr/001-buildkit.md: -------------------------------------------------------------------------------- 1 | # Using BuildKit as a build backend 2 | 3 | # Context and Problem Statement 4 | 5 | Using a direct container run(via `docker run...` or running docker containers) make brewkit highly dependent on docker daemon. 6 | We want to avoid this dependency, as BrewKit does not need to run containers 7 | It can only be used where there is a docker daemon. Although containers themselves as processes (as for kubernetes, for example) are not needed for Brewkit. 8 | 9 | # Decision Drivers 10 | 11 | - BrewKit should not depend on the docker daemon 12 | - When using BuildKit, you can get fancy features (caching, rootless artifacts, access to ssh-agent inside the builder) out of the box. 13 | 14 | # Considered Options 15 | 16 | - Using `docker run ...` 17 | - Using BuildKit 18 | 19 | # Decision Outcome 20 | 21 | The chosen variant is "Use BuildKit." Since it is a more modern way to build projects inside the container. 22 | 23 | # Pros and Cons of the Options 24 | 25 | ## Using `docker run ...`. 26 | 27 | All project build commands will run inside a container that is started via a direct request to the docker daemon. The container is started and a command is run inside it. 28 | 29 | * Good, simple solution 30 | * Bad, dependency on the Docker daemon 31 | * Bad, no caching depending on code changes in the project 32 | 33 | ## Using BuildKit 34 | 35 | Send build request to buildkit to build an image with specific instructions 36 | 37 | * Good, no root privileges needed 38 | * Good, already integrated in new versions of Docker to build images. 39 | * Bad, BuildKit not in final version yet (as of 05.2023) 40 | * Bad, BuildKit can't export build cache to another folder (relevant for Golang where package cache is not in project directory) 41 | 42 | ## More Information 43 | 44 | ## More information about BuildKit 45 | 46 | - [BuildKit github](https://github.com/moby/buildkit) 47 | - [Docker BuildKit documentation](https://docs.docker.com/build/buildkit/) 48 | 49 | ## Independence from the docker daemon 50 | 51 | `BuildKit` is a separate part of docker project used to build images 52 | `Buildkit` itself **does not require root privileges**, and can be shipped completely independent of `Docker`. 53 | 54 | Independence from `Docker` and root privileges provides the following benefits: 55 | - Easier and safer _Docker-in-Docker_: you don't need to pass a socket your docker into a container to build images inside the container, you can use a BuildKit image and build images through it 56 | - Running a container with BuildKit does not require `priveledged``: more secure image building in containers -------------------------------------------------------------------------------- /docs/adr/002-jsonnet.md: -------------------------------------------------------------------------------- 1 | # Jsonnet - project build description language for BrewKit 2 | 3 | # Context and Problem Statement 4 | 5 | The project build description language for BrewKit should support a powerful templating mechanism, imports (for splitting into files) and have internal validation. 6 | 7 | # Decision Drivers 8 | 9 | - The configuration language must have a powerful templating mechanism 10 | - The language should either be easy to learn or be a mockup of one of the popular languages (YAML. JSON). 11 | 12 | # Considered Options 13 | 14 | - YAML 15 | - JSON 16 | - HCL 17 | - JSONNET 18 | 19 | # Decision Outcome 20 | 21 | Decision selected: "Use Jsonnet". Jsonnet offers great flexibility in templating Json documents and has a Go version that can be used as a library. 22 | 23 | # Pros and Cons of the Options 24 | 25 | ## YAML 26 | 27 | - Good, popular description format 28 | - Good, considered more human-friendly than JSON 29 | - Good, supports templating via anchors 30 | - Bad, has many problems related to its design (typing, nesting) 31 | - Bad, not enough templating tools to describe the build of a complex project 32 | 33 | ## JSON 34 | 35 | - Good, standard in describing schemas, configs, etc. 36 | - Bad, no templating => not suitable for describing a complex project assembly 37 | 38 | ## HCL - [github](https://github.com/hashicorp/hcl) 39 | 40 | A configuration description language from HashiCorp. Mostly used for [Terraform](https://www.terraform.io/) configuration. 41 | 42 | - Good, has built-in validation (you can set field optionality, etc.). 43 | - Good, is a new language for declarative style description 44 | - Bad, not enough templating tools to describe the build of a complex project (lack of variables, etc.). 45 | 46 | ## Jsonnet - [Jsonnet](https://jsonnet.org/) 47 | 48 | A superset of JSON augmented with functions, variables and object-oriented approach. Implemented by Google. 49 | Used for description in AML projects, actively used by [Grafana Labs](https://grafana.github.io/grafonnet-lib/). 50 | 51 | - Good, full-featured language with support for f-i, variables, etc. 52 | - Good, output is simple JSON that can be viewed and simply parsed. 53 | - Good, strict type system (as in JSON) 54 | - Good, allows you to build a custom project build process 55 | - Good, powerful templateizer, file import - you can build a project of great complexity. 56 | - Good, has a full-featured Go implementation that can be used as a library 57 | - Bad, little known and few examples on the Internet (ChatGPT knows the language very well:) ) 58 | - Bad, when describing a large project - you can get a bulky description file. -------------------------------------------------------------------------------- /internal/common/infrastructure/executor/executor.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/pkg/errors" 11 | 12 | "github.com/ispringtech/brewkit/internal/common/maybe" 13 | ) 14 | 15 | type RunParams struct { 16 | Stdin maybe.Maybe[io.Reader] 17 | Stdout maybe.Maybe[io.Writer] 18 | Stderr maybe.Maybe[io.Writer] 19 | } 20 | 21 | type Executor interface { 22 | Run(ctx context.Context, args Args, params RunParams) error 23 | } 24 | 25 | func New(executable string, opts ...Opt) (Executor, error) { 26 | _, err := exec.LookPath(executable) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | o := options{} 32 | for _, opt := range opts { 33 | opt.apply(&o) 34 | } 35 | 36 | return &executor{ 37 | executable: executable, 38 | options: o, 39 | }, nil 40 | } 41 | 42 | type executor struct { 43 | executable string 44 | options options 45 | } 46 | 47 | func (e *executor) Run(ctx context.Context, args Args, params RunParams) (err error) { 48 | cmd := exec.Command(e.executable, args...) // #nosec G204 49 | 50 | cmd.Stdin = maybe.MapNone(params.Stdin, func() io.Reader { 51 | return os.Stdin 52 | }) 53 | cmd.Stdout = maybe.MapNone(params.Stdout, func() io.Writer { 54 | return os.Stdout 55 | }) 56 | cmd.Stderr = maybe.MapNone(params.Stderr, func() io.Writer { 57 | return os.Stderr 58 | }) 59 | 60 | cmd.Env = e.options.env 61 | 62 | err = e.logArgs(args) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | err = cmd.Start() 68 | if err != nil { 69 | return errors.Wrapf(err, "failed to run cmd %s", cmd.String()) 70 | } 71 | 72 | commandCtx, cancelFunc := context.WithCancel(context.Background()) 73 | defer cancelFunc() 74 | 75 | var runErr error 76 | go func() { 77 | runErr = cmd.Wait() 78 | cancelFunc() 79 | }() 80 | 81 | select { 82 | case <-ctx.Done(): 83 | if e.options.logger != nil { 84 | e.options.logger.Info("Stopping process: docker\n") 85 | } 86 | 87 | killErr := cmd.Process.Signal(os.Interrupt) 88 | err = errors.WithStack(ctx.Err()) 89 | if killErr != nil { 90 | err = errors.Wrapf(err, killErr.Error()) 91 | } 92 | return err 93 | case <-commandCtx.Done(): 94 | err = runErr 95 | return err 96 | } 97 | } 98 | 99 | func (e *executor) logArgs(args []string) error { 100 | if e.options.logger == nil { 101 | return nil 102 | } 103 | 104 | bytes, err := json.Marshal(args) 105 | if err != nil { 106 | return errors.Wrap(err, "failed to marshal command args") 107 | } 108 | 109 | e.options.logger.Debug(string(bytes) + "\n") 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Features overview 2 | 3 | ## Aggressive-caching 4 | 5 | Aggressive caching lets brewkit achieve faster repeated builds. 6 | When running target BrewKit (or BuildKit under the hood) scans file changes from `copy` and changes 7 | from previous targets to decide if target needs to be executed. 8 | 9 | So if there is no dependency changed and command is the same - there is no need to execute target. 10 | 11 | Exmaple: 12 | 13 | Build-definition for simple go service 14 | ```jsonnet 15 | local app = "service"; 16 | 17 | local copy = std.native('copy'); 18 | 19 | { 20 | apiVersion: "brewkit/v1", 21 | targets: { 22 | all: ['gobuild'], 23 | 24 | gobuild: { 25 | from: "golang:1.20", 26 | workdir: "/app", 27 | copy: [ 28 | copy('cmd', 'cmd'), 29 | copy('pkg', 'pkg'), 30 | ], 31 | command: std.format("go build -o ./bin/%s ./cmd/%s", [app]) 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | First launch will pull all images, copy artifacts and runs commands 38 | 39 | ```shell 40 | brewkit build 41 | ``` 42 | 43 | Output: 44 | ```shell 45 | => [gobuild 4/6] COPY cmd cmd 46 | => [gobuild 5/6] COPY internal internal 47 | => [gobuild 6/6] RUN < CACHED [gobuild 4/6] COPY cmd cmd 54 | => CACHED [gobuild 5/6] COPY internal internal 55 | => CACHED [gobuild 6/6] RUN < 27 | `brewkit` - go-style 28 | 29 | ## Start with BrewKit 30 | 31 | Install BrewKit via go >= 1.20 32 | 33 | ```shell 34 | go install github.com/ispringtech/brewkit/cmd/brewkit@latest 35 | ``` 36 | 37 | Create `brewkit.jsonnet` 38 | ```shell 39 | touch brewkit.jsonnet 40 | ``` 41 | 42 | Describe simple target 43 | ```jsonnet 44 | local app = "service"; 45 | 46 | local copy = std.native('copy'); 47 | 48 | { 49 | apiVersion: "brewkit/v1", 50 | targets: { 51 | all: ['gobuild'], 52 | 53 | gobuild: { 54 | from: "golang:1.20", 55 | workdir: "/app", 56 | copy: [ 57 | copy('cmd', 'cmd'), 58 | copy('pkg', 'pkg'), 59 | ], 60 | command: std.format("go build -o ./bin/%s ./cmd/%s", [app]) 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | Run build 67 | ```shell 68 | brewkit build 69 | 70 | => [internal] load build definition from Dockerfile 0.1s 71 | => => transferring dockerfile: 3.45kB 0.0s 72 | => [internal] load .dockerignore 0.1s 73 | => => transferring context: 2B 74 | # ... 75 | ``` 76 | 77 | ## Build BrewKit 78 | 79 | When brewkit installed locally 80 | ```shell 81 | brewkit build 82 | ``` 83 | 84 | Build from source: 85 | ```shell 86 | go build -o ./bin/brewkit ./cmd/brewkit 87 | ``` 88 | 89 | ## Documentation 90 | 91 | * [Documentaion entrypoint](docs/readme.md) 92 | -------------------------------------------------------------------------------- /internal/backend/infrastructure/docker/outputparser.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | const ( 14 | doneSymbol = "DONE" 15 | ) 16 | 17 | var ( 18 | outputHeader = regexp.MustCompile(`#\d+ \[.*(?P\d/\d)] (?P[A-Z]+) .*`) 19 | outputLine = regexp.MustCompile(`#\d+ (?P\S+) (?P.*)$`) 20 | ) 21 | 22 | type outputParser struct{} 23 | 24 | func (p outputParser) parseBuildOutputForRunTarget(output io.Reader) ([]byte, error) { 25 | scanner := bufio.NewScanner(output) 26 | for scanner.Scan() { 27 | submatch := outputHeader.FindStringSubmatch(scanner.Text()) 28 | if len(submatch) != len(outputHeader.SubexpNames()) { 29 | // It is not a header since fully do not match regexp 30 | continue 31 | } 32 | 33 | instruction := submatch[2] 34 | if instruction != "RUN" { // Check only RUN instructions for output 35 | continue 36 | } 37 | 38 | progress := submatch[1] 39 | completed, err := completed(progress) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | if !completed { // Skip uncompleted stages 45 | continue 46 | } 47 | 48 | ok := scanner.Scan() // Skip line with intermediate container hash 49 | if !ok { 50 | return nil, errors.New("failed to skip line with intermediate container hash") 51 | } 52 | 53 | commandOutput, err := scanCommandOutput(scanner) 54 | if err != nil { 55 | return nil, errors.Wrap(err, "failed to scan command output") 56 | } 57 | 58 | return []byte(commandOutput), nil 59 | } 60 | 61 | return nil, errors.New("command output is not fully parsed") 62 | } 63 | 64 | func scanCommandOutput(scanner *bufio.Scanner) (string, error) { 65 | var res []string 66 | for scanner.Scan() { 67 | line := scanner.Text() 68 | 69 | submatch := outputLine.FindStringSubmatch(line) 70 | if len(submatch) != len(outputLine.SubexpNames()) { 71 | return "", errors.Errorf("invalid output line format: %s", line) 72 | } 73 | 74 | mark := submatch[1] 75 | output := submatch[2] 76 | 77 | if mark == doneSymbol { 78 | return strings.Join(res, "\n"), nil 79 | } 80 | 81 | res = append(res, output) 82 | } 83 | 84 | output := strings.Join(res, "\n") 85 | return "", errors.Errorf("output line is not terminated by %s: current line: %s", doneSymbol, output) 86 | } 87 | 88 | func completed(progress string) (bool, error) { 89 | const progressSeparator = "/" 90 | parts := strings.Split(progress, progressSeparator) 91 | if len(parts) != 2 { 92 | return false, errors.Errorf("incorrect progress format %s", progress) 93 | } 94 | 95 | readyPart, err := strconv.Atoi(parts[0]) 96 | if err != nil { 97 | return false, errors.Errorf("incorrect progress format %s", progress) 98 | } 99 | 100 | allPart, err := strconv.Atoi(parts[1]) 101 | if err != nil { 102 | return false, errors.Errorf("incorrect progress format %s", progress) 103 | } 104 | 105 | return readyPart == allPart, nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/backend/app/dockerfile/vargenerator.go: -------------------------------------------------------------------------------- 1 | package dockerfile 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ispringtech/brewkit/internal/backend/api" 7 | "github.com/ispringtech/brewkit/internal/common/maybe" 8 | "github.com/ispringtech/brewkit/internal/common/slices" 9 | "github.com/ispringtech/brewkit/internal/dockerfile" 10 | ) 11 | 12 | type VarGenerator interface { 13 | GenerateDockerfile(vars []api.Var) (dockerfile.Dockerfile, error) 14 | } 15 | 16 | func NewVarGenerator(dockerfileImage string) VarGenerator { 17 | return &varGenerator{dockerfileImage: dockerfileImage} 18 | } 19 | 20 | type varGenerator struct { 21 | dockerfileImage string 22 | } 23 | 24 | func (generator varGenerator) GenerateDockerfile(vars []api.Var) (dockerfile.Dockerfile, error) { 25 | return dockerfile.Dockerfile{ 26 | SyntaxHeader: dockerfile.Syntax(generator.dockerfileImage), 27 | Stages: slices.Map(vars, generator.stageForVar), 28 | }, nil 29 | } 30 | 31 | func (generator varGenerator) stageForVar(v api.Var) dockerfile.Stage { 32 | return dockerfile.Stage{ 33 | From: v.From, 34 | As: maybe.NewJust(v.Name), 35 | Instructions: generator.instructionsForVar(v), 36 | } 37 | } 38 | 39 | func (generator varGenerator) instructionsForVar(v api.Var) []dockerfile.Instruction { 40 | //nolint:prealloc 41 | var instructions []dockerfile.Instruction 42 | 43 | instructions = append(instructions, dockerfile.Workdir(v.WorkDir)) 44 | 45 | for k, v := range v.Env { 46 | instructions = append(instructions, dockerfile.Env{ 47 | K: k, 48 | V: v, 49 | }) 50 | } 51 | 52 | for _, c := range v.Copy { 53 | instructions = append(instructions, dockerfile.Copy{ 54 | Src: c.Src, 55 | Dst: c.Dst, 56 | From: c.From, 57 | }) 58 | } 59 | 60 | //nolint:prealloc 61 | var mounts []dockerfile.Mount 62 | 63 | for _, cache := range v.Cache { 64 | mounts = append(mounts, dockerfile.MountCache{ 65 | ID: maybe.NewJust(cache.ID), 66 | Target: cache.Path, 67 | }) 68 | } 69 | 70 | for _, secret := range v.Secrets { 71 | mounts = append(mounts, dockerfile.MountSecret{ 72 | ID: maybe.NewJust(secret.ID), 73 | Target: maybe.NewJust(secret.MountPath), 74 | Required: maybe.NewJust(true), // make error if secret unavailable 75 | }) 76 | } 77 | 78 | if maybe.Valid(v.SSH) { 79 | mounts = append(mounts, dockerfile.MountSSH{ 80 | Required: maybe.NewJust(true), // make error if ssh key unavailable 81 | }) 82 | } 83 | 84 | var network string 85 | if maybe.Valid(v.Network) { 86 | network = maybe.Just(v.Network).Network 87 | } 88 | 89 | command := generator.transformToHeredoc(v.Command) 90 | 91 | instructions = append(instructions, dockerfile.Run{ 92 | Mounts: mounts, 93 | Network: network, 94 | Command: command, 95 | }) 96 | 97 | return instructions 98 | } 99 | 100 | func (generator varGenerator) transformToHeredoc(s string) string { 101 | const heredocHeader = "EOF" 102 | 103 | return fmt.Sprintf("<<%s\n%s\n%s", heredocHeader, s, heredocHeader) 104 | } 105 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/builddefinition/nativefunc.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "github.com/google/go-jsonnet" 5 | "github.com/google/go-jsonnet/ast" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type nativeFunc interface { 10 | nativeFunc() *jsonnet.NativeFunction 11 | } 12 | 13 | type nativeFunc2[V1, V2 any] struct { 14 | name string 15 | v1 argDesc 16 | v2 argDesc 17 | f func(v1 V1, v2 V2) (interface{}, error) 18 | } 19 | 20 | func (f nativeFunc2[V1, V2]) nativeFunc() *jsonnet.NativeFunction { 21 | return &jsonnet.NativeFunction{ 22 | Name: f.name, 23 | Func: errWrapper( 24 | f.name, 25 | func(i []interface{}) (interface{}, error) { 26 | const argsCount = 2 27 | if len(i) != argsCount { 28 | return nil, errors.Errorf("not enough arguments to call, expected %d", argsCount) 29 | } 30 | 31 | v1, err := checkArg[V1](f.v1, i[0]) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | v2, err := checkArg[V2](f.v2, i[1]) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return f.f(v1, v2) 42 | }, 43 | ), 44 | Params: []ast.Identifier{ 45 | ast.Identifier(f.v1.name), 46 | ast.Identifier(f.v2.name), 47 | }, 48 | } 49 | } 50 | 51 | type nativeFunc3[V1, V2, V3 any] struct { 52 | name string 53 | v1 argDesc 54 | v2 argDesc 55 | v3 argDesc 56 | f func(v1 V1, v2 V2, v3 V3) (interface{}, error) 57 | } 58 | 59 | func (f nativeFunc3[V1, V2, V3]) nativeFunc() *jsonnet.NativeFunction { 60 | return &jsonnet.NativeFunction{ 61 | Name: f.name, 62 | Func: errWrapper( 63 | f.name, 64 | func(i []interface{}) (interface{}, error) { 65 | const argsCount = 3 66 | if len(i) != argsCount { 67 | return nil, errors.Errorf("not enough arguments to call, expected %d", argsCount) 68 | } 69 | 70 | v1, err := checkArg[V1](f.v1, i[0]) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | v2, err := checkArg[V2](f.v2, i[1]) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | v3, err := checkArg[V3](f.v3, i[2]) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return f.f(v1, v2, v3) 86 | }, 87 | ), 88 | Params: []ast.Identifier{ 89 | ast.Identifier(f.v1.name), 90 | ast.Identifier(f.v2.name), 91 | ast.Identifier(f.v3.name), 92 | }, 93 | } 94 | } 95 | 96 | func errWrapper(name string, next func(i []interface{}) (interface{}, error)) func(i []interface{}) (interface{}, error) { 97 | return func(i []interface{}) (interface{}, error) { 98 | json, err := next(i) 99 | if err != nil { 100 | err = errors.Wrapf(err, "call '%s' failed", name) 101 | } 102 | return json, err 103 | } 104 | } 105 | 106 | type argDesc struct { 107 | name string 108 | } 109 | 110 | // check argument type according to generic type and returns value converted to generic type and error 111 | func checkArg[V any](arg argDesc, argV interface{}) (v V, err error) { 112 | var ok bool 113 | v, ok = argV.(V) 114 | if !ok { 115 | return v, errors.Errorf("expected '%T' got '%T' as %s arg", v, argV, arg.name) 116 | } 117 | return v, nil 118 | } 119 | -------------------------------------------------------------------------------- /docs/build-definition/overview.md: -------------------------------------------------------------------------------- 1 | # Build definition 2 | 3 | Build definition describes build-time `Vars` and `Targets` for build 4 | 5 | ## Overview 6 | 7 | Each build-definition has top-level blocks: 8 | * **apiVersion** - describes build-definition apiVersion for backward compatibility 9 | * **vars** - build-time variables that calculates in build time 10 | * **targets** - executable build targets 11 | 12 | Build definition with vars and targets 13 | ```jsonnet 14 | local app = "service"; 15 | 16 | local copy = std.native('copy'); 17 | 18 | { 19 | apiVersion: "brewkit/v1", 20 | 21 | vars: { 22 | gitcommit: { 23 | from: "golang:1.20", 24 | workdir: "/app", 25 | copy: copy('.git', '.git'), 26 | command: "git -c log.showsignature=false show -s --format=%H:%ct" 27 | } 28 | }, 29 | 30 | targets: { 31 | all: ['gobuild'], 32 | 33 | gobuild: { 34 | from: "golang:1.20", 35 | workdir: "/app", 36 | copy: [ 37 | copy('cmd', 'cmd'), 38 | copy('pkg', 'pkg'), 39 | ], 40 | // Use gitcommit as go ldflag 41 | command: std.format('go build -ldflags "-X main.Commit=${gitcommit}" -o ./bin/%s ./cmd/%s', [app]) 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | ## Vars 48 | 49 | Define a var 50 | ```jsonnet 51 | // ... 52 | vars: { 53 | gitcommit: { 54 | from: "golang:1.20", 55 | workdir: "/app", 56 | copy: copy('.git', '.git'), 57 | command: "git -c log.showsignature=false show -s --format=%H:%ct" 58 | } 59 | } 60 | // ... 61 | ``` 62 | 63 | Now you can reference it in target. Variable reference format: `${VAR}` 64 | ```jsonnet 65 | targets: { 66 | gobuild: { 67 | from: "golang:1.20", 68 | workdir: "/app", 69 | copy: [ 70 | copy('cmd', 'cmd'), 71 | copy('pkg', 'pkg'), 72 | ], 73 | // Use ${gitcommit} as go ldflag 74 | command: std.format('go build -ldflags "-X main.Commit=${gitcommit}" -o ./bin/%s ./cmd/%s', [app]) 75 | } 76 | } 77 | ``` 78 | 79 | _Notes about variables_: 80 | * You should clearly separate JSONNET variables, which are calculated when build-definition compiles, and brewkit vars, which calculates in build time. 81 | * Vars calculate before build starts 82 | 83 | ### Deference between jsonnet variables 84 | 85 | You can define variable in jsonnet and use it in build definition. As in top example with `local app = "service";` 86 | 87 | But jsonnet variables can't be changed due to build-time, for example git commit hash or smth else. 88 | 89 | So, when you need build-time variables, you can use `vars`. As in top example with `gitcommit` variable. 90 | 91 | ## API version 92 | 93 | Each brewkit build-definition should satisfy build-definition apiVersion. 94 | All `apiVersion` schemas placed - [build-definition](/data/specification/build-definition) 95 | 96 | ## All target 97 | 98 | `All` is special reserved target name which runs when no concrete target name passed. 99 | 100 | So when run `brewkit build` brewkit executes `all` target. 101 | 102 | ## jsonnet 103 | 104 | BrewKit use jsonnet as build-definition language. 105 | 106 | As jsonnet is extension of JSON you can write build-definition in JSON and pass to brewkit. 107 | 108 | Also, all features of [jsonnet](https://github.com/google/go-jsonnet) are supported 109 | 110 | ## std.native('copy') - extension functions 111 | 112 | JSONNET extension functions can be used to simplify writing build-definition. 113 |
114 | Extension functions can be accessed via `std.native('')` 115 | 116 | List of jsonnet extension functions - [jsonnet-extensions](jsonnet-extensions.md) 117 | 118 | -------------------------------------------------------------------------------- /brewkit/project.libsonnet: -------------------------------------------------------------------------------- 1 | local images = import 'images.libsonnet'; 2 | 3 | local cache = std.native('cache'); 4 | local copy = std.native('copy'); 5 | local copyFrom = std.native('copyFrom'); 6 | 7 | // External cache for go compiler, go mod, golangci-lint 8 | local gocache = [ 9 | cache("go-build", "/app/cache"), 10 | cache("go-mod", "/go/pkg/mod"), 11 | ]; 12 | 13 | // Sources which will be tracked for changes 14 | local gosources = [ 15 | "go.mod", 16 | "go.sum", 17 | "cmd", 18 | "internal", 19 | ]; 20 | 21 | { 22 | project():: { 23 | apiVersion: "brewkit/v1", 24 | 25 | vars: { 26 | gitcommit: { 27 | from: images.golang, 28 | workdir: "/app", 29 | copy: copy('.git', '.git'), 30 | command: "git -c log.showsignature=false show -s --format=%H:%ct" 31 | } 32 | }, 33 | 34 | targets: { 35 | all: ["build", "check", "modulesvendor"], 36 | 37 | gosources: { 38 | from: "scratch", 39 | workdir: "/app", 40 | copy: [copy(source, source) for source in gosources] 41 | }, 42 | 43 | gobase: { 44 | from: images.golang, 45 | workdir: "/app", 46 | env: { 47 | GOCACHE: "/app/cache/go-build", 48 | }, 49 | copy: copyFrom( 50 | 'gosources', 51 | '/app', 52 | '/app' 53 | ), 54 | }, 55 | 56 | build: { 57 | from: "gobase", 58 | cache: gocache, 59 | workdir: "/app", 60 | dependsOn: ['modules'], 61 | command: std.format(' 62 | go build \\ 63 | -trimpath -v \\ 64 | -ldflags "-X main.Commit=${gitcommit} -X main.DockerfileImage=%s" \\ 65 | -o ./bin/brewkit ./cmd/brewkit 66 | ', [images.dockerfile]), 67 | output: { 68 | artifact: "/app/bin/brewkit", 69 | "local": "./bin" 70 | } 71 | }, 72 | 73 | modules: { 74 | from: "gobase", 75 | cache: gocache, 76 | workdir: "/app", 77 | command: "go mod tidy", 78 | output: { 79 | artifact: "/app/go.*", 80 | "local": ".", 81 | }, 82 | }, 83 | 84 | // export local copy of dependencies for ide index 85 | modulesvendor: { 86 | from: "gobase", 87 | workdir: "/app", 88 | cache: gocache, 89 | dependsOn: ['modules'], 90 | command: "go mod vendor", 91 | output: { 92 | artifact: "/app/vendor", 93 | "local": "vendor", 94 | }, 95 | }, 96 | 97 | check: ["test", "lint"], 98 | 99 | test: { 100 | from: "gobase", 101 | workdir: "/app", 102 | cache: gocache, 103 | command: "go test ./...", 104 | }, 105 | 106 | lint: { 107 | from: images.golangcilint, 108 | workdir: "/app", 109 | cache: gocache, 110 | copy: [ 111 | copyFrom( 112 | 'gosources', 113 | '/app', 114 | '/app' 115 | ), 116 | copy('.golangci.yml', '.golangci.yml'), 117 | ], 118 | env: { 119 | GOCACHE: "/app/cache/go-build", 120 | GOLANGCI_LINT_CACHE: "/app/cache/go-build" 121 | }, 122 | command: "golangci-lint run" 123 | }, 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /internal/frontend/app/service/build.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/pkg/errors" 8 | 9 | "github.com/ispringtech/brewkit/internal/backend/api" 10 | "github.com/ispringtech/brewkit/internal/common/maybe" 11 | "github.com/ispringtech/brewkit/internal/common/slices" 12 | "github.com/ispringtech/brewkit/internal/frontend/app/buildconfig" 13 | "github.com/ispringtech/brewkit/internal/frontend/app/builddefinition" 14 | appconfig "github.com/ispringtech/brewkit/internal/frontend/app/config" 15 | ) 16 | 17 | const ( 18 | allTargetKeyword = "all" 19 | ) 20 | 21 | type BuildService interface { 22 | Build(ctx context.Context, p BuildParams) error 23 | 24 | DumpBuildDefinition(ctx context.Context, configPath string) (string, error) 25 | DumpCompiledBuildDefinition(ctx context.Context, configPath string) (string, error) 26 | } 27 | 28 | type BuildParams struct { 29 | Targets []string // Target names to run 30 | BuildDefinition string 31 | 32 | ForcePull bool 33 | } 34 | 35 | func NewBuildService( 36 | configParser buildconfig.Parser, 37 | definitionBuilder builddefinition.Builder, 38 | builder api.BuilderAPI, 39 | config appconfig.Config, 40 | ) BuildService { 41 | return &buildService{ 42 | configParser: configParser, 43 | definitionBuilder: definitionBuilder, 44 | builder: builder, 45 | config: config, 46 | } 47 | } 48 | 49 | type buildService struct { 50 | configParser buildconfig.Parser 51 | definitionBuilder builddefinition.Builder 52 | builder api.BuilderAPI 53 | config appconfig.Config 54 | } 55 | 56 | func (service *buildService) Build(ctx context.Context, p BuildParams) error { 57 | c, err := service.configParser.Parse(p.BuildDefinition) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | definition, err := service.definitionBuilder.Build(c, service.config.Secrets) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | vertex, err := service.buildVertex(p.Targets, definition) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | secrets := slices.Map(service.config.Secrets, func(s appconfig.Secret) api.SecretSrc { 73 | return api.SecretSrc{ 74 | ID: s.ID, 75 | SourcePath: s.Path, 76 | } 77 | }) 78 | 79 | return service.builder.Build( 80 | ctx, 81 | vertex, 82 | definition.Vars, 83 | secrets, 84 | api.BuildParams{ 85 | ForcePull: p.ForcePull, 86 | }, 87 | ) 88 | } 89 | 90 | func (service *buildService) DumpBuildDefinition(_ context.Context, configPath string) (string, error) { 91 | c, err := service.configParser.Parse(configPath) 92 | if err != nil { 93 | return "", err 94 | } 95 | 96 | definition, err := service.definitionBuilder.Build(c, service.config.Secrets) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | d, err := json.Marshal(definition) 102 | if err != nil { 103 | return "", errors.WithStack(err) 104 | } 105 | 106 | return string(d), nil 107 | } 108 | 109 | func (service *buildService) DumpCompiledBuildDefinition(_ context.Context, configPath string) (string, error) { 110 | return service.configParser.CompileConfig(configPath) 111 | } 112 | 113 | func (service *buildService) buildVertex(targets []string, definition builddefinition.Definition) (api.Vertex, error) { 114 | if len(targets) == 0 { 115 | v, err := service.findTarget(allTargetKeyword, definition) 116 | return v, errors.Wrap(err, "failed to find default target") 117 | } 118 | 119 | if len(targets) == 1 { 120 | return service.findTarget(targets[0], definition) 121 | } 122 | 123 | vertexes, err := slices.MapErr(targets, func(t string) (api.Vertex, error) { 124 | return service.findTarget(t, definition) 125 | }) 126 | if err != nil { 127 | return api.Vertex{}, err 128 | } 129 | 130 | return api.Vertex{ 131 | Name: allTargetKeyword, 132 | DependsOn: vertexes, 133 | }, nil 134 | } 135 | 136 | func (service *buildService) findTarget(target string, definition builddefinition.Definition) (api.Vertex, error) { 137 | vertex := definition.Vertex(target) 138 | if !maybe.Valid(vertex) { 139 | return api.Vertex{}, errors.Errorf("target %s not found", target) 140 | } 141 | return maybe.Just(vertex), nil 142 | } 143 | -------------------------------------------------------------------------------- /cmd/brewkit/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | backendapp "github.com/ispringtech/brewkit/internal/backend/app/build" 9 | "github.com/ispringtech/brewkit/internal/backend/infrastructure/docker" 10 | "github.com/ispringtech/brewkit/internal/backend/infrastructure/ssh" 11 | "github.com/ispringtech/brewkit/internal/frontend/app/buildconfig" 12 | "github.com/ispringtech/brewkit/internal/frontend/app/builddefinition" 13 | "github.com/ispringtech/brewkit/internal/frontend/app/service" 14 | infrabuilddefinition "github.com/ispringtech/brewkit/internal/frontend/infrastructure/builddefinition" 15 | ) 16 | 17 | func build(workdir string) *cli.Command { 18 | return &cli.Command{ 19 | Name: "build", 20 | Usage: "Build project from build definition", 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "definition", 24 | Usage: "Config with build definition", 25 | Aliases: []string{"d"}, 26 | Value: path.Join(workdir, buildconfig.DefaultName), 27 | EnvVars: []string{"BREWKIT_BUILD_CONFIG"}, 28 | }, 29 | &cli.BoolFlag{ 30 | Name: "force-pull", 31 | Usage: "Always pull a newer version of images for targets", 32 | Aliases: []string{"p"}, 33 | EnvVars: []string{"BREWKIT_FORCE_PULL"}, 34 | }, 35 | }, 36 | Action: executeBuild, 37 | Subcommands: []*cli.Command{ 38 | { 39 | Name: "definition", 40 | Usage: "Print full parsed and verified build definition", 41 | Action: executeBuildDefinition, 42 | }, 43 | { 44 | Name: "definition-debug", 45 | Usage: "Print compiled build definition in raw JSON, useful for debugging complex build definitions", 46 | Action: executeCompileBuildDefinition, 47 | }, 48 | }, 49 | } 50 | } 51 | 52 | type buildOps struct { 53 | commonOpt 54 | BuildDefinition string 55 | ForcePull bool 56 | } 57 | 58 | func (o *buildOps) scan(ctx *cli.Context) { 59 | o.commonOpt.scan(ctx) 60 | o.BuildDefinition = ctx.String("definition") 61 | o.ForcePull = ctx.Bool("force-pull") 62 | } 63 | 64 | func executeBuild(ctx *cli.Context) error { 65 | var opts buildOps 66 | opts.scan(ctx) 67 | 68 | buildService, err := makeBuildService(opts) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | return buildService.Build(ctx.Context, service.BuildParams{ 74 | Targets: ctx.Args().Slice(), 75 | BuildDefinition: opts.BuildDefinition, 76 | ForcePull: opts.ForcePull, 77 | }) 78 | } 79 | 80 | func executeBuildDefinition(ctx *cli.Context) error { 81 | var opts buildOps 82 | opts.scan(ctx) 83 | 84 | logger := makeLogger(opts.verbose) 85 | 86 | buildService, err := makeBuildService(opts) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | buildDefinition, err := buildService.DumpBuildDefinition(ctx.Context, opts.BuildDefinition) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | logger.Outputf(buildDefinition) 97 | 98 | return nil 99 | } 100 | 101 | func executeCompileBuildDefinition(ctx *cli.Context) error { 102 | var opts buildOps 103 | opts.scan(ctx) 104 | 105 | logger := makeLogger(opts.verbose) 106 | 107 | buildService, err := makeBuildService(opts) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | buildDefinition, err := buildService.DumpCompiledBuildDefinition(ctx.Context, opts.BuildDefinition) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | logger.Outputf(buildDefinition) 118 | 119 | return nil 120 | } 121 | 122 | func makeBuildService(options buildOps) (service.BuildService, error) { 123 | parser := infrabuilddefinition.Parser{} 124 | 125 | logger := makeLogger(options.verbose) 126 | 127 | config, err := parseConfig(options.configPath, logger) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | dockerClient, err := docker.NewClient(options.dockerClientConfigPath, logger) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | agentProvider, err := ssh.NewAgentProvider() 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | backendBuildService := backendapp.NewBuildService( 143 | dockerClient, 144 | DockerfileImage, 145 | agentProvider, 146 | logger, 147 | ) 148 | 149 | return service.NewBuildService( 150 | parser, 151 | builddefinition.NewBuilder(), 152 | backendBuildService, 153 | config, 154 | ), nil 155 | } 156 | -------------------------------------------------------------------------------- /internal/frontend/infrastructure/builddefinition/parser.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | 8 | "github.com/google/go-jsonnet" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ispringtech/brewkit/internal/common/either" 12 | "github.com/ispringtech/brewkit/internal/common/maybe" 13 | "github.com/ispringtech/brewkit/internal/common/slices" 14 | "github.com/ispringtech/brewkit/internal/frontend/app/buildconfig" 15 | ) 16 | 17 | type Parser struct{} 18 | 19 | func (parser Parser) Parse(configPath string) (buildconfig.Config, error) { 20 | data, err := parser.compileConfig(configPath) 21 | if err != nil { 22 | return buildconfig.Config{}, err 23 | } 24 | 25 | var c Config 26 | 27 | err = json.Unmarshal([]byte(data), &c) 28 | if err != nil { 29 | return buildconfig.Config{}, errors.Wrap(err, "failed to parse json config") 30 | } 31 | 32 | return mapConfig(c), nil 33 | } 34 | 35 | func (parser Parser) CompileConfig(configPath string) (string, error) { 36 | return parser.compileConfig(configPath) 37 | } 38 | 39 | func (parser Parser) compileConfig(configPath string) (string, error) { 40 | fileBytes, err := os.ReadFile(configPath) 41 | if err != nil { 42 | return "", errors.Wrap(err, "failed to read build config file") 43 | } 44 | 45 | vm := jsonnet.MakeVM() 46 | 47 | for _, f := range funcs { 48 | vm.NativeFunction(f.nativeFunc()) 49 | } 50 | 51 | data, err := vm.EvaluateAnonymousSnippet(path.Base(configPath), string(fileBytes)) 52 | return data, errors.Wrap(err, "failed to compile jsonnet for build definition") 53 | } 54 | 55 | func mapConfig(c Config) buildconfig.Config { 56 | return buildconfig.Config{ 57 | APIVersion: c.APIVersion, 58 | Targets: mapTargets(c.Targets), 59 | Vars: mapVars(c.Vars), 60 | } 61 | } 62 | 63 | func mapTargets(targets map[string]either.Either[[]string, Target]) []buildconfig.TargetData { 64 | result := make([]buildconfig.TargetData, 0, len(targets)) 65 | for name, target := range targets { 66 | target. 67 | MapLeft(func(dependsOn []string) { 68 | result = append(result, buildconfig.TargetData{ 69 | Name: name, 70 | DependsOn: dependsOn, 71 | Stage: maybe.NewNone[buildconfig.StageData](), 72 | }) 73 | }). 74 | MapRight(func(t Target) { 75 | s := maybe.FromPtr(t.Stage) 76 | 77 | result = append(result, buildconfig.TargetData{ 78 | Name: name, 79 | DependsOn: t.DependsOn, 80 | Stage: maybe.Map(s, mapStage), 81 | }) 82 | }) 83 | } 84 | 85 | return result 86 | } 87 | 88 | func mapStage(stage Stage) buildconfig.StageData { 89 | return buildconfig.StageData{ 90 | From: stage.From, 91 | Platform: stage.Platform, 92 | WorkDir: stage.WorkDir, 93 | Env: stage.Env, 94 | Command: stage.Command, 95 | SSH: mapSSH(stage.SSH), 96 | Cache: slices.Map(stage.Cache, mapCache), 97 | Copy: parseCopy(stage.Copy), 98 | Secrets: parseSecret(stage.Secrets), 99 | Network: maybe.Map(stage.Network, func(n string) string { 100 | return n 101 | }), 102 | Output: maybe.Map(stage.Output, func(o Output) buildconfig.Output { 103 | return buildconfig.Output{ 104 | Artifact: o.Artifact, 105 | Local: o.Local, 106 | } 107 | }), 108 | } 109 | } 110 | 111 | func mapVars(vars map[string]Var) []buildconfig.VarData { 112 | result := make([]buildconfig.VarData, 0, len(vars)) 113 | for name, v := range vars { 114 | result = append(result, mapVar(name, v)) 115 | } 116 | return result 117 | } 118 | 119 | func mapVar(name string, v Var) buildconfig.VarData { 120 | return buildconfig.VarData{ 121 | Name: name, 122 | From: v.From, 123 | Platform: v.Platform, 124 | WorkDir: v.WorkDir, 125 | Env: v.Env, 126 | SSH: mapSSH(v.SSH), 127 | Cache: slices.Map(v.Cache, mapCache), 128 | Copy: parseCopy(v.Copy), 129 | Secrets: parseSecret(v.Secrets), 130 | Network: maybe.Map(v.Network, func(n string) string { 131 | return n 132 | }), 133 | Command: v.Command, 134 | } 135 | } 136 | 137 | func mapSSH(ssh maybe.Maybe[SSH]) maybe.Maybe[buildconfig.SSH] { 138 | return maybe.Map(ssh, func(s SSH) buildconfig.SSH { 139 | return buildconfig.SSH{} 140 | }) 141 | } 142 | 143 | func mapCache(cache Cache) buildconfig.Cache { 144 | return buildconfig.Cache{ 145 | ID: cache.ID, 146 | Path: cache.Path, 147 | } 148 | } 149 | 150 | func parseCopy(c either.Either[[]Copy, Copy]) (result []buildconfig.Copy) { 151 | c. 152 | MapLeft(func(l []Copy) { 153 | result = slices.Map(l, mapCopy) 154 | }). 155 | MapRight(func(r Copy) { 156 | result = append(result, mapCopy(r)) 157 | }) 158 | return result 159 | } 160 | 161 | func mapCopy(c Copy) buildconfig.Copy { 162 | return buildconfig.Copy{ 163 | Src: c.Src, 164 | Dst: c.Dst, 165 | From: c.From, 166 | } 167 | } 168 | 169 | func parseSecret(s either.Either[[]Secret, Secret]) (result []buildconfig.Secret) { 170 | s. 171 | MapLeft(func(l []Secret) { 172 | result = slices.Map(l, mapSecret) 173 | }). 174 | MapRight(func(r Secret) { 175 | result = append(result, mapSecret(r)) 176 | }) 177 | return result 178 | } 179 | 180 | func mapSecret(secret Secret) buildconfig.Secret { 181 | return buildconfig.Secret{ 182 | ID: secret.ID, 183 | Path: secret.Path, 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /internal/backend/infrastructure/docker/client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | 12 | "github.com/pkg/errors" 13 | 14 | "github.com/ispringtech/brewkit/internal/backend/app/docker" 15 | "github.com/ispringtech/brewkit/internal/common/infrastructure/executor" 16 | "github.com/ispringtech/brewkit/internal/common/infrastructure/logger" 17 | "github.com/ispringtech/brewkit/internal/common/maybe" 18 | "github.com/ispringtech/brewkit/internal/dockerfile" 19 | ) 20 | 21 | const ( 22 | dockerExecutable = "docker" 23 | ) 24 | 25 | var ( 26 | dockerEnv = map[string]string{ 27 | "DOCKER_BUILDKIT": "1", // enable buildkit explicitly 28 | } 29 | ) 30 | 31 | func NewClient(clientConfigPath maybe.Maybe[string], log logger.Logger) (docker.Client, error) { 32 | d, err := executor.New( 33 | dockerExecutable, 34 | executor.WithEnv(os.Environ()), 35 | executor.WithEnvMap(dockerEnv), 36 | executor.WithLogger(logger.NewExecutorLogger(log)), 37 | ) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return &client{ 43 | clientConfigPath: clientConfigPath, 44 | dockerExecutor: d, 45 | outputParser: outputParser{}, 46 | }, nil 47 | } 48 | 49 | type client struct { 50 | clientConfigPath maybe.Maybe[string] 51 | dockerExecutor executor.Executor 52 | outputParser outputParser 53 | } 54 | 55 | func (c *client) Build(ctx context.Context, d dockerfile.Dockerfile, params docker.BuildParams) error { 56 | var args executor.Args 57 | 58 | c.populateWithCommonArgs(&args) 59 | c.populateWithBuilderArgs(&args) 60 | args.AddArgs("build") 61 | 62 | if maybe.Valid(params.SSHAgent) { 63 | args.AddKV("--ssh", fmt.Sprintf("default=%s", maybe.Just(params.SSHAgent))) 64 | } 65 | 66 | if len(params.Secrets) > 0 { 67 | for _, secret := range params.Secrets { 68 | args.AddKV("--secret", fmt.Sprintf("id=%s,src=%s", secret.ID, secret.Path)) 69 | } 70 | } 71 | 72 | args.AddKV("--target", params.Target) 73 | 74 | if maybe.Valid(params.Output) { 75 | args.AddKV("--output", maybe.Just(params.Output)) 76 | } 77 | 78 | args.AddArgs("-f-", ".") // Read Dockerfile from stdin and use PWD as context 79 | 80 | dockerfileReader := bytes.NewBufferString(d.Format()) 81 | 82 | err := c.dockerExecutor.Run(ctx, args, executor.RunParams{ 83 | Stdin: maybe.NewJust[io.Reader](dockerfileReader), 84 | }) 85 | if err != nil { 86 | if exitErr, ok := err.(*exec.ExitError); ok { 87 | return docker.RequestError{ 88 | Output: string(exitErr.Stderr), 89 | Code: exitErr.ExitCode(), 90 | } 91 | } 92 | } 93 | return nil 94 | } 95 | 96 | func (c *client) Value(ctx context.Context, d dockerfile.Dockerfile, params docker.ValueParams) ([]byte, error) { 97 | var args executor.Args 98 | 99 | c.populateWithCommonArgs(&args) 100 | c.populateWithBuilderArgs(&args) 101 | args.AddArgs("build") 102 | args.AddKV("--progress", "plain") // Set to plain to be able to parse output 103 | 104 | if !params.UseCache { 105 | args.AddArgs("--no-cache") // Disable cache for target 106 | } 107 | 108 | if maybe.Valid(params.SSHAgent) { 109 | args.AddKV("--ssh", fmt.Sprintf("default=%s", maybe.Just(params.SSHAgent))) 110 | } 111 | 112 | args.AddKV("--target", params.Var) 113 | 114 | args.AddArgs("-f-", ".") // Read Dockerfile from stdin and use PWD as context 115 | 116 | dockerfileReader := bytes.NewBufferString(d.Format()) 117 | 118 | output := &bytes.Buffer{} 119 | err := c.dockerExecutor.Run(ctx, args, executor.RunParams{ 120 | Stdin: maybe.NewJust[io.Reader](dockerfileReader), 121 | Stderr: maybe.NewJust[io.Writer](output), 122 | }) 123 | if err != nil { 124 | if _, ok := err.(*exec.ExitError); ok { 125 | return nil, &docker.RequestError{ 126 | Output: output.String(), 127 | } 128 | } 129 | return nil, err 130 | } 131 | 132 | return c.outputParser.parseBuildOutputForRunTarget(output) 133 | } 134 | 135 | func (c *client) ListImages(ctx context.Context, images []string) ([]docker.Image, error) { 136 | var args executor.Args 137 | 138 | c.populateWithCommonArgs(&args) 139 | 140 | args.AddArgs("image", "ls") // List images 141 | 142 | for _, image := range images { 143 | args.AddKV("--filter", fmt.Sprintf("reference=%s", image)) 144 | } 145 | 146 | args.AddKV("--format", "{{.Repository}}:{{.Tag}}") // Just list images repository and tag for now, can be used as filter 147 | 148 | output := &bytes.Buffer{} 149 | err := c.dockerExecutor.Run(ctx, args, executor.RunParams{Stdout: maybe.NewJust[io.Writer](output)}) 150 | if err != nil { 151 | return nil, errors.Wrapf(err, "failed to list docker images") 152 | } 153 | 154 | var res []docker.Image 155 | scanner := bufio.NewScanner(output) 156 | for scanner.Scan() { 157 | image := scanner.Text() 158 | res = append(res, docker.Image{Img: image}) 159 | } 160 | 161 | return res, nil 162 | } 163 | 164 | func (c *client) PullImage(ctx context.Context, img string) error { 165 | var args executor.Args 166 | 167 | c.populateWithCommonArgs(&args) 168 | args.AddArgs("pull") 169 | args.AddArgs(img) 170 | 171 | return c.dockerExecutor.Run(ctx, args, executor.RunParams{}) 172 | } 173 | 174 | func (c *client) ClearCache(ctx context.Context, params docker.ClearCacheParams) error { 175 | var args executor.Args 176 | 177 | c.populateWithCommonArgs(&args) 178 | c.populateWithBuilderArgs(&args) 179 | 180 | args.AddArgs("prune", "-f") 181 | if params.All { 182 | args.AddArgs("-a") // Delete all cache 183 | } 184 | 185 | return c.dockerExecutor.Run(ctx, args, executor.RunParams{}) 186 | } 187 | 188 | func (c *client) BuildImage(_ context.Context, _ string) error { 189 | // TODO implement me 190 | panic("implement me") 191 | } 192 | 193 | func (c *client) populateWithCommonArgs(args *executor.Args) { 194 | if maybe.Valid(c.clientConfigPath) { 195 | args.AddKV("--config", maybe.Just(c.clientConfigPath)) 196 | } 197 | } 198 | 199 | func (c *client) populateWithBuilderArgs(args *executor.Args) { 200 | args.AddArgs("builder") // Use builder explicitly 201 | } 202 | -------------------------------------------------------------------------------- /internal/dockerfile/dockerfile.go: -------------------------------------------------------------------------------- 1 | package dockerfile 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/ispringtech/brewkit/internal/common/maybe" 9 | "github.com/ispringtech/brewkit/internal/common/slices" 10 | ) 11 | 12 | type Syntax string 13 | 14 | const ( 15 | Scratch = "scratch" 16 | 17 | Dockerfile14 Syntax = "docker/dockerfile:1.4" 18 | ) 19 | 20 | type Dockerfile struct { 21 | SyntaxHeader Syntax 22 | Stages []Stage 23 | } 24 | 25 | func (d Dockerfile) Format() string { 26 | s := []string{ 27 | fmt.Sprintf("# syntax=%s", d.SyntaxHeader), 28 | strings.Join(slices.Map(d.Stages, func(s Stage) string { 29 | return s.Format() 30 | }), "\n"), 31 | } 32 | 33 | return strings.Join(s, "\n") 34 | } 35 | 36 | type Stage struct { 37 | From string 38 | As maybe.Maybe[string] 39 | Instructions []Instruction 40 | } 41 | 42 | func (s Stage) Format() string { 43 | instructions := strings.Join(slices.Map(s.Instructions, func(i Instruction) string { 44 | return i.FormatInstruction() 45 | }), "\n") 46 | 47 | var asBlock string 48 | if maybe.Valid(s.As) { 49 | asBlock = fmt.Sprintf("as %s", maybe.Just(s.As)) 50 | } 51 | 52 | return fmt.Sprintf("FROM %s %s\n%s", s.From, asBlock, instructions) 53 | } 54 | 55 | type Instruction interface { 56 | FormatInstruction() string 57 | } 58 | 59 | type Workdir string 60 | 61 | func (w Workdir) FormatInstruction() string { 62 | return fmt.Sprintf("WORKDIR %s", w) 63 | } 64 | 65 | type Env struct { 66 | K, V string 67 | } 68 | 69 | func (e Env) FormatInstruction() string { 70 | return fmt.Sprintf("ENV %s=%s", e.K, e.V) 71 | } 72 | 73 | type Copy struct { 74 | Src string 75 | Dst string 76 | From maybe.Maybe[string] 77 | } 78 | 79 | func (c Copy) FormatInstruction() string { 80 | var from string 81 | if maybe.Valid(c.From) { 82 | from = fmt.Sprintf("--from=%s", maybe.Just(c.From)) 83 | } 84 | 85 | return fmt.Sprintf("COPY %s %s %s", from, c.Src, c.Dst) 86 | } 87 | 88 | type Run struct { 89 | Mounts []Mount 90 | Network string 91 | Command string 92 | } 93 | 94 | func (r Run) FormatInstruction() string { 95 | instructions := make([]string, 0, len(r.Mounts)) 96 | for _, mount := range r.Mounts { 97 | m := mount.FormatMount() 98 | instructions = append(instructions, fmt.Sprintf("--mount=%s", m)) 99 | } 100 | 101 | if r.Network != "" { 102 | instructions = append(instructions, fmt.Sprintf("--network=%s", r.Network)) 103 | } 104 | 105 | return fmt.Sprintf( 106 | "RUN %s \\\n %s", 107 | strings.Join(instructions, " \\\n"), 108 | r.Command, 109 | ) 110 | } 111 | 112 | type Mount interface { 113 | FormatMount() string 114 | } 115 | 116 | type MountBind struct { 117 | Target string 118 | Source maybe.Maybe[string] 119 | From maybe.Maybe[string] 120 | ReadWrite maybe.Maybe[bool] 121 | } 122 | 123 | func (m MountBind) FormatMount() string { 124 | s := settings{} 125 | 126 | s.addKV("type", "bind") 127 | s.addKV("target", m.Target) 128 | 129 | if maybe.Valid(m.Source) { 130 | s.addKV("source", maybe.Just(m.Source)) 131 | } 132 | 133 | if maybe.Valid(m.From) { 134 | s.addKV("from", maybe.Just(m.From)) 135 | } 136 | 137 | if maybe.Valid(m.ReadWrite) { 138 | s.addKV("rw", strconv.FormatBool(maybe.Just(m.ReadWrite))) 139 | } 140 | 141 | return s.formatSettings() 142 | } 143 | 144 | type MountCache struct { 145 | ID maybe.Maybe[string] 146 | Target string 147 | ReadOnly maybe.Maybe[bool] 148 | From maybe.Maybe[string] 149 | Source maybe.Maybe[string] 150 | Mode maybe.Maybe[string] 151 | UID maybe.Maybe[string] 152 | GID maybe.Maybe[string] 153 | } 154 | 155 | func (m MountCache) FormatMount() string { 156 | s := settings{} 157 | 158 | s.addKV("type", "cache") 159 | s.addKV("target", m.Target) 160 | 161 | if maybe.Valid(m.ReadOnly) { 162 | s.addKV("source", strconv.FormatBool(maybe.Just(m.ReadOnly))) 163 | } 164 | 165 | if maybe.Valid(m.From) { 166 | s.addKV("from", maybe.Just(m.From)) 167 | } 168 | 169 | if maybe.Valid(m.Source) { 170 | s.addKV("source", maybe.Just(m.Source)) 171 | } 172 | 173 | if maybe.Valid(m.Mode) { 174 | s.addKV("mode", maybe.Just(m.Mode)) 175 | } 176 | 177 | if maybe.Valid(m.UID) { 178 | s.addKV("uid", maybe.Just(m.UID)) 179 | } 180 | 181 | if maybe.Valid(m.GID) { 182 | s.addKV("gid", maybe.Just(m.GID)) 183 | } 184 | 185 | return s.formatSettings() 186 | } 187 | 188 | type MountSSH struct { 189 | ID maybe.Maybe[string] 190 | Target maybe.Maybe[string] 191 | Required maybe.Maybe[bool] 192 | Mode maybe.Maybe[string] 193 | UID maybe.Maybe[string] 194 | GID maybe.Maybe[string] 195 | } 196 | 197 | func (m MountSSH) FormatMount() string { 198 | s := settings{} 199 | 200 | s.addKV("type", "ssh") 201 | 202 | if maybe.Valid(m.Target) { 203 | s.addKV("target", maybe.Just(m.Target)) 204 | } 205 | 206 | if maybe.Valid(m.Required) { 207 | s.addKV("required", strconv.FormatBool(maybe.Just(m.Required))) 208 | } 209 | 210 | if maybe.Valid(m.Mode) { 211 | s.addKV("mode", maybe.Just(m.Mode)) 212 | } 213 | 214 | if maybe.Valid(m.UID) { 215 | s.addKV("uid", maybe.Just(m.UID)) 216 | } 217 | 218 | if maybe.Valid(m.GID) { 219 | s.addKV("gid", maybe.Just(m.GID)) 220 | } 221 | 222 | return s.formatSettings() 223 | } 224 | 225 | type MountSecret struct { 226 | ID maybe.Maybe[string] 227 | Target maybe.Maybe[string] 228 | Required maybe.Maybe[bool] 229 | Mode maybe.Maybe[string] 230 | UID maybe.Maybe[string] 231 | GID maybe.Maybe[string] 232 | } 233 | 234 | func (m MountSecret) FormatMount() string { 235 | s := settings{} 236 | 237 | s.addKV("type", "secret") 238 | 239 | if maybe.Valid(m.ID) { 240 | s.addKV("id", maybe.Just(m.ID)) 241 | } 242 | 243 | if maybe.Valid(m.Target) { 244 | s.addKV("target", maybe.Just(m.Target)) 245 | } 246 | 247 | if maybe.Valid(m.Required) { 248 | s.addKV("required", strconv.FormatBool(maybe.Just(m.Required))) 249 | } 250 | 251 | if maybe.Valid(m.Mode) { 252 | s.addKV("mode", maybe.Just(m.Mode)) 253 | } 254 | 255 | if maybe.Valid(m.UID) { 256 | s.addKV("uid", maybe.Just(m.UID)) 257 | } 258 | 259 | if maybe.Valid(m.GID) { 260 | s.addKV("gid", maybe.Just(m.GID)) 261 | } 262 | 263 | return s.formatSettings() 264 | } 265 | 266 | type settings []string 267 | 268 | func (s *settings) addKV(k, v string) { 269 | *s = append(*s, fmt.Sprintf("%s=%s", k, v)) 270 | } 271 | 272 | func (s *settings) formatSettings() string { 273 | return strings.Join(*s, ",") 274 | } 275 | -------------------------------------------------------------------------------- /internal/backend/app/dockerfile/targetgenerator.go: -------------------------------------------------------------------------------- 1 | package dockerfile 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | 9 | "github.com/ispringtech/brewkit/internal/backend/api" 10 | "github.com/ispringtech/brewkit/internal/common/maps" 11 | "github.com/ispringtech/brewkit/internal/common/maybe" 12 | "github.com/ispringtech/brewkit/internal/dockerfile" 13 | ) 14 | 15 | type Vars map[string]string 16 | 17 | type TargetGenerator interface { 18 | GenerateDockerfile() (dockerfile.Dockerfile, error) 19 | } 20 | 21 | func NewTargetGenerator(v api.Vertex, vars Vars, dockerfileImage string) TargetGenerator { 22 | return &targetGenerator{ 23 | v: v, 24 | vars: vars, 25 | dockerfileImage: dockerfileImage, 26 | generatedStages: maps.Set[string]{}, 27 | } 28 | } 29 | 30 | type targetGenerator struct { 31 | dockerfileImage string 32 | v api.Vertex 33 | vars Vars 34 | generatedStages maps.Set[string] 35 | } 36 | 37 | func (generator targetGenerator) GenerateDockerfile() (dockerfile.Dockerfile, error) { 38 | dockerfileStages, err := generator.stagesForTarget(generator.v) 39 | if err != nil { 40 | return dockerfile.Dockerfile{}, err 41 | } 42 | 43 | return dockerfile.Dockerfile{ 44 | SyntaxHeader: dockerfile.Syntax(generator.dockerfileImage), 45 | Stages: dockerfileStages, 46 | }, nil 47 | } 48 | 49 | func (generator targetGenerator) stagesForTarget(v api.Vertex) ([]dockerfile.Stage, error) { 50 | var stages []dockerfile.Stage 51 | if maybe.Valid(v.From) { 52 | s, err := generator.stagesForTarget(*maybe.Just(v.From)) 53 | if err != nil { 54 | return nil, err 55 | } 56 | stages = append(stages, s...) 57 | } 58 | 59 | if maybe.Valid(v.Stage) { 60 | stage := maybe.Just(v.Stage) 61 | s, err := generator.stagesForCopy(stage) 62 | if err != nil { 63 | return nil, err 64 | } 65 | stages = append(stages, s...) 66 | 67 | s, err = generator.stages(v.Name, stage) 68 | if err != nil { 69 | return nil, errors.Wrapf(err, "failed to generate instructions for target %s", v.Name) 70 | } 71 | stages = append(stages, s...) 72 | } 73 | 74 | for _, childV := range v.DependsOn { 75 | s, err := generator.stagesForTarget(childV) 76 | if err != nil { 77 | return nil, err 78 | } 79 | stages = append(stages, s...) 80 | } 81 | 82 | return stages, nil 83 | } 84 | 85 | func (generator targetGenerator) stagesForCopy(stage api.Stage) ([]dockerfile.Stage, error) { 86 | var stages []dockerfile.Stage 87 | for _, c := range stage.Copy { 88 | if !maybe.Valid(c.From) { 89 | // Skip images with empty from 90 | continue 91 | } 92 | 93 | var ( 94 | s []dockerfile.Stage 95 | err error 96 | ) 97 | maybe.Just(c.From). 98 | MapLeft(func(l *api.Vertex) { 99 | s, err = generator.stagesForTarget(*l) 100 | if err != nil { 101 | return 102 | } 103 | }) 104 | if err != nil { 105 | return nil, err 106 | } 107 | stages = append(stages, s...) 108 | } 109 | return stages, nil 110 | } 111 | 112 | func (generator targetGenerator) stages(name string, stage api.Stage) ([]dockerfile.Stage, error) { 113 | // Stage was already generated by another dependency 114 | if generator.generatedStages.Has(name) { 115 | return nil, nil 116 | } 117 | 118 | instructions, err := generator.instructionsForStage(stage) 119 | if err != nil { 120 | return nil, errors.Wrap(err, "failed to generate instructions for stage") 121 | } 122 | 123 | stages := []dockerfile.Stage{ 124 | { 125 | From: stage.From, 126 | As: maybe.NewJust(name), 127 | Instructions: instructions, 128 | }, 129 | } 130 | 131 | if maybe.Valid(stage.Output) { 132 | output := maybe.Just(stage.Output) 133 | 134 | const pwd = "." 135 | stages = append(stages, dockerfile.Stage{ 136 | From: dockerfile.Scratch, 137 | As: maybe.NewJust(fmt.Sprintf("%s-out", name)), 138 | Instructions: []dockerfile.Instruction{ 139 | dockerfile.Copy{ 140 | Src: output.Artifact, 141 | Dst: pwd, 142 | From: maybe.NewJust(name), 143 | }, 144 | }, 145 | }) 146 | } 147 | 148 | generator.generatedStages[name] = struct{}{} 149 | 150 | return stages, nil 151 | } 152 | 153 | func (generator targetGenerator) instructionsForStage(stage api.Stage) ([]dockerfile.Instruction, error) { 154 | //nolint:prealloc 155 | var instructions []dockerfile.Instruction 156 | 157 | instructions = append(instructions, dockerfile.Workdir(stage.WorkDir)) 158 | 159 | for k, v := range stage.Env { 160 | instructions = append(instructions, dockerfile.Env{ 161 | K: k, 162 | V: v, 163 | }) 164 | } 165 | 166 | for _, c := range stage.Copy { 167 | var from maybe.Maybe[string] 168 | if maybe.Valid(c.From) { 169 | maybe.Just(c.From). 170 | MapLeft(func(v *api.Vertex) { 171 | from = maybe.NewJust(v.Name) 172 | }). 173 | MapRight(func(image string) { 174 | from = maybe.NewJust(image) 175 | }) 176 | } 177 | 178 | instructions = append(instructions, dockerfile.Copy{ 179 | Src: c.Src, 180 | Dst: c.Dst, 181 | From: from, 182 | }) 183 | } 184 | 185 | //nolint:prealloc 186 | var mounts []dockerfile.Mount 187 | 188 | for _, cache := range stage.Cache { 189 | mounts = append(mounts, dockerfile.MountCache{ 190 | ID: maybe.NewJust(cache.ID), 191 | Target: cache.Path, 192 | }) 193 | } 194 | 195 | for _, secret := range stage.Secrets { 196 | mounts = append(mounts, dockerfile.MountSecret{ 197 | ID: maybe.NewJust(secret.ID), 198 | Target: maybe.NewJust(secret.MountPath), 199 | Required: maybe.NewJust(true), // make error if secret unavailable 200 | }) 201 | } 202 | 203 | if maybe.Valid(stage.SSH) { 204 | mounts = append(mounts, dockerfile.MountSSH{ 205 | Required: maybe.NewJust(true), // make error if ssh key unavailable 206 | }) 207 | } 208 | 209 | if maybe.Valid(stage.Command) { 210 | var network string 211 | if maybe.Valid(stage.Network) { 212 | network = maybe.Just(stage.Network).Network 213 | } 214 | 215 | command := generator.fillCommandWithVariables(maybe.Just(stage.Command)) 216 | 217 | command = generator.transformToHeredoc(command) 218 | 219 | instructions = append(instructions, dockerfile.Run{ 220 | Mounts: mounts, 221 | Network: network, 222 | Command: command, 223 | }) 224 | } 225 | 226 | return instructions, nil 227 | } 228 | 229 | func (generator targetGenerator) fillCommandWithVariables(command string) string { 230 | return os.Expand(command, func(v string) string { 231 | return generator.vars[v] 232 | }) 233 | } 234 | 235 | func (generator targetGenerator) transformToHeredoc(s string) string { 236 | const heredocHeader = "EOF" 237 | 238 | return fmt.Sprintf("<<%s\n%s\n%s", heredocHeader, s, heredocHeader) 239 | } 240 | -------------------------------------------------------------------------------- /internal/frontend/app/builddefinition/vertex.go: -------------------------------------------------------------------------------- 1 | package builddefinition 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | stdslices "golang.org/x/exp/slices" 6 | 7 | "github.com/ispringtech/brewkit/internal/backend/api" 8 | "github.com/ispringtech/brewkit/internal/common/either" 9 | "github.com/ispringtech/brewkit/internal/common/maps" 10 | "github.com/ispringtech/brewkit/internal/common/maybe" 11 | "github.com/ispringtech/brewkit/internal/common/slices" 12 | "github.com/ispringtech/brewkit/internal/frontend/app/buildconfig" 13 | "github.com/ispringtech/brewkit/internal/frontend/app/config" 14 | ) 15 | 16 | func newVertexGraphBuilder(secrets []config.Secret, targets []buildconfig.TargetData) *vertexGraphBuilder { 17 | return &vertexGraphBuilder{ 18 | visitedVertexes: map[string]api.Vertex{}, 19 | vertexesSet: maps.SetFromSlice(targets, func(t buildconfig.TargetData) string { 20 | return t.Name 21 | }), 22 | targetsMap: maps.FromSlice(targets, func(t buildconfig.TargetData) (string, buildconfig.TargetData) { 23 | return t.Name, t 24 | }), 25 | trace: trace{}, 26 | secrets: secrets, 27 | } 28 | } 29 | 30 | type vertexGraphBuilder struct { 31 | visitedVertexes map[string]api.Vertex // Set of visited vertexes 32 | vertexesSet maps.Set[string] 33 | targetsMap map[string]buildconfig.TargetData 34 | 35 | trace trace // Trace to detect cyclic graphs 36 | secrets []config.Secret 37 | } 38 | 39 | func (builder *vertexGraphBuilder) graphVertexes() ([]api.Vertex, error) { 40 | vertexes := make([]api.Vertex, 0, len(builder.vertexesSet)) 41 | for vertex := range builder.vertexesSet { 42 | v, err := builder.recursiveGraph(vertex) 43 | if err != nil { 44 | return nil, errors.Wrap(err, "graph solve error") 45 | } 46 | 47 | vertexes = append(vertexes, v) 48 | } 49 | 50 | return vertexes, nil 51 | } 52 | 53 | func (builder *vertexGraphBuilder) recursiveGraph(vertex string) (api.Vertex, error) { 54 | if v, found := builder.visitedVertexes[vertex]; found { 55 | return v, nil 56 | } 57 | 58 | if builder.trace.has(vertex) { 59 | return api.Vertex{}, errors.Errorf("recursive graph detected by '%s' target, trace: %s", vertex, builder.trace.String()) 60 | } 61 | 62 | t, ok := builder.targetsMap[vertex] 63 | if !ok { 64 | return api.Vertex{}, errors.Errorf("logic error: TargetData for Vertex %s not found", vertex) 65 | } 66 | 67 | var ( 68 | fromV maybe.Maybe[*api.Vertex] 69 | copyDirs []api.Copy 70 | dependsOn []api.Vertex 71 | ) 72 | 73 | //nolint:nestif 74 | if maybe.Valid(t.Stage) { 75 | stage := maybe.Just(t.Stage) 76 | 77 | // found means target 'From' set as another target 78 | if builder.vertexesSet.Has(stage.From) { 79 | v, err := builder.walkFrom(vertex, stage.From) 80 | if err != nil { 81 | return api.Vertex{}, err 82 | } 83 | 84 | fromV = maybe.NewJust(v) 85 | } 86 | 87 | if len(stage.Copy) != 0 { 88 | var err error 89 | copyDirs, err = builder.walkCopy(vertex, stage.Copy) 90 | if err != nil { 91 | return api.Vertex{}, err 92 | } 93 | } 94 | } 95 | 96 | if len(t.DependsOn) != 0 { 97 | var err error 98 | dependsOn, err = builder.walkDependsOn(vertex, t) 99 | if err != nil { 100 | return api.Vertex{}, err 101 | } 102 | } 103 | 104 | var stage maybe.Maybe[api.Stage] 105 | 106 | stage, err := maybe.MapErr(t.Stage, func(s buildconfig.StageData) (api.Stage, error) { 107 | return mapStage(t.Name, maybe.Just(t.Stage), copyDirs, builder.secrets) 108 | }) 109 | if err != nil { 110 | return api.Vertex{}, err 111 | } 112 | 113 | return api.Vertex{ 114 | Name: vertex, 115 | Stage: stage, 116 | From: fromV, 117 | DependsOn: dependsOn, 118 | }, nil 119 | } 120 | 121 | // solves 'from' dependencies 122 | func (builder *vertexGraphBuilder) walkFrom(vertexName, fromVName string) (*api.Vertex, error) { 123 | builder.trace.push(traceEntry{ 124 | name: vertexName, 125 | directive: from, 126 | }) 127 | defer builder.trace.pop() 128 | 129 | vertex, err := builder.recursiveGraph(fromVName) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | return &vertex, nil 135 | } 136 | 137 | // solves 'copy' dependencies 138 | func (builder *vertexGraphBuilder) walkCopy(vertexName string, copyDirs []buildconfig.Copy) ([]api.Copy, error) { 139 | builder.trace.push(traceEntry{ 140 | name: vertexName, 141 | directive: copyDirective, 142 | }) 143 | defer builder.trace.pop() 144 | 145 | return slices.MapErr(copyDirs, func(c buildconfig.Copy) (api.Copy, error) { 146 | if !maybe.Valid(c.From) { 147 | return api.Copy{ 148 | Src: c.Src, 149 | Dst: c.Dst, 150 | }, nil 151 | } 152 | 153 | copyFrom := maybe.Just(c.From) 154 | 155 | if !builder.vertexesSet.Has(copyFrom) { 156 | return api.Copy{ 157 | Src: c.Src, 158 | Dst: c.Dst, 159 | From: maybe.NewJust(either.NewRight[*api.Vertex, string](copyFrom)), 160 | }, nil 161 | } 162 | 163 | vertex, err := builder.recursiveGraph(copyFrom) 164 | if err != nil { 165 | return api.Copy{}, err 166 | } 167 | 168 | return api.Copy{ 169 | Src: c.Src, 170 | Dst: c.Dst, 171 | From: maybe.NewJust(either.NewLeft[*api.Vertex, string](&vertex)), 172 | }, nil 173 | }) 174 | } 175 | 176 | // solves 'dependsOn' dependencies 177 | func (builder *vertexGraphBuilder) walkDependsOn(vertexName string, t buildconfig.TargetData) ([]api.Vertex, error) { 178 | builder.trace.push(traceEntry{ 179 | name: vertexName, 180 | directive: deps, 181 | }) 182 | defer builder.trace.pop() 183 | 184 | return slices.MapErr(t.DependsOn, func(dependencyName string) (api.Vertex, error) { 185 | exists := builder.vertexesSet.Has(dependencyName) 186 | if !exists { 187 | return api.Vertex{}, errors.Errorf("%s depends on unknown target %s", vertexName, dependencyName) 188 | } 189 | 190 | return builder.recursiveGraph(dependencyName) 191 | }) 192 | } 193 | 194 | func mapStage( 195 | stageName string, 196 | s buildconfig.StageData, 197 | copyDirs []api.Copy, 198 | secrets []config.Secret, 199 | ) (api.Stage, error) { 200 | mappedSecrets, err := mapSecrets(s.Secrets, secrets) 201 | if err != nil { 202 | return api.Stage{}, errors.Wrapf(err, "failed to map secrets in %s stage", stageName) 203 | } 204 | 205 | return api.Stage{ 206 | From: s.From, 207 | Platform: maybe.Map(s.Platform, func(p string) string { 208 | return p 209 | }), 210 | WorkDir: s.WorkDir, 211 | Env: s.Env, 212 | Cache: slices.Map(s.Cache, mapCache), 213 | Copy: copyDirs, 214 | Network: maybe.Map(s.Network, func(n string) api.Network { 215 | return api.Network{ 216 | Network: n, 217 | } 218 | }), 219 | SSH: maybe.Map(s.SSH, func(s buildconfig.SSH) api.SSH { 220 | return api.SSH{} 221 | }), 222 | Secrets: mappedSecrets, 223 | Command: s.Command, 224 | Output: maybe.Map(s.Output, func(o buildconfig.Output) api.Output { 225 | return api.Output{ 226 | Artifact: o.Artifact, 227 | Local: o.Local, 228 | } 229 | }), 230 | }, nil 231 | } 232 | 233 | func mapCache(cache buildconfig.Cache) api.Cache { 234 | return api.Cache{ 235 | ID: cache.ID, 236 | Path: cache.Path, 237 | } 238 | } 239 | 240 | func mapCopy(c buildconfig.Copy) api.CopyVar { 241 | return api.CopyVar{ 242 | Src: c.Src, 243 | Dst: c.Dst, 244 | From: c.From, 245 | } 246 | } 247 | 248 | func mapSecrets(secrets []buildconfig.Secret, secretSrc []config.Secret) ([]api.Secret, error) { 249 | return slices.MapErr(secrets, func(s buildconfig.Secret) (api.Secret, error) { 250 | return mapSecret(s, secretSrc) 251 | }) 252 | } 253 | 254 | func mapSecret(secret buildconfig.Secret, secrets []config.Secret) (api.Secret, error) { 255 | found := stdslices.ContainsFunc(secrets, func(s config.Secret) bool { 256 | return s.ID == secret.ID 257 | }) 258 | if !found { 259 | return api.Secret{}, errors.Errorf("reference to unknown secret %s", secret.ID) 260 | } 261 | return api.Secret{ 262 | ID: secret.ID, 263 | MountPath: secret.Path, 264 | }, nil 265 | } 266 | -------------------------------------------------------------------------------- /docs/build-definition/reference.md: -------------------------------------------------------------------------------- 1 | # BrewKit build-definition reference 2 | 3 | BrewKit build-definition is a JSON schema. 4 | 5 | Full build-definition described in [brewkit/v1](/data/specification/build-definition/v1.json) JSON schema 6 | 7 | ## apiVersion 8 | 9 | API version of build definition. Declared for backward compatibility 10 | 11 | ## Vars 12 | 13 | Build-time variables that calculates in build time 14 | 15 | `Vars` supports following directives: 16 | * [from](#from) 17 | * [platform](#platform) 18 | * [workdir](#workdir) 19 | * [env](#env) 20 | * [cache](#cache) 21 | * [copy](#copy) 22 | * [secrets](#secrets) 23 | * [network](#network) 24 | * [ssh](#ssh) 25 | * [command](#command) 26 | 27 | ## Target 28 | 29 | Executable build targets 30 | 31 | `Targets` supports following directives: 32 | * [from](#from) 33 | * [dependsOn](#dependsOn) 34 | * [platform](#platform) 35 | * [workdir](#workdir) 36 | * [env](#env) 37 | * [cache](#cache) 38 | * [copy](#copy) 39 | * [secrets](#secrets) 40 | * [network](#network) 41 | * [ssh](#ssh) 42 | * [command](#command) 43 | * [output](#output) 44 | 45 | ### Composite targets 46 | 47 | You can define target, that compose other targets as dependencies: 48 | 49 | ```jsonnet 50 | targets: { 51 | // when runs build - gobuild and golint will run sequentially 52 | build: ['gobuild', 'golint'], 53 | 54 | gobuild: {}, 55 | golint: {}, 56 | } 57 | ``` 58 | 59 | ## Directives 60 | 61 | ### From 62 | 63 | Defines base target or image for target. 64 | 65 | Use image: 66 | ```jsonnet 67 | targets: { 68 | gobuild: { 69 | // use as base go image 70 | from: "golang:1.21.1" 71 | }, 72 | } 73 | ``` 74 | 75 | Use target: 76 | ```jsonnet 77 | targets: { 78 | gocompiler: { 79 | from: "golang:1.21.1", 80 | // .. 81 | } 82 | 83 | gobuild: { 84 | // use as base gocompiler target 85 | from: "gocompiler" 86 | }, 87 | } 88 | ``` 89 | 90 | ### DependsOn 91 | 92 | Target may require to subsequently run another target before the execution. 93 | So, define `dependsOn` to arrange targets execution 94 | 95 | ```jsonnet 96 | targets: { 97 | gogenerate: {} 98 | 99 | // Before running gobuild brewkit will run gogenerate 100 | gobuild: { 101 | dependsOn: ['gogenerate'] 102 | } 103 | } 104 | ``` 105 | 106 | ### Platform 107 | 108 | Define platform for target. Supported platforms that supported by buildkit. 109 | 110 | Underlying used buildkit platform directive - [buildkit-docs](https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#from) 111 | 112 | ```jsonnet 113 | targets: { 114 | gobuild: { 115 | from: "golang:1.21.1", 116 | platform: "linux/amd64" 117 | }, 118 | } 119 | ``` 120 | 121 | ### Workdir 122 | 123 | Working directory for target or var 124 | 125 | 126 | ```jsonnet 127 | targets: { 128 | gobuild: { 129 | from: "golang:1.21.1", 130 | platform: "linux/amd64" 131 | }, 132 | } 133 | ``` 134 | 135 | ### Env 136 | 137 | Describes env for target or var in JSON map format 138 | 139 | ```jsonnet 140 | targets: { 141 | gobuild: { 142 | from: "golang:1.21.1", 143 | env: { 144 | GOCACHE: "/app/cache/go-build", 145 | APP_ID: "contentservice" 146 | }, 147 | }, 148 | } 149 | ``` 150 | 151 | ### Cache 152 | 153 | Describes buildkit [cache](https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache). 154 | 155 | Cache can be reused between builds. 156 | 157 | **Cache directive do not affect on container layer** 158 | 159 | You can define cache in one-liner via [cache jsonnet extension](jsonnet-extensions.md#cache) 160 | 161 | Define cache 162 | ```jsonnet 163 | // import cache for single line cache declaration 164 | local cache = std.native('cache'); 165 | //... 166 | targets: { 167 | gobuild: { 168 | // set up cache with id go-build and path /app/cache in container 169 | cache: cache("go-build", "/app/cache"), 170 | }, 171 | } 172 | ``` 173 | 174 | You can define multiple caches 175 | ```jsonnet 176 | local cache = std.native('cache'); 177 | //... 178 | targets: { 179 | gobuild: { 180 | cache: [ 181 | cache("go-build", "/app/cache"), 182 | cache("go-mod", "/go/pkg/mod"), 183 | ] 184 | }, 185 | } 186 | ``` 187 | 188 | ### Copy 189 | 190 | Copy files from host fs into container fs 191 | 192 | You can define cache in one-liner via [copy jsonnet extension](jsonnet-extensions.md#copy) 193 | 194 | ```jsonnet 195 | // import copy for single line copy declaration 196 | local copy = std.native('copy'); 197 | //... 198 | targets: { 199 | gobuild: { 200 | copy: [ 201 | copy('cmd', 'cmd'), 202 | copy('pkg', 'pkg') 203 | ], 204 | }, 205 | } 206 | ``` 207 | 208 | ### Secrets 209 | 210 | Use file as secret in container without copying it into container. 211 | **So file will not be copied into container layer** 212 | 213 | To use secret in brewkit target you should define **secret in brewkit config**. See [config overview](/docs/config/overview.md) 214 | 215 | You can define cache in one-liner via [copy jsonnet extension](jsonnet-extensions.md#secret) 216 | 217 | ```jsonnet 218 | // import copy for single line copy declaration 219 | local secret = std.native('secret'); 220 | //... 221 | targets: { 222 | gobuild: { 223 | // secret with id sould be already defined id brewkit config 224 | secret: secret("aws", "/root/.aws/credentials"), 225 | }, 226 | } 227 | ``` 228 | 229 | Define secret in `~/.brewkit/config` 230 | ```jsonnet 231 | { 232 | "secrets": [ 233 | { 234 | "id": "aws", 235 | // path may contain env variables 236 | "path": "${HOME}/.aws/credentials" 237 | }, 238 | ] 239 | } 240 | ``` 241 | 242 | ### SSH 243 | 244 | Defines access to ssh socket from host. BrewKit mounts ssg agent from `$SSH_AUTH_SOCK` into container via buildkit [ssh mount](https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypessh) 245 | 246 | Ssh defined for now as empty object for further customization 247 | ```jsonnet 248 | targets: { 249 | gomod: { 250 | ssh: {}, 251 | }, 252 | } 253 | ``` 254 | 255 | ### Network 256 | 257 | Define network for target. Supported all network which supported by docker 258 | 259 | ### Command 260 | 261 | Command to be run in stage. Command runs **in container shell**, **not in exec** 262 | 263 | ```jsonnet 264 | targets: { 265 | gobuild: { 266 | command: 'go build -o ./bin/brewkit ./cmd/brewkit', 267 | }, 268 | } 269 | ``` 270 | 271 | Vars values can be used in commands 272 | 273 | ```jsonnet 274 | vars: { 275 | gitcommit: {} 276 | } 277 | 278 | targets: { 279 | gobuild: { 280 | // pass gitcommit to go ldflags 281 | command: 'go build -ldflags "-X main.Commit=${gitcommit}" -o ./bin/brewkit ./cmd/brewkit', 282 | }, 283 | } 284 | ``` 285 | 286 | ### Output 287 | 288 | Output artifacts from targets. Artifacts exported with current user id, **so no root owned artifacts** 289 | 290 | ```jsonnet 291 | targets: { 292 | gobuild: { 293 | command: 'go build -o ./bin/brewkit ./cmd/brewkit', 294 | output: { 295 | // export /app/bin/brewkit from container 296 | artifact: "/app/bin/brewkit", 297 | // export to ./bin folder 298 | "local": "./bin" 299 | } 300 | }, 301 | } 302 | ``` -------------------------------------------------------------------------------- /internal/backend/app/build/service.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/ispringtech/brewkit/internal/backend/api" 11 | "github.com/ispringtech/brewkit/internal/backend/app/docker" 12 | "github.com/ispringtech/brewkit/internal/backend/app/dockerfile" 13 | "github.com/ispringtech/brewkit/internal/backend/app/reporter" 14 | "github.com/ispringtech/brewkit/internal/backend/app/ssh" 15 | "github.com/ispringtech/brewkit/internal/common/maps" 16 | "github.com/ispringtech/brewkit/internal/common/maybe" 17 | "github.com/ispringtech/brewkit/internal/common/slices" 18 | df "github.com/ispringtech/brewkit/internal/dockerfile" 19 | ) 20 | 21 | type Service interface { 22 | api.BuilderAPI 23 | } 24 | 25 | func NewBuildService( 26 | dockerClient docker.Client, 27 | dockerfileImage string, 28 | sshAgentProvider ssh.AgentProvider, 29 | backendReporter reporter.Reporter, 30 | ) Service { 31 | return &buildService{ 32 | dockerClient: dockerClient, 33 | dockerfileImage: dockerfileImage, 34 | sshAgentProvider: sshAgentProvider, 35 | reporter: backendReporter, 36 | } 37 | } 38 | 39 | type buildService struct { 40 | dockerClient docker.Client 41 | dockerfileImage string 42 | sshAgentProvider ssh.AgentProvider 43 | reporter reporter.Reporter 44 | } 45 | 46 | func (service *buildService) Build( 47 | ctx context.Context, 48 | v api.Vertex, 49 | vars []api.Var, 50 | secretsSrc []api.SecretSrc, 51 | params api.BuildParams, 52 | ) error { 53 | err := service.prePullImages(ctx, v, vars, params.ForcePull) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | varsMap, err := service.calculateVars(ctx, vars) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return service.buildVertex(ctx, v, varsMap, secretsSrc) 64 | } 65 | 66 | func (service *buildService) calculateVars(ctx context.Context, vars []api.Var) (dockerfile.Vars, error) { 67 | if len(vars) == 0 { 68 | return nil, nil 69 | } 70 | 71 | d, err := dockerfile.NewVarGenerator(service.dockerfileImage).GenerateDockerfile(vars) 72 | if err != nil { 73 | return nil, errors.Wrap(err, "failed to generate dockerfile for variables") 74 | } 75 | 76 | res := map[string]string{} 77 | 78 | for _, v := range vars { 79 | // Check if context closed before running Value 80 | select { 81 | case <-ctx.Done(): 82 | return nil, ctx.Err() 83 | default: 84 | } 85 | 86 | data, err2 := service.dockerClient.Value(ctx, d, docker.ValueParams{ 87 | Var: v.Name, 88 | SSHAgent: maybe.NewJust(service.sshAgentProvider.Default()), 89 | UseCache: false, // Disable cache for retrieving variable value 90 | }) 91 | if err2 != nil { 92 | return nil, errors.Wrapf(err2, "failed to calculate %s var", v.Name) 93 | } 94 | 95 | res[v.Name] = string(data) 96 | } 97 | 98 | return res, nil 99 | } 100 | 101 | func (service *buildService) buildVertex( 102 | ctx context.Context, 103 | v api.Vertex, 104 | vars dockerfile.Vars, 105 | secretsSrc []api.SecretSrc, 106 | ) error { 107 | d, err := dockerfile.NewTargetGenerator(v, vars, service.dockerfileImage).GenerateDockerfile() 108 | if err != nil { 109 | return err 110 | } 111 | 112 | service.reporter.Debugf("dockerfile:\n%s\n", d.Format()) 113 | 114 | executedVertexes := maps.Set[string]{} 115 | 116 | secrets := slices.Map(secretsSrc, func(s api.SecretSrc) docker.SecretData { 117 | return docker.SecretData{ 118 | ID: s.ID, 119 | Path: s.SourcePath, 120 | } 121 | }) 122 | 123 | var recursiveBuild func(ctx context.Context, v api.Vertex) error 124 | recursiveBuild = func(ctx context.Context, v api.Vertex) error { 125 | if executedVertexes.Has(v.Name) { 126 | // Skip already executed stages 127 | return nil 128 | } 129 | 130 | if maybe.Valid(v.From) && shouldExplicitRunFrom(*maybe.Just(v.From)) { 131 | err2 := recursiveBuild(ctx, *maybe.Just(v.From)) 132 | if err2 != nil { 133 | return err2 134 | } 135 | } 136 | 137 | for _, childVertex := range v.DependsOn { 138 | err2 := recursiveBuild(ctx, childVertex) 139 | if err2 != nil { 140 | return err2 141 | } 142 | } 143 | 144 | if !maybe.Valid(v.Stage) { 145 | return nil 146 | } 147 | 148 | executedVertexes.Add(v.Name) 149 | 150 | targetName := v.Name 151 | var output maybe.Maybe[string] 152 | 153 | stage := maybe.Just(v.Stage) 154 | if maybe.Valid(stage.Output) { 155 | o := maybe.Just(stage.Output) 156 | 157 | // Execute output stage to save artifacts 158 | targetName = fmt.Sprintf("%s-out", v.Name) 159 | output = maybe.NewJust(o.Local) 160 | } 161 | 162 | // Check if context closed before running build 163 | select { 164 | case <-ctx.Done(): 165 | return ctx.Err() 166 | default: 167 | } 168 | 169 | return service.dockerClient.Build(ctx, d, docker.BuildParams{ 170 | Target: targetName, 171 | SSHAgent: maybe.NewJust(service.sshAgentProvider.Default()), 172 | Output: output, 173 | Secrets: secrets, 174 | }) 175 | } 176 | 177 | return recursiveBuild(ctx, v) 178 | } 179 | 180 | func (service *buildService) prePullImages( 181 | ctx context.Context, 182 | v api.Vertex, 183 | vars []api.Var, 184 | forcePull bool, 185 | ) error { 186 | images := maps.Set[string]{} 187 | images.Add(service.dockerfileImage) 188 | 189 | images = service.listVertexImages(v, images) 190 | images = service.listVarsImages(vars, images) 191 | 192 | if forcePull { 193 | service.reporter.Logf("Force pull images\n") 194 | for image := range images { 195 | err2 := service.dockerClient.PullImage(ctx, image) 196 | if err2 != nil { 197 | return err2 198 | } 199 | } 200 | return nil 201 | } 202 | 203 | imagesSlice := maps.ToSlice(images, func(image string, _ struct{}) string { 204 | return image 205 | }) 206 | existingImages, err := service.dockerClient.ListImages(ctx, imagesSlice) 207 | if err != nil { 208 | return errors.Wrap(err, "failed to filter existing images") 209 | } 210 | 211 | imagesToPull := slices.Diff(imagesSlice, slices.Map(existingImages, func(img docker.Image) string { 212 | return img.Img 213 | })) 214 | 215 | if len(imagesToPull) == 0 { 216 | return nil 217 | } 218 | 219 | service.reporter.Logf("Absent images: %s\n", strings.Join(imagesToPull, " ")) 220 | for _, image := range imagesToPull { 221 | err2 := service.dockerClient.PullImage(ctx, image) 222 | if err2 != nil { 223 | return err2 224 | } 225 | } 226 | 227 | return nil 228 | } 229 | 230 | func (service *buildService) listVertexImages(v api.Vertex, images maps.Set[string]) maps.Set[string] { 231 | // Recursive walk to From stage 232 | if maybe.Valid(v.From) { 233 | images = service.listVertexImages(*maybe.Just(v.From), images) 234 | } 235 | 236 | // Pull 'From' image 237 | if !maybe.Valid(v.From) && maybe.Valid(v.Stage) { 238 | image := maybe.Just(v.Stage).From 239 | // There is no need to pull scratch image 240 | if image != df.Scratch && !images.Has(image) { 241 | images.Add(image) 242 | } 243 | } 244 | 245 | if maybe.Valid(v.Stage) { 246 | copyDirs := maybe.Just(v.Stage).Copy 247 | 248 | for _, c := range copyDirs { 249 | if !maybe.Valid(c.From) { 250 | // Skip vertexes with empty from 251 | continue 252 | } 253 | 254 | maybe.Just(c.From). 255 | MapLeft(func(copyV *api.Vertex) { 256 | images = service.listVertexImages(*copyV, images) 257 | }). 258 | MapRight(func(image string) { 259 | if image != df.Scratch && !images.Has(image) { 260 | images.Add(image) 261 | } 262 | }) 263 | } 264 | } 265 | 266 | for _, childVertex := range v.DependsOn { 267 | images = service.listVertexImages(childVertex, images) 268 | } 269 | 270 | return images 271 | } 272 | 273 | func (service *buildService) listVarsImages(vars []api.Var, images maps.Set[string]) maps.Set[string] { 274 | for _, v := range vars { 275 | image := v.From 276 | if !images.Has(image) { 277 | images.Add(image) 278 | } 279 | } 280 | 281 | return images 282 | } 283 | 284 | func shouldExplicitRunFrom(v api.Vertex) bool { 285 | var hasOutput bool 286 | if maybe.Valid(v.Stage) { 287 | hasOutput = maybe.Valid(maybe.Just(v.Stage).Output) 288 | } 289 | 290 | hasDependsOn := len(v.DependsOn) > 0 291 | 292 | return hasOutput || hasDependsOn 293 | } 294 | -------------------------------------------------------------------------------- /data/specification/build-definition/v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "BrewKit build definition", 3 | "type": "object", 4 | "properties": { 5 | "apiVersion": { 6 | "description": "Api version of build definition", 7 | "type": "string", 8 | "enum": [ 9 | "brewkit/v1" 10 | ] 11 | }, 12 | "vars": { 13 | "description": "Runtime variables", 14 | "type": "object", 15 | "additionalProperties": { 16 | "$ref": "#/$defs/var" 17 | } 18 | }, 19 | "targets": { 20 | "description": "Build targets", 21 | "type": "object", 22 | "additionalProperties": { 23 | "$ref": "#/$defs/target" 24 | } 25 | } 26 | }, 27 | "required": [ "apiVersion", "targets" ], 28 | "$defs": { 29 | "var": { 30 | "type": "object", 31 | "properties": { 32 | "from": { 33 | "$ref": "#/$defs/components/from" 34 | }, 35 | "platform": { 36 | "$ref": "#/$defs/components/platform" 37 | }, 38 | "workdir": { 39 | "$ref": "#/$defs/components/workdir" 40 | }, 41 | "env": { 42 | "$ref": "#/$defs/components/env" 43 | }, 44 | "cache": { 45 | "$ref": "#/$defs/components/caches" 46 | }, 47 | "copy": { 48 | "$ref": "#/$defs/components/copies" 49 | }, 50 | "secrets": { 51 | "$ref": "#/$defs/components/secrets" 52 | }, 53 | "network": { 54 | "$ref": "#/$defs/components/network" 55 | }, 56 | "ssh": { 57 | "$ref": "#/$defs/components/ssh" 58 | }, 59 | "command": { 60 | "$ref": "#/$defs/components/command" 61 | } 62 | }, 63 | "required": [ 64 | "from", 65 | "workdir", 66 | "command" 67 | ] 68 | }, 69 | "target": { 70 | "description": "One of definition allows to describe compound target in simple way", 71 | "type": "object", 72 | "oneOf": [ 73 | { 74 | "$ref": "#/$defs/targetOnlyWithDependsOn" 75 | }, 76 | { 77 | "$ref": "#/$defs/targetWithStage" 78 | } 79 | ] 80 | }, 81 | 82 | "targetOnlyWithDependsOn": { 83 | "type": "array", 84 | "items": { 85 | "type": "string" 86 | } 87 | }, 88 | 89 | "targetWithStage": { 90 | "type": "object", 91 | "properties": { 92 | "from": { 93 | "$ref": "#/$defs/components/from" 94 | }, 95 | "dependsOn": { 96 | "$ref": "#/$defs/components/dependsOn" 97 | }, 98 | "platform": { 99 | "$ref": "#/$defs/components/platform" 100 | }, 101 | "workdir": { 102 | "$ref": "#/$defs/components/workdir" 103 | }, 104 | "env": { 105 | "$ref": "#/$defs/components/env" 106 | }, 107 | "cache": { 108 | "$ref": "#/$defs/components/caches" 109 | }, 110 | "copy": { 111 | "$ref": "#/$defs/components/copies" 112 | }, 113 | "secrets": { 114 | "$ref": "#/$defs/components/secrets" 115 | }, 116 | "network": { 117 | "$ref": "#/$defs/components/network" 118 | }, 119 | "ssh": { 120 | "$ref": "#/$defs/components/ssh" 121 | }, 122 | "command": { 123 | "$ref": "#/$defs/components/command" 124 | }, 125 | "output": { 126 | "$ref": "#/$defs/components/output" 127 | } 128 | }, 129 | "required": [ 130 | "from", 131 | "workdir" 132 | ] 133 | }, 134 | 135 | "components": { 136 | "dependsOn": { 137 | "description": "Other targets on which the current one depends", 138 | "type": "array", 139 | "items": { 140 | "type": "string" 141 | } 142 | }, 143 | "from": { 144 | "type": "string" 145 | }, 146 | "platform": { 147 | "description": "platform for multi-arch builds", 148 | "type": "string" 149 | }, 150 | "workdir": { 151 | "description": "Working directory for current stage", 152 | "type": "string" 153 | }, 154 | "env": { 155 | "description": "Env for current stage", 156 | "type": "object", 157 | "additionalProperties": { 158 | "type": "string" 159 | } 160 | }, 161 | "cache": { 162 | "type": "object", 163 | "properties": { 164 | "id": { 165 | "description": "Unique cache ID", 166 | "type": "string" 167 | }, 168 | "path": { 169 | "description": "Path for cache in container", 170 | "type": "string" 171 | } 172 | }, 173 | "required": [ "id", "path" ] 174 | }, 175 | "caches": { 176 | "type": "object", 177 | "oneOf": [ 178 | { 179 | "type": "array", 180 | "items": { 181 | "$ref": "#/$defs/components/cache" 182 | } 183 | }, 184 | { 185 | "$ref": "#/$defs/components/cache" 186 | } 187 | ] 188 | }, 189 | "copy": { 190 | "type": "object", 191 | "properties": { 192 | "from": { 193 | "description": "Copy from others targets", 194 | "type": "string" 195 | }, 196 | "src": { 197 | "description": "Source on host or other target", 198 | "type": "string" 199 | }, 200 | "dst": { 201 | "description": "Destination in container", 202 | "type": "string" 203 | } 204 | }, 205 | "required": [ "src", "dst" ] 206 | }, 207 | "copies": { 208 | "type": "object", 209 | "oneOf": [ 210 | { 211 | "type": "array", 212 | "items": { 213 | "$ref": "#/$defs/components/copy" 214 | } 215 | }, 216 | { 217 | "$ref": "#/$defs/components/copy" 218 | } 219 | ] 220 | }, 221 | "secret": { 222 | "type": "object", 223 | "properties": { 224 | "id": { 225 | "description": "Unique secret id", 226 | "type": "string" 227 | }, 228 | "path": { 229 | "description": "Secret mount path in container", 230 | "type": "string" 231 | } 232 | }, 233 | "required": [ "id", "path" ] 234 | }, 235 | "secrets": { 236 | "type": "object", 237 | "oneOf": [ 238 | { 239 | "type": "array", 240 | "items": { 241 | "$ref": "#/$defs/components/secret" 242 | } 243 | }, 244 | { 245 | "$ref": "#/$defs/components/secret" 246 | } 247 | ] 248 | }, 249 | "ssh": { 250 | "type": "object" 251 | }, 252 | "network": { 253 | "description": "Network for container", 254 | "type": "string", 255 | "enum": [ 256 | "default", 257 | "none", 258 | "host" 259 | ] 260 | }, 261 | "command": { 262 | "description": "Command that should be run in shell", 263 | "type": "string" 264 | }, 265 | "output": { 266 | "description": "Definition of stage output", 267 | "type": "object", 268 | "properties": { 269 | "artifact": { 270 | "description": "Path to artifact in container", 271 | "type": "string" 272 | }, 273 | "local": { 274 | "description": "Path on host to save artifact", 275 | "type": "string" 276 | } 277 | }, 278 | "required": [ "artifact", "local" ] 279 | } 280 | } 281 | } 282 | } --------------------------------------------------------------------------------