From 3ffeaa8b7dda879072611a8b9b2203d1f8139276 Mon Sep 17 00:00:00 2001 From: Kent Rancourt Date: Wed, 28 Aug 2024 10:20:54 -0400 Subject: [PATCH] initial scaffolding for directive validation and git-based directives Signed-off-by: Kent Rancourt --- Makefile | 23 +- go.mod | 3 + go.sum | 6 + hack/codegen/directive-configs.sh | 26 ++ internal/directives/git_clone_directive.go | 52 ++++ .../directives/git_clone_directive_test.go | 266 ++++++++++++++++++ internal/directives/git_commit_directive.go | 52 ++++ .../directives/git_commit_directive_test.go | 96 +++++++ internal/directives/git_push_directive.go | 52 ++++ .../directives/git_push_directive_test.go | 129 +++++++++ internal/directives/schema_loader.go | 22 ++ internal/directives/schemas/common.json | 24 ++ .../directives/schemas/git-clone-config.json | 88 ++++++ .../directives/schemas/git-commit-config.json | 41 +++ .../directives/schemas/git-push-config.json | 38 +++ internal/directives/validation.go | 27 ++ internal/directives/zz_config_types.go | 74 +++++ 17 files changed, 1010 insertions(+), 9 deletions(-) create mode 100755 hack/codegen/directive-configs.sh create mode 100644 internal/directives/git_clone_directive.go create mode 100644 internal/directives/git_clone_directive_test.go create mode 100644 internal/directives/git_commit_directive.go create mode 100644 internal/directives/git_commit_directive_test.go create mode 100644 internal/directives/git_push_directive.go create mode 100644 internal/directives/git_push_directive_test.go create mode 100644 internal/directives/schema_loader.go create mode 100644 internal/directives/schemas/common.json create mode 100644 internal/directives/schemas/git-clone-config.json create mode 100644 internal/directives/schemas/git-commit-config.json create mode 100644 internal/directives/schemas/git-push-config.json create mode 100644 internal/directives/validation.go create mode 100644 internal/directives/zz_config_types.go diff --git a/Makefile b/Makefile index b29638529..2d1ad1b30 100644 --- a/Makefile +++ b/Makefile @@ -152,7 +152,11 @@ build-cli-with-ui: build-ui build-cli ################################################################################ .PHONY: codegen -codegen: codegen-proto codegen-controller codegen-ui codegen-docs +codegen: codegen-proto codegen-controller codegen-directive-configs codegen-ui codegen-docs + +.PHONY: codegen-proto +codegen-proto: install-protoc install-go-to-protobuf install-protoc-gen-gogo install-goimports install-buf + ./hack/codegen/proto.sh .PHONY: codegen-controller codegen-controller: install-controller-gen @@ -166,20 +170,21 @@ codegen-controller: install-controller-gen object:headerFile=hack/boilerplate.go.txt \ paths=./... -.PHONY: codegen-docs -codegen-docs: - npm install -g @bitnami/readme-generator-for-helm - bash hack/helm-docs/helm-docs.sh - -.PHONY: codegen-proto -codegen-proto: install-protoc install-go-to-protobuf install-protoc-gen-gogo install-goimports install-buf - ./hack/codegen/proto.sh +.PHONY: codegen-directive-configs +codegen-directive-configs: + npm install -g quicktype + ./hack/codegen/directive-configs.sh .PHONY: codegen-ui codegen-ui: pnpm --dir=ui install --dev pnpm --dir=ui run generate:schema +.PHONY: codegen-docs +codegen-docs: + npm install -g @bitnami/readme-generator-for-helm + bash hack/helm-docs/helm-docs.sh + ################################################################################ # Hack: Targets to help you hack # # # diff --git a/go.mod b/go.mod index a3e3d0eab..56378e060 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 github.com/technosophos/moniker v0.0.0-20210218184952-3ea787d3943b + github.com/xeipuuv/gojsonschema v1.2.0 go.uber.org/ratelimit v0.3.1 golang.org/x/crypto v0.26.0 golang.org/x/net v0.28.0 @@ -77,6 +78,8 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.13.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect go.opencensus.io v0.24.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go.sum b/go.sum index b7126fbea..65a157b3b 100644 --- a/go.sum +++ b/go.sum @@ -418,6 +418,12 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/go-gitlab v0.107.0 h1:P2CT9Uy9yN9lJo3FLxpMZ4xj6uWcpnigXsjvqJ6nd2Y= github.com/xanzy/go-gitlab v0.107.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/hack/codegen/directive-configs.sh b/hack/codegen/directive-configs.sh new file mode 100755 index 000000000..8dd415125 --- /dev/null +++ b/hack/codegen/directive-configs.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +out_file=internal/directives/zz_config_types.go +generated_code_warning="// Code generated by quicktype. DO NOT EDIT.\n\n" + +rm -rf ${out_file} + +quicktype \ + --src-lang schema --alphabetize-properties \ + --lang go --just-types-and-package --package directives --omit-empty \ + -o internal/directives/zz_config_types.go \ + internal/directives/schemas/*.json + +printf "${generated_code_warning}$(cat ${out_file})" > ${out_file} + +# Pointers to bools and strings don't make a lot of sense in most cases. +# +# Note that -i works on Linux, but not on macOS. -i '' works on macOS, but not +# on Linux. So we use -i.bak, which works on both. +sed -i.bak 's/\*bool/bool/g' ${out_file} +sed -i.bak 's/\*string/string/g' ${out_file} +rm ${out_file}.bak + +gofmt -w ${out_file} diff --git a/internal/directives/git_clone_directive.go b/internal/directives/git_clone_directive.go new file mode 100644 index 000000000..d0b7d34c8 --- /dev/null +++ b/internal/directives/git_clone_directive.go @@ -0,0 +1,52 @@ +package directives + +import ( + "context" + "fmt" + + "github.com/xeipuuv/gojsonschema" +) + +func init() { + // Register the git-clone directive with the builtins registry. + builtins.RegisterDirective(newGitCloneDirective()) +} + +// gitCloneDirective is a directive that clones one or more refs from a remote +// Git repository to one or more working directories. +type gitCloneDirective struct { + schemaLoader gojsonschema.JSONLoader +} + +// newGitCloneDirective creates a new git-clone directive. +func newGitCloneDirective() Directive { + return &gitCloneDirective{ + schemaLoader: getConfigSchemaLoader("git-clone"), + } +} + +// Name implements the Directive interface. +func (g *gitCloneDirective) Name() string { + return "git-clone" +} + +// Run implements the Directive interface. +func (g *gitCloneDirective) Run( + _ context.Context, + stepCtx *StepContext, +) (Result, error) { + // Validate the configuration against the JSON Schema + if err := validate( + g.schemaLoader, + gojsonschema.NewGoLoader(stepCtx.Config), + "git-clone", + ); err != nil { + return ResultFailure, err + } + if _, err := configToStruct[GitCloneConfig](stepCtx.Config); err != nil { + return ResultFailure, + fmt.Errorf("could not convert config into git-clone config: %w", err) + } + // TODO: Add implementation here + return ResultSuccess, nil +} diff --git a/internal/directives/git_clone_directive_test.go b/internal/directives/git_clone_directive_test.go new file mode 100644 index 000000000..90cb97d5f --- /dev/null +++ b/internal/directives/git_clone_directive_test.go @@ -0,0 +1,266 @@ +package directives + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitCloneDirective_Run(t *testing.T) { + ctx := context.Background() + + d := newGitCloneDirective() + + t.Run("validations", func(t *testing.T) { + testCases := []struct { + name string + config Config + expectedProblems []string + }{ + { + name: "repoURL not specified", + config: Config{}, + expectedProblems: []string{ + "(root): repoURL is required", + }, + }, + { + name: "repoURL is empty string", + config: Config{ + "repoURL": "", + }, + expectedProblems: []string{ + "repoURL: String length must be greater than or equal to 1", + "repoURL: Does not match format 'uri'", + }, + }, + { + name: "no checkout specified", + config: Config{}, + expectedProblems: []string{ + "(root): checkout is required", + }, + }, + { + name: "checkout is an empty array", + config: Config{ + "checkout": []Config{}, + }, + expectedProblems: []string{ + "checkout: Array must have at least 1 items", + }, + }, + { + name: "checkout path is not specified", + config: Config{ + "checkout": []Config{{}}, + }, + expectedProblems: []string{ + "checkout.0: path is required", + }, + }, + { + name: "checkout path is empty string", + config: Config{ + "checkout": []Config{{ + "path": "", + }}, + }, + expectedProblems: []string{ + "checkout.0.path: String length must be greater than or equal to 1", + }, + }, + { + name: "neither branch nor fromFreight nor tag specified", + // This is ok. The behavior should be to clone the default branch. + config: Config{ // Should be completely valid + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{{ + "path": "/fake/path", + }}, + }, + }, + { + name: "branch is empty string, fromFreight is explicitly false, and tag is empty string", + // This is ok. The behavior should be to clone the default branch. + config: Config{ // Should be completely valid + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{{ + "branch": "", + "fromFreight": false, + "tag": "", + "path": "/fake/path", + }}, + }, + }, + { + name: "just branch is specified", + config: Config{ // Should be completely valid + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{{ + "branch": "fake-branch", + "path": "/fake/path", + }}, + }, + }, + { + name: "branch is specified and fromFreight is true", + // These are meant to be mutually exclusive. + config: Config{ + "checkout": []Config{{ + "branch": "fake-branch", + "fromFreight": true, + }}, + }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, + }, + { + name: "branch and fromOrigin are both specified", + // These are not meant to be used together. + config: Config{ + "checkout": []Config{{ + "branch": "fake-branch", + "fromOrigin": Config{}, + }}, + }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, + }, + { + name: "branch and tag are both specified", + // These are meant to be mutually exclusive. + config: Config{ + "checkout": []Config{{ + "branch": "fake-branch", + "tag": "fake-tag", + }}, + }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, + }, + { + name: "just fromFreight is true", + config: Config{ // Should be completely valid + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{{ + "fromFreight": true, + "path": "/fake/path", + }}, + }, + }, + { + name: "fromFreight is true and fromOrigin is specified", + config: Config{ // Should be completely valid + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{{ + "fromFreight": true, + "fromOrigin": Config{ + "kind": "Warehouse", + "name": "fake-warehouse", + }, + "path": "/fake/path", + }}, + }, + }, + { + name: "fromFreight is true and tag is specified", + // These are meant to be mutually exclusive. + config: Config{ + "checkout": []Config{{ + "fromFreight": true, + "tag": "fake-tag", + }}, + }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, + }, + { + name: "just fromOrigin is specified", + // This is not meant to be used without fromFreight=true. + config: Config{ + "checkout": []Config{{ + "fromOrigin": Config{}, + }}, + }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, + }, + { + name: "fromOrigin and tag are both specified", + // These are not meant to be used together. + config: Config{ + "checkout": []Config{{ + "fromOrigin": Config{}, + "tag": "fake-tag", + }}, + }, + expectedProblems: []string{ + "checkout.0: Must validate one and only one schema", + }, + }, + { + name: "just tag is specified", + config: Config{ // Should be completely valid + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{{ + "tag": "fake-tag", + "path": "/fake/path", + }}, + }, + }, + { + name: "valid kitchen sink", + config: Config{ + "repoURL": "https://github.com/example/repo.git", + "checkout": []Config{ + { + "path": "/fake/path/0", + }, + { + "branch": "fake-branch", + "path": "/fake/path/1", + }, + { + "fromFreight": true, + "path": "/fake/path/2", + }, + { + "fromFreight": true, + "fromOrigin": Config{ + "kind": "Warehouse", + "name": "fake-warehouse", + }, + "path": "/fake/path/3", + }, + { + "tag": "fake-tag", + "path": "/fake/path/4", + }, + }, + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + stepCtx := &StepContext{ + Config: testCase.config, + } + _, err := d.Run(ctx, stepCtx) + if len(testCase.expectedProblems) == 0 { + require.NoError(t, err) + } else { + for _, problem := range testCase.expectedProblems { + require.ErrorContains(t, err, problem) + } + } + }) + } + }) +} diff --git a/internal/directives/git_commit_directive.go b/internal/directives/git_commit_directive.go new file mode 100644 index 000000000..28db615e8 --- /dev/null +++ b/internal/directives/git_commit_directive.go @@ -0,0 +1,52 @@ +package directives + +import ( + "context" + "fmt" + + "github.com/xeipuuv/gojsonschema" +) + +func init() { + // Register the git-commit directive with the builtins registry. + builtins.RegisterDirective(newGitCommitDirective()) +} + +// gitCommitDirective is a directive that makes a commit to a local Git +// repository. +type gitCommitDirective struct { + schemaLoader gojsonschema.JSONLoader +} + +// newGitCommitDirective creates a new git-commit directive. +func newGitCommitDirective() Directive { + return &gitCommitDirective{ + schemaLoader: getConfigSchemaLoader("git-commit"), + } +} + +// Name implements the Directive interface. +func (g *gitCommitDirective) Name() string { + return "git-commit" +} + +// Run implements the Directive interface. +func (g *gitCommitDirective) Run( + _ context.Context, + stepCtx *StepContext, +) (Result, error) { + // Validate the configuration against the JSON Schema + if err := validate( + g.schemaLoader, + gojsonschema.NewGoLoader(stepCtx.Config), + "git-commit", + ); err != nil { + return ResultFailure, err + } + if _, err := configToStruct[GitCommitConfig](stepCtx.Config); err != nil { + return ResultFailure, + fmt.Errorf("could not convert config into git-commit config: %w", err) + } + // TODO: Add implementation here + return ResultSuccess, nil +} diff --git a/internal/directives/git_commit_directive_test.go b/internal/directives/git_commit_directive_test.go new file mode 100644 index 000000000..ba7d9eab7 --- /dev/null +++ b/internal/directives/git_commit_directive_test.go @@ -0,0 +1,96 @@ +package directives + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitCommitDirective_Run(t *testing.T) { + ctx := context.Background() + + d := newGitCommitDirective() + + t.Run("validations", func(t *testing.T) { + testCases := []struct { + name string + config Config + expectedProblems []string + }{ + { + name: "path not specified", + config: Config{}, + expectedProblems: []string{ + "(root): path is required", + }, + }, + { + name: "path is empty string", + config: Config{ + "path": "", + }, + expectedProblems: []string{ + "path: String length must be greater than or equal to 1", + }, + }, + { + name: "author email is not specified", + config: Config{ // Should be completely valid + "author": Config{}, + "path": "/tmp/foo", + }, + }, + { + name: "author email is empty string", + config: Config{ // Should be completely valid + "author": Config{ + "email": "", + }, + "path": "/tmp/foo", + }, + }, + { + name: "author name is not specified", + config: Config{ // Should be completely valid + "author": Config{}, + "path": "/tmp/foo", + }, + }, + { + name: "author name is empty string", + config: Config{ // Should be completely valid + "author": Config{ + "name": "", + }, + "path": "/tmp/foo", + }, + }, + { + name: "valid kitchen sink", + config: Config{ + "author": Config{ + "email": "tony@starkindustries.com", + "name": "Tony Stark", + }, + "path": "/tmp/foo", + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + stepCtx := &StepContext{ + Config: testCase.config, + } + _, err := d.Run(ctx, stepCtx) + if len(testCase.expectedProblems) == 0 { + require.NoError(t, err) + } else { + for _, problem := range testCase.expectedProblems { + require.ErrorContains(t, err, problem) + } + } + }) + } + }) +} diff --git a/internal/directives/git_push_directive.go b/internal/directives/git_push_directive.go new file mode 100644 index 000000000..67a985070 --- /dev/null +++ b/internal/directives/git_push_directive.go @@ -0,0 +1,52 @@ +package directives + +import ( + "context" + "fmt" + + "github.com/xeipuuv/gojsonschema" +) + +func init() { + // Register the git-push directive with the builtins registry. + builtins.RegisterDirective(newGitPushDirective()) +} + +// gitPushDirective is a directive that pushes commits from a local Git +// repository to a remote Git repository. +type gitPushDirective struct { + schemaLoader gojsonschema.JSONLoader +} + +// newGitPushDirective creates a new git-push directive. +func newGitPushDirective() Directive { + return &gitPushDirective{ + schemaLoader: getConfigSchemaLoader("git-push"), + } +} + +// Name implements the Directive interface. +func (g *gitPushDirective) Name() string { + return "git-push" +} + +// Run implements the Directive interface. +func (g *gitPushDirective) Run( + _ context.Context, + stepCtx *StepContext, +) (Result, error) { + // Validate the configuration against the JSON Schema + if err := validate( + g.schemaLoader, + gojsonschema.NewGoLoader(stepCtx.Config), + "git-push", + ); err != nil { + return ResultFailure, err + } + if _, err := configToStruct[GitPushConfig](stepCtx.Config); err != nil { + return ResultFailure, + fmt.Errorf("could not convert config into git-push config: %w", err) + } + // TODO: Add implementation here + return ResultSuccess, nil +} diff --git a/internal/directives/git_push_directive_test.go b/internal/directives/git_push_directive_test.go new file mode 100644 index 000000000..0e7f6e7da --- /dev/null +++ b/internal/directives/git_push_directive_test.go @@ -0,0 +1,129 @@ +package directives + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitPushDirective_Run(t *testing.T) { + ctx := context.Background() + + d := newGitPushDirective() + + t.Run("validations", func(t *testing.T) { + testCases := []struct { + name string + config Config + expectedProblems []string + }{ + { + name: "path not specified", + config: Config{}, + expectedProblems: []string{ + "(root): path is required", + }, + }, + { + name: "path is empty string", + config: Config{ + "path": "", + }, + expectedProblems: []string{ + "path: String length must be greater than or equal to 1", + }, + }, + { + name: "just generateTargetBranch is true", + config: Config{ // Should be completely valid + "generateTargetBranch": true, + "path": "/fake/path", + }, + }, + { + name: "generateTargetBranch is true and targetBranch is empty string", + config: Config{ // Should be completely valid + "generateTargetBranch": true, + "path": "/fake/path", + "targetBranch": "", + }, + }, + { + name: "generateTargetBranch is true and targetBranch is specified", + // These are meant to be mutually exclusive. + config: Config{ + "generateTargetBranch": true, + "targetBranch": "fake-branch", + }, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, + { + name: "generateTargetBranch not specified and targetBranch not specified", + config: Config{}, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, + { + name: "generateTargetBranch not specified and targetBranch is empty string", + config: Config{ + "targetBranch": "", + }, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, + { + name: "generateTargetBranch not specified and targetBranch is specified", + config: Config{ // Should be completely valid + "path": "/fake/path", + "targetBranch": "fake-branch", + }, + }, + { + name: "just generateTargetBranch is false", + config: Config{ + "generateTargetBranch": false, + }, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, + { + name: "generateTargetBranch is false and targetBranch is empty string", + config: Config{ + "generateTargetBranch": false, + "targetBranch": "", + }, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, + { + name: "generateTargetBranch is false and targetBranch is specified", + config: Config{ // Should be completely valid + "path": "/fake/path", + "targetBranch": "fake-branch", + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + stepCtx := &StepContext{ + Config: testCase.config, + } + _, err := d.Run(ctx, stepCtx) + if len(testCase.expectedProblems) == 0 { + require.NoError(t, err) + } else { + for _, problem := range testCase.expectedProblems { + require.ErrorContains(t, err, problem) + } + } + }) + } + }) +} diff --git a/internal/directives/schema_loader.go b/internal/directives/schema_loader.go new file mode 100644 index 000000000..6b4fd25f9 --- /dev/null +++ b/internal/directives/schema_loader.go @@ -0,0 +1,22 @@ +package directives + +import ( + "embed" + "fmt" + "net/http" + + "github.com/xeipuuv/gojsonschema" +) + +var ( + //go:embed schemas/* + embeddedSchemasFS embed.FS + schemasFS = http.FS(embeddedSchemasFS) +) + +func getConfigSchemaLoader(name string) gojsonschema.JSONLoader { + return gojsonschema.NewReferenceLoaderFileSystem( + fmt.Sprintf("file:///schemas/%s-config.json", name), + schemasFS, + ) +} diff --git a/internal/directives/schemas/common.json b/internal/directives/schemas/common.json new file mode 100644 index 000000000..4429a7a06 --- /dev/null +++ b/internal/directives/schemas/common.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CommonDefs", + + "definitions": { + "origin": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "name"], + "properties": { + "kind": { + "type": "string", + "description": "The kind of origin. Currently only 'Warehouse' is supported. Required.", + "enum": ["Warehouse"] + }, + "name": { + "type": "string", + "description": "The name of the origin. Required.", + "minLength": 1 + } + } + } + } +} diff --git a/internal/directives/schemas/git-clone-config.json b/internal/directives/schemas/git-clone-config.json new file mode 100644 index 000000000..306f66c96 --- /dev/null +++ b/internal/directives/schemas/git-clone-config.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitCloneConfig", + "type": "object", + "additionalProperties": false, + "required": ["repoURL", "checkout"], + "properties": { + "insecureSkipTLSVerify" : { + "type": "boolean", + "description": "Indicates whether to skip TLS verification when cloning the repository. Default is false." + }, + "repoURL": { + "type": "string", + "description": "The URL of a remote Git repository to clone. Required.", + "minLength": 1, + "format": "uri" + }, + "checkout": { + "type": "array", + "description": "The commits, branches, or tags to check out from the repository and the paths where they should be checked out. At least one must be specified.", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "branch": { + "type": "string", + "description": "The branch to checkout. Mutually exclusive with 'tag' and 'fromFreight=true'. If none of these is specified, the default branch is checked out." + }, + "fromFreight": { + "type": "boolean", + "description": "Indicates whether the ID of a commit to check out may be obtained from Freight. A value of 'true' is mutually exclusive with 'branch' and 'tag'. If none of these is specified, the default branch is checked out." + }, + "fromOrigin": { + "$ref": "./common.json#definitions/origin" + }, + "path": { + "type": "string", + "description": "The path where the repository should be checked out.", + "minLength": 1 + }, + "tag": { + "type": "string", + "description": "The tag to checkout. Mutually exclusive with 'branch' and 'fromFreight=true'. If none of these is specified, the default branch is checked out." + } + }, + "oneOf": [ + { + "properties": { + "branch": { "enum": [null, ""] }, + "fromFreight": { "enum": [null, false] }, + "fromOrigin": { "enum": [null] }, + "tag": { "enum": [null, ""] } + } + }, + { + "required": ["branch"], + "properties": { + "branch": { "minLength": 1 }, + "fromFreight": { "enum": [null, false] }, + "fromOrigin": { "enum": [null] }, + "tag": { "enum": [null, ""] } + } + }, + { + "required": ["fromFreight"], + "properties": { + "branch": { "enum": [null, ""] }, + "fromFreight": { "const": true }, + "fromOrigin": { "oneOf": [{ "type": "object" }, { "enum": [null] }] }, + "tag": { "enum": [null, ""] } + } + }, + { + "required": ["tag"], + "properties": { + "branch": { "enum": [null, ""] }, + "fromFreight": { "enum": [null, false] }, + "fromOrigin": { "enum": [null] }, + "tag": { "minLength": 1 } + } + } + ] + } + } + } +} diff --git a/internal/directives/schemas/git-commit-config.json b/internal/directives/schemas/git-commit-config.json new file mode 100644 index 000000000..486a3c06b --- /dev/null +++ b/internal/directives/schemas/git-commit-config.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitCommitConfig", + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "author": { + "type": "object", + "description": "The author of the commit.", + "additionalProperties": false, + "properties": { + "email": { + "type": "string", + "description": "The email of the author.", + "oneOf": [ + { + "format": "email" + }, + { + "const": "" + } + ] + }, + "name": { + "type": "string", + "description": "The name of the author." + } + } + }, + "message": { + "type": "string", + "description": "The commit message." + }, + "path": { + "type": "string", + "description": "The path to a working directory of a local repository.", + "minLength": 1 + } + } +} diff --git a/internal/directives/schemas/git-push-config.json b/internal/directives/schemas/git-push-config.json new file mode 100644 index 000000000..d5f19cb22 --- /dev/null +++ b/internal/directives/schemas/git-push-config.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitPushConfig", + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "generateTargetBranch": { + "type": "boolean", + "description": "Indicates whether to push to a new remote branch. A value of 'true' is mutually exclusive with 'targetBranch'. If neither of these is provided, the target branch will be the currently checked out branch." + }, + "path": { + "type": "string", + "description": "The path to a working directory of a local repository.", + "minLength": 1 + }, + "targetBranch": { + "type": "string", + "description": "The target branch to push to. Mutually exclusive with 'generateTargetBranch=true'. If neither of these is provided, the target branch will be the currently checked out branch." + } + }, + "oneOf": [ + { + "required": ["generateTargetBranch"], + "properties": { + "generateTargetBranch": { "const": true }, + "targetBranch": { "enum": ["", null] } + } + }, + { + "required": ["targetBranch"], + "properties": { + "generateTargetBranch": { "enum": [null, false] }, + "targetBranch": { "minLength": 1 } + } + } + ] +} diff --git a/internal/directives/validation.go b/internal/directives/validation.go new file mode 100644 index 000000000..5e8e0e544 --- /dev/null +++ b/internal/directives/validation.go @@ -0,0 +1,27 @@ +package directives + +import ( + "errors" + "fmt" + + "github.com/xeipuuv/gojsonschema" +) + +func validate( + schemaLoader gojsonschema.JSONLoader, + docLoader gojsonschema.JSONLoader, + configKind string, +) error { + result, err := gojsonschema.Validate(schemaLoader, docLoader) + if err != nil { + return fmt.Errorf("could not validate %s config: %w", configKind, err) + } + if !result.Valid() { + errs := make([]error, len(result.Errors())) + for i, err := range result.Errors() { + errs[i] = errors.New(err.String()) + } + return fmt.Errorf("invalid %s config: %w", configKind, errors.Join(errs...)) + } + return nil +} diff --git a/internal/directives/zz_config_types.go b/internal/directives/zz_config_types.go new file mode 100644 index 000000000..4dc0c3c31 --- /dev/null +++ b/internal/directives/zz_config_types.go @@ -0,0 +1,74 @@ +// Code generated by quicktype. DO NOT EDIT. + +package directives + +type CommonDefs interface{} + +type GitCloneConfig struct { + // The commits, branches, or tags to check out from the repository and the paths where they + // should be checked out. At least one must be specified. + Checkout []Checkout `json:"checkout"` + // Indicates whether to skip TLS verification when cloning the repository. Default is false. + InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` + // The URL of a remote Git repository to clone. Required. + RepoURL string `json:"repoURL"` +} + +type Checkout struct { + // The branch to checkout. Mutually exclusive with 'tag' and 'fromFreight=true'. If none of + // these is specified, the default branch is checked out. + Branch string `json:"branch,omitempty"` + // Indicates whether the ID of a commit to check out may be obtained from Freight. A value + // of 'true' is mutually exclusive with 'branch' and 'tag'. If none of these is specified, + // the default branch is checked out. + FromFreight bool `json:"fromFreight,omitempty"` + FromOrigin *Origin `json:"fromOrigin,omitempty"` + // The path where the repository should be checked out. + Path string `json:"path"` + // The tag to checkout. Mutually exclusive with 'branch' and 'fromFreight=true'. If none of + // these is specified, the default branch is checked out. + Tag string `json:"tag,omitempty"` +} + +type Origin struct { + // The kind of origin. Currently only 'Warehouse' is supported. Required. + Kind Kind `json:"kind"` + // The name of the origin. Required. + Name string `json:"name"` +} + +type GitCommitConfig struct { + // The author of the commit. + Author *Author `json:"author,omitempty"` + // The commit message. + Message string `json:"message,omitempty"` + // The path to a working directory of a local repository. + Path string `json:"path"` +} + +// The author of the commit. +type Author struct { + // The email of the author. + Email string `json:"email,omitempty"` + // The name of the author. + Name string `json:"name,omitempty"` +} + +type GitPushConfig struct { + // Indicates whether to push to a new remote branch. A value of 'true' is mutually exclusive + // with 'targetBranch'. If neither of these is provided, the target branch will be the + // currently checked out branch. + GenerateTargetBranch bool `json:"generateTargetBranch,omitempty"` + // The path to a working directory of a local repository. + Path string `json:"path"` + // The target branch to push to. Mutually exclusive with 'generateTargetBranch=true'. If + // neither of these is provided, the target branch will be the currently checked out branch. + TargetBranch string `json:"targetBranch,omitempty"` +} + +// The kind of origin. Currently only 'Warehouse' is supported. Required. +type Kind string + +const ( + Warehouse Kind = "Warehouse" +)