config: add starlark config support

Handle `.agola/config.star` files in starlark config format.
To provide a context like done for jsonnet we require that the starlark agola
config file contains a main function that will receive a config context as a
dict.
We also had to implement our own json conversion from a starlark dict since go
starlark removed its own function.
This commit is contained in:
Simone Gotti 2020-03-04 13:08:56 +01:00
parent 714e561c75
commit d91ec09d7d
8 changed files with 479 additions and 108 deletions

1
go.mod
View File

@ -32,6 +32,7 @@ require (
github.com/spf13/cobra v0.0.5
github.com/xanzy/go-gitlab v0.26.0
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738
go.starlark.net v0.0.0-20200203144150-6677ee5c7211
go.uber.org/zap v1.13.0
golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d

6
go.sum
View File

@ -59,6 +59,9 @@ github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb
github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bmatcuk/doublestar v1.2.2 h1:oC24CykoSAB8zd7XgruHo33E0cHJf/WhQA/7BeXj+x0=
github.com/bmatcuk/doublestar v1.2.2/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
@ -429,6 +432,8 @@ go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738 h1:VcrIfasaLFkyjk6KNlXQSzO+B0
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.starlark.net v0.0.0-20200203144150-6677ee5c7211 h1:Qoe+9POtDT51UBQ8XEnS9QKeHDQzEl2QRh3eok9R4aw=
go.starlark.net v0.0.0-20200203144150-6677ee5c7211/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
@ -528,6 +533,7 @@ golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191002063906-3421d5a6bb1c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -25,7 +25,6 @@ import (
"agola.io/agola/services/types"
"github.com/ghodss/yaml"
"github.com/google/go-jsonnet"
errors "golang.org/x/xerrors"
"k8s.io/apimachinery/pkg/api/resource"
)
@ -44,6 +43,7 @@ const (
// ConfigFormatJSON handles both json or yaml format (since json is a subset of yaml)
ConfigFormatJSON ConfigFormat = iota
ConfigFormatJsonnet
ConfigFormatStarlark
)
var (
@ -660,19 +660,19 @@ type ConfigContext struct {
func ParseConfig(configData []byte, format ConfigFormat, configContext *ConfigContext) (*Config, error) {
// Generate json from jsonnet
if format == ConfigFormatJsonnet {
// TODO(sgotti) support custom import files inside the configdir ???
vm := jsonnet.MakeVM()
cj, err := json.Marshal(configContext)
switch format {
case ConfigFormatJsonnet:
var err error
configData, err = execJsonnet(configData, configContext)
if err != nil {
return nil, errors.Errorf("failed to marshal config context: %w", err)
return nil, errors.Errorf("failed to execute jsonnet: %w", err)
}
vm.TLACode("ctx", string(cj))
out, err := vm.EvaluateSnippet("", string(configData))
case ConfigFormatStarlark:
var err error
configData, err = execStarlark(configData, configContext)
if err != nil {
return nil, errors.Errorf("failed to evaluate jsonnet config: %w", err)
return nil, errors.Errorf("failed to execute starlark: %w", err)
}
configData = []byte(out)
}
config := DefaultConfig

View File

@ -0,0 +1,38 @@
// Copyright 2020 Sorint.lab
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"encoding/json"
"github.com/google/go-jsonnet"
errors "golang.org/x/xerrors"
)
func execJsonnet(configData []byte, configContext *ConfigContext) ([]byte, error) {
vm := jsonnet.MakeVM()
cj, err := json.Marshal(configContext)
if err != nil {
return nil, errors.Errorf("failed to marshal config context: %w", err)
}
vm.TLACode("ctx", string(cj))
out, err := vm.EvaluateSnippet("", string(configData))
if err != nil {
return nil, errors.Errorf("failed to evaluate jsonnet config: %w", err)
}
return []byte(out), nil
}

160
internal/config/starlark.go Normal file
View File

@ -0,0 +1,160 @@
// Copyright 2020 Sorint.lab
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"bytes"
"encoding/json"
"fmt"
"go.starlark.net/starlark"
errors "golang.org/x/xerrors"
)
func starlarkArgs(cc *ConfigContext) (starlark.Tuple, error) {
d := &starlark.Dict{}
if err := d.SetKey(starlark.String("ref_type"), starlark.String(cc.RefType)); err != nil {
return nil, err
}
if err := d.SetKey(starlark.String("ref"), starlark.String(cc.Ref)); err != nil {
return nil, err
}
if err := d.SetKey(starlark.String("branch"), starlark.String(cc.Branch)); err != nil {
return nil, err
}
if err := d.SetKey(starlark.String("tag"), starlark.String(cc.Tag)); err != nil {
return nil, err
}
if err := d.SetKey(starlark.String("pull_request_id"), starlark.String(cc.PullRequestID)); err != nil {
return nil, err
}
if err := d.SetKey(starlark.String("commit_sha"), starlark.String(cc.CommitSHA)); err != nil {
return nil, err
}
return []starlark.Value{d}, nil
}
// based on (not existing anymore) function provided in
// https://github.com/google/starlark-go/blob/6fffce7528ee0fce17d72a4abe2919f464225968/starlarkstruct/struct.go#L325
// with changes to use go json marshalling functions
func starlarkJSON(out *bytes.Buffer, v starlark.Value) error {
switch v := v.(type) {
case starlark.NoneType:
out.WriteString("null")
case starlark.Bool:
fmt.Fprintf(out, "%t", v)
case starlark.Int:
data, err := json.Marshal(v.BigInt())
if err != nil {
return err
}
out.Write(data)
case starlark.Float:
data, err := json.Marshal(float64(v))
if err != nil {
return err
}
out.Write(data)
case starlark.String:
// we have to use a json Encoder to disable noisy html
// escaping. But the encoder appends a final \n so we
// also should remove it.
data := &bytes.Buffer{}
e := json.NewEncoder(data)
e.SetEscapeHTML(false)
if err := e.Encode(string(v)); err != nil {
return err
}
// remove final \n introduced by the encoder
out.Write(bytes.TrimSuffix(data.Bytes(), []byte("\n")))
case starlark.Indexable: // Tuple, List
out.WriteByte('[')
for i, n := 0, starlark.Len(v); i < n; i++ {
if i > 0 {
out.WriteString(", ")
}
if err := starlarkJSON(out, v.Index(i)); err != nil {
return err
}
}
out.WriteByte(']')
case *starlark.Dict:
out.WriteByte('{')
for i, item := range v.Items() {
if i > 0 {
out.WriteString(", ")
}
if _, ok := item[0].(starlark.String); !ok {
return fmt.Errorf("cannot convert non-string dict key to JSON")
}
if err := starlarkJSON(out, item[0]); err != nil {
return err
}
out.WriteString(": ")
if err := starlarkJSON(out, item[1]); err != nil {
return err
}
}
out.WriteByte('}')
default:
return fmt.Errorf("cannot convert starlark type %q to JSON", v.Type())
}
return nil
}
func execStarlark(configData []byte, configContext *ConfigContext) ([]byte, error) {
thread := &starlark.Thread{
Name: "agola-starlark",
// TODO(sgotti) redirect print to a logger?
Print: func(_ *starlark.Thread, msg string) {},
}
globals, err := starlark.ExecFile(thread, "config.star", configData, nil)
if err != nil {
return nil, err
}
// we require a main function that will be called wiht one
// arguments containing the config context
mainVal, ok := globals["main"]
if !ok {
return nil, errors.Errorf("no main function in starlark config")
}
main, ok := mainVal.(starlark.Callable)
if !ok {
return nil, errors.Errorf("main in starlark config is not a function")
}
args, err := starlarkArgs(configContext)
if err != nil {
return nil, errors.Errorf("cannot create startlark arguments: %w", err)
}
mainVal, err = starlark.Call(thread, main, args, nil)
if err != nil {
return nil, err
}
buf := new(bytes.Buffer)
switch v := mainVal.(type) {
case *starlark.Dict:
if err := starlarkJSON(buf, v); err != nil {
return nil, err
}
default:
return nil, errors.Errorf("wrong starlark output, must be a dict")
}
return buf.Bytes(), nil
}

View File

@ -0,0 +1,101 @@
// Copyright 2020 Sorint.lab
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"bytes"
"fmt"
"math"
"testing"
"github.com/google/go-cmp/cmp"
"go.starlark.net/starlark"
)
func TestStarlarkJSON(t *testing.T) {
tests := []struct {
name string
in starlark.Value
out string
err error
}{
{
name: "test key as a string",
in: func() starlark.Value {
s := &starlark.Dict{}
_ = s.SetKey(starlark.String("key"), starlark.String("string01"))
return starlark.Value(s)
}(),
out: `{"key": "string01"}`,
},
{
name: "test key not a string",
in: func() starlark.Value {
s := &starlark.Dict{}
_ = s.SetKey(starlark.MakeInt(10), starlark.String("string01"))
return starlark.Value(s)
}(),
err: fmt.Errorf("cannot convert non-string dict key to JSON"),
},
{
name: "test list",
in: func() starlark.Value {
l := []starlark.Value{
starlark.String("\ns\ttring01"),
starlark.MakeInt(10),
starlark.Bool(true),
starlark.Float(math.MaxFloat64),
func() starlark.Value {
s := &starlark.Dict{}
_ = s.SetKey(starlark.String("key"), starlark.String("string01"))
return starlark.Value(s)
}(),
}
return starlark.NewList(l)
}(),
out: `["\ns\ttring01", 10, true, 1.7976931348623157e+308, {"key": "string01"}]`,
},
{
name: "test string special chars",
in: starlark.String("\ns\ttring01"),
out: `"\ns\ttring01"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := new(bytes.Buffer)
if err := starlarkJSON(out, tt.in); err != nil {
if tt.err == nil {
t.Fatalf("got error: %v, expected no error", err)
}
if err.Error() != tt.err.Error() {
t.Fatalf("got error: %v, want error: %v", err, tt.err)
}
} else {
if tt.err != nil {
t.Fatalf("got nil error, want error: %v", tt.err)
}
}
if tt.err == nil {
if diff := cmp.Diff(tt.out, out.String()); diff != "" {
t.Fatalf(diff)
}
}
})
}
}

View File

@ -39,6 +39,7 @@ const (
defaultSSHPort = "22"
agolaDefaultConfigDir = ".agola"
agolaDefaultStarlarkConfigFile = "config.star"
agolaDefaultJsonnetConfigFile = "config.jsonnet"
agolaDefaultJsonConfigFile = "config.json"
agolaDefaultYamlConfigFile = "config.yml"
@ -489,6 +490,8 @@ func (h *ActionHandler) CreateRuns(ctx context.Context, req *CreateRunRequest) e
var configFormat config.ConfigFormat
switch path.Ext(filename) {
case ".star":
configFormat = config.ConfigFormatStarlark
case ".jsonnet":
configFormat = config.ConfigFormatJsonnet
case ".json":
@ -566,7 +569,7 @@ func (h *ActionHandler) fetchConfigFiles(ctx context.Context, gitSource gitsourc
var data []byte
var filename string
err := util.ExponentialBackoff(ctx, util.FetchFileBackoff, func() (bool, error) {
for _, filename = range []string{agolaDefaultJsonnetConfigFile, agolaDefaultJsonConfigFile, agolaDefaultYamlConfigFile} {
for _, filename = range []string{agolaDefaultStarlarkConfigFile, agolaDefaultJsonnetConfigFile, agolaDefaultJsonConfigFile, agolaDefaultYamlConfigFile} {
var err error
data, err = gitSource.GetFile(repopath, commitSHA, path.Join(agolaDefaultConfigDir, filename))
if err == nil {

View File

@ -64,6 +64,15 @@ const (
agolaUser01 = "user01"
)
type ConfigFormat string
const (
// ConfigFormatJSON handles both json or yaml format (since json is a subset of yaml)
ConfigFormatJSON ConfigFormat = "json"
ConfigFormatJsonnet ConfigFormat = "jsonnet"
ConfigFormatStarlark ConfigFormat = "starlark"
)
func setupEtcd(t *testing.T, logger *zap.Logger, dir string) *testutil.TestEmbeddedEtcd {
tetcd, err := testutil.NewTestEmbeddedEtcd(t, logger, dir)
if err != nil {
@ -850,7 +859,7 @@ func TestPush(t *testing.T) {
}
}
func directRun(t *testing.T, dir, config, gatewayURL, token string, args ...string) {
func directRun(t *testing.T, dir, config string, configFormat ConfigFormat, gatewayURL, token string, args ...string) {
agolaBinDir := os.Getenv("AGOLA_BIN_DIR")
if agolaBinDir == "" {
t.Fatalf("env var AGOLA_BIN_DIR is undefined")
@ -868,7 +877,15 @@ func directRun(t *testing.T, dir, config, gatewayURL, token string, args ...stri
gitfs := osfs.New(repoDir)
dot, _ := gitfs.Chroot(".git")
f, err := gitfs.Create(".agola/config.jsonnet")
var configPath string
switch configFormat {
case ConfigFormatJsonnet:
configPath = ".agola/config.jsonnet"
case ConfigFormatStarlark:
configPath = ".agola/config.star"
}
f, err := gitfs.Create(configPath)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
@ -987,7 +1004,7 @@ func TestDirectRun(t *testing.T) {
// From now use the user token
gwClient = gwclient.NewClient(c.Gateway.APIExposedURL, token)
directRun(t, dir, config, c.Gateway.APIExposedURL, token, tt.args...)
directRun(t, dir, config, ConfigFormatJsonnet, c.Gateway.APIExposedURL, token, tt.args...)
_ = testutil.Wait(30*time.Second, func() (bool, error) {
runs, _, err := gwClient.GetRuns(ctx, nil, nil, []string{path.Join("/user", user.ID)}, nil, "", 0, false)
@ -1140,7 +1157,7 @@ func TestDirectRunVariables(t *testing.T) {
// From now use the user token
gwClient = gwclient.NewClient(c.Gateway.APIExposedURL, token)
directRun(t, dir, config, c.Gateway.APIExposedURL, token, tt.args...)
directRun(t, dir, config, ConfigFormatJsonnet, c.Gateway.APIExposedURL, token, tt.args...)
// TODO(sgotti) add an util to wait for a run phase
_ = testutil.Wait(30*time.Second, func() (bool, error) {
@ -1311,7 +1328,7 @@ func TestDirectRunLogs(t *testing.T) {
// From now use the user token
gwClient = gwclient.NewClient(c.Gateway.APIExposedURL, token)
directRun(t, dir, config, c.Gateway.APIExposedURL, token)
directRun(t, dir, config, ConfigFormatJsonnet, c.Gateway.APIExposedURL, token)
_ = testutil.Wait(30*time.Second, func() (bool, error) {
runs, _, err := gwClient.GetRuns(ctx, nil, nil, []string{path.Join("/user", user.ID)}, nil, "", 0, false)
@ -1682,7 +1699,7 @@ func TestPullRequest(t *testing.T) {
}
func TestConfigContext(t *testing.T) {
config := `
jsonnetConfig := `
function(ctx) {
runs: [
{
@ -1714,6 +1731,41 @@ function(ctx) {
},
],
}
`
starlarkConfig := `
def main(ctx):
return {
"runs": [
{
"name": 'run01',
"tasks": [
{
"name": 'task01',
"runtime": {
"containers": [
{
"image": 'alpine/git',
}
]
},
"environment": {
"REF_TYPE": ctx["ref_type"],
"REF": ctx["ref"],
"BRANCH": ctx["branch"],
"TAG": ctx["tag"],
"PULL_REQUEST_ID": ctx["pull_request_id"],
"COMMIT_SHA": ctx["commit_sha"]
},
"steps": [
{ "type": 'clone' },
{ "type": 'run', "command": 'env' }
],
},
],
},
]
}
`
tests := []struct {
@ -1758,8 +1810,17 @@ function(ctx) {
},
}
for _, configFormat := range []ConfigFormat{ConfigFormatJsonnet, ConfigFormatStarlark} {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Run(fmt.Sprintf("%s with %s config", tt.name, configFormat), func(t *testing.T) {
var config string
switch configFormat {
case ConfigFormatJsonnet:
config = jsonnetConfig
case ConfigFormatStarlark:
config = starlarkConfig
}
dir, err := ioutil.TempDir("", "agola")
if err != nil {
t.Fatalf("unexpected err: %v", err)
@ -1785,7 +1846,7 @@ function(ctx) {
// From now use the user token
gwClient = gwclient.NewClient(c.Gateway.APIExposedURL, token)
directRun(t, dir, config, c.Gateway.APIExposedURL, token, tt.args...)
directRun(t, dir, config, configFormat, c.Gateway.APIExposedURL, token, tt.args...)
// TODO(sgotti) add an util to wait for a run phase
_ = testutil.Wait(30*time.Second, func() (bool, error) {
@ -1865,4 +1926,5 @@ function(ctx) {
}
})
}
}
}