From d57b4ebc07f5a4a932b1ceb5212f131ffa22485d Mon Sep 17 00:00:00 2001 From: wass3r <1301201+wass3r@users.noreply.github.com> Date: Wed, 30 Oct 2024 15:39:14 -0500 Subject: [PATCH] minor tweaks and testing --- .github/workflows/test.yml | 46 ++++++---- Makefile | 34 +++++++ cmd/jsonschema-gen/main.go | 63 +------------ compiler/types/yaml/template.go | 2 +- schema/pipeline.go | 91 +++++++++++++++++++ schema/pipeline_test.go | 40 ++++++++ .../pipeline/fail/stages_and_steps.yml | 15 +++ schema/testdata/pipeline/pass/basic.yml | 7 ++ schema/testdata/pipeline/pass/complex.yml | 59 ++++++++++++ schema/testdata/pipeline/pass/stages.yml | 20 ++++ 10 files changed, 298 insertions(+), 79 deletions(-) create mode 100644 schema/pipeline.go create mode 100644 schema/pipeline_test.go create mode 100644 schema/testdata/pipeline/fail/stages_and_steps.yml create mode 100644 schema/testdata/pipeline/pass/basic.yml create mode 100644 schema/testdata/pipeline/pass/complex.yml create mode 100644 schema/testdata/pipeline/pass/stages.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 272bbf582..4987db435 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,23 +12,29 @@ jobs: runs-on: ubuntu-latest steps: - - name: clone - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - - - name: install go - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 - with: - # use version from go.mod file - go-version-file: 'go.mod' - cache: true - check-latest: true - - - name: test - run: | - make test - - - name: coverage - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: coverage.out \ No newline at end of file + - name: clone + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + + - name: install go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + # use version from go.mod file + go-version-file: "go.mod" + cache: true + check-latest: true + + - name: test + run: | + make test + + - name: test jsonschema + run: | + go install github.com/santhosh-tekuri/jsonschema/cmd/jv@v0.7.0 + make test-jsonschema + + - name: coverage + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: coverage.out + diff --git a/Makefile b/Makefile index d28ca9a5c..227c4480a 100644 --- a/Makefile +++ b/Makefile @@ -336,6 +336,40 @@ jsonschema: @echo "### Generating JSON schema" @go run cmd/jsonschema-gen/main.go > schema.json +# The `test-jsonschema` target is intended to test +# the created jsonschema against a set of failing +# and passing example vela templates located in +# schema/testdata/pipeline. +# +# The test relies on the `jv` command line tool, +# which can be installed via: +# +# go install github.com/santhosh-tekuri/jsonschema/cmd/jv@latest +# +# Usage: `make test-jsonschema` +.PHONY: test-jsonschema +test-jsonschema: jsonschema + @echo + @echo "### Testing Pipelines against JSON Schema" + @echo + @echo "=== Expected Failing Tests" + @for file in schema/testdata/pipeline/fail/*.yml; do \ + echo "› Test: $$file"; \ + if jv schema.json $$file >/dev/null 2>&1; then \ + echo "Unexpected success for $$file"; \ + exit 1; \ + fi; \ + done + @echo + @echo "=== Expected Passing Tests" + @for file in schema/testdata/pipeline/pass/*.yml; do \ + echo "› Test: $$file"; \ + if ! jv schema.json $$file >/dev/null 2>&1; then \ + echo "Unexpected failure for $$file"; \ + exit 1; \ + fi; \ + done + # The `lint` target is intended to lint the # Go source code with golangci-lint. # diff --git a/cmd/jsonschema-gen/main.go b/cmd/jsonschema-gen/main.go index 25a3be018..ce6b49b37 100644 --- a/cmd/jsonschema-gen/main.go +++ b/cmd/jsonschema-gen/main.go @@ -2,78 +2,25 @@ //go:build ignore -// This program will utilize the json/jsonschema tags -// on structs in compiler/types/yaml to generate the -// majority of the final jsonschema for a -// Vela pipeline. -// -// Some manual intervention is needed for custom types -// and/or custom marshalling that is in place. For reference -// we use the provided mechanisms, see: -// https://github.com/invopop/jsonschema?tab=readme-ov-file#custom-type-definitions -// for hooking into the schema generation process. Some -// types will have a JSONSchema or JSONSchemaExtend method -// attached to handle the overrides. - package main import ( "encoding/json" "fmt" - "github.com/invopop/jsonschema" "github.com/sirupsen/logrus" - "github.com/go-vela/server/compiler/types/yaml" + "github.com/go-vela/server/schema" ) func main() { - ref := jsonschema.Reflector{ - ExpandedStruct: true, - } - d := ref.Reflect(yaml.Build{}) - - if d == nil { - logrus.Fatal("reflection failed") - } - - d.Title = "Vela Pipeline Configuration" - - // allows folks to have other top level arbitrary - // keys without validation errors - d.AdditionalProperties = nil - - // rules can currently live at ruleset level or - // nested within 'if' (default) or 'unless'. - // without changes the struct would only allow - // the nested version. - // - // note: we have to do the modification here, - // because the custom type hooks can't provide - // access to the top level definitions, even if - // they were already processed, so we have to - // do it at this top level. - ruleSetWithRules := *d.Definitions["Rules"] - ruleSet := *d.Definitions["Ruleset"] - - // copy every property from Ruleset, other than `if` and `unless` - for item := ruleSet.Properties.Newest(); item != nil; item = item.Prev() { - if item.Key != "if" && item.Key != "unless" { - ruleSetWithRules.Properties.Set(item.Key, item.Value) - } - } - - // create a new definition for Ruleset - d.Definitions["Ruleset"].AnyOf = []*jsonschema.Schema{ - &ruleSet, - &ruleSetWithRules, + js, err := schema.NewPipelineSchema() + if err != nil { + logrus.Fatal("schema generation failed:", err) } - d.Definitions["Ruleset"].Properties = nil - d.Definitions["Ruleset"].Type = "" - d.Definitions["Ruleset"].AdditionalProperties = nil // output json - j, err := json.MarshalIndent(d, "", " ") + j, err := json.MarshalIndent(js, "", " ") if err != nil { logrus.Fatal(err) } diff --git a/compiler/types/yaml/template.go b/compiler/types/yaml/template.go index ef2005540..4055fc9dc 100644 --- a/compiler/types/yaml/template.go +++ b/compiler/types/yaml/template.go @@ -17,7 +17,7 @@ type ( Name string `yaml:"name,omitempty" json:"name,omitempty" jsonschema:"required,minLength=1,description=Unique identifier for the template.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/#the-name-key"` Source string `yaml:"source,omitempty" json:"source,omitempty" jsonschema:"required,minLength=1,description=Path to template in remote system.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/#the-source-key"` Format string `yaml:"format,omitempty" json:"format,omitempty" jsonschema:"enum=starlark,enum=golang,enum=go,default=go,minLength=1,description=language used within the template file \nReference: https://go-vela.github.io/docs/reference/yaml/templates/#the-format-key"` - Type string `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"minLength=1,example=github,description=Type of template provided from the remote system.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/#the-type-key"` + Type string `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"minLength=1,enum=github,enum=file,example=github,description=Type of template provided from the remote system.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/#the-type-key"` Variables map[string]interface{} `yaml:"vars,omitempty" json:"vars,omitempty" jsonschema:"description=Variables injected into the template.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/#the-variables-key"` } diff --git a/schema/pipeline.go b/schema/pipeline.go new file mode 100644 index 000000000..0daf45115 --- /dev/null +++ b/schema/pipeline.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +// This program utilizes the json/jsonschema tags on structs in compiler/types/yaml +// to generate the majority of the final jsonschema for a Vela pipeline. +// +// Some manual intervention is needed for custom types and/or custom unmarshaling +// that is in place. For reference, we use the mechanisms provided by the schema lib: +// https://github.com/invopop/jsonschema?tab=readme-ov-file#custom-type-definitions +// for hooking into the schema generation process. Some types will have a JSONSchema +// or JSONSchemaExtend method attached to handle these overrides. + +package schema + +import ( + "fmt" + + "github.com/invopop/jsonschema" + + types "github.com/go-vela/server/compiler/types/yaml" +) + +// NewPipelineSchema generates the JSON schema object for a Vela pipeline configuration. +// +// The returned value can be marshaled into actual JSON. +func NewPipelineSchema() (*jsonschema.Schema, error) { + ref := jsonschema.Reflector{ + ExpandedStruct: true, + } + s := ref.Reflect(types.Build{}) + + // very unlikely scenario + if s == nil { + return nil, fmt.Errorf("schema generation failed") + } + + s.Title = "Vela Pipeline Configuration" + + // allows folks to have other top level arbitrary + // keys without validation errors + s.AdditionalProperties = nil + + // apply Ruleset modification + // + // note: we have to do the modification here, + // because the custom type hooks can't provide + // access to the top level definitions, even if + // they were already processed, so we have to + // do it at this top level. + modRulesetSchema(s) + + return s, nil +} + +// modRulesetSchema applies modifications to the Ruleset definition. +// +// rules can currently live at ruleset level or nested within +// 'if' (default) or 'unless'. without changes the struct would +// only allow the nested version. +func modRulesetSchema(schema *jsonschema.Schema) { + if schema.Definitions == nil { + return + } + + rules, hasRules := schema.Definitions["Rules"] + ruleSet, hasRuleset := schema.Definitions["Ruleset"] + + // exit early if we don't have what we need + if !hasRules || !hasRuleset { + return + } + + // create copies + _rulesWithRuleset := *rules + _ruleSet := *ruleSet + + // copy every property from Ruleset, other than `if` and `unless` + for item := _ruleSet.Properties.Newest(); item != nil; item = item.Prev() { + if item.Key != "if" && item.Key != "unless" { + _rulesWithRuleset.Properties.Set(item.Key, item.Value) + } + } + + // create a new definition for Ruleset + schema.Definitions["Ruleset"].AnyOf = []*jsonschema.Schema{ + &_ruleSet, + &_rulesWithRuleset, + } + schema.Definitions["Ruleset"].Properties = nil + schema.Definitions["Ruleset"].Type = "" + schema.Definitions["Ruleset"].AdditionalProperties = nil +} diff --git a/schema/pipeline_test.go b/schema/pipeline_test.go new file mode 100644 index 000000000..d1a1a2d95 --- /dev/null +++ b/schema/pipeline_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "testing" +) + +func TestSchema_NewPipelineSchema(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + { + name: "basic schema generation", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewPipelineSchema() + if (err != nil) != tt.wantErr { + t.Errorf("NewPipelineSchema() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got == nil { + t.Error("NewPipelineSchema() returned nil schema without error") + } + if !tt.wantErr { + if got.Title != "Vela Pipeline Configuration" { + t.Errorf("NewPipelineSchema() title = %v, want %v", got.Title, "Vela Pipeline Configuration") + } + if got.AdditionalProperties != nil { + t.Error("NewPipelineSchema() AdditionalProperties should be nil") + } + } + }) + } +} diff --git a/schema/testdata/pipeline/fail/stages_and_steps.yml b/schema/testdata/pipeline/fail/stages_and_steps.yml new file mode 100644 index 000000000..1418f4daf --- /dev/null +++ b/schema/testdata/pipeline/fail/stages_and_steps.yml @@ -0,0 +1,15 @@ +version: "1" + +stages: + test: + steps: + - name: test + image: alpine:latest + commands: + - echo "hello world" + +steps: + - name: test + image: alpine:latest + commands: + - echo "hello world" diff --git a/schema/testdata/pipeline/pass/basic.yml b/schema/testdata/pipeline/pass/basic.yml new file mode 100644 index 000000000..6787adfe1 --- /dev/null +++ b/schema/testdata/pipeline/pass/basic.yml @@ -0,0 +1,7 @@ +version: "1" + +steps: + - name: test + image: alpine:latest + commands: + - echo "hello world" diff --git a/schema/testdata/pipeline/pass/complex.yml b/schema/testdata/pipeline/pass/complex.yml new file mode 100644 index 000000000..c36e71f48 --- /dev/null +++ b/schema/testdata/pipeline/pass/complex.yml @@ -0,0 +1,59 @@ +version: "1" + +step_image: &step_image + image: something + +templates: + - name: go + source: github.com/octocat/hello-world/.vela/build.yml + format: go + type: github + +metadata: + clone: false + +worker: + flavor: large + +stages: + greeting: + steps: + - name: Greeting + secrets: [ docker_username ] + image: alpine + commands: + - echo "Hello, World" + - name: Template + template: + name: go + vars: + image: golang:latest + + welcome: + steps: + - name: Welcome + <<: *step_image + ruleset: + unless: + event: push + branch: main + if: + event: pull_request + continue: true + commands: | + echo "Welcome to the Vela docs" + go build something + + goodbye: + # will wait for greeting and welcome to finish + needs: [greeting, welcome] + steps: + - name: Goodbye + image: alpine + commands: + - echo "Goodbye, World" +secrets: + - name: docker_username + key: go-vela/docs/username + engine: native + type: repo \ No newline at end of file diff --git a/schema/testdata/pipeline/pass/stages.yml b/schema/testdata/pipeline/pass/stages.yml new file mode 100644 index 000000000..8fe7820c6 --- /dev/null +++ b/schema/testdata/pipeline/pass/stages.yml @@ -0,0 +1,20 @@ +version: "1" + +stages: + test: + steps: + - name: test + image: alpine:latest + commands: + - echo "hello world" + - name: test2 + image: alpine:latest + commands: + - echo "hello world" + + test2: + steps: + - name: test + image: alpine:latest + commands: + - echo "hello world" \ No newline at end of file