diff --git a/pkg/config/draftconfig.go b/pkg/config/draftconfig.go index fc7c57e3..861064e5 100644 --- a/pkg/config/draftconfig.go +++ b/pkg/config/draftconfig.go @@ -7,6 +7,8 @@ import ( log "github.com/sirupsen/logrus" "gopkg.in/yaml.v2" + + "github.com/blang/semver/v4" ) const draftConfigFile = "draft.yaml" @@ -125,6 +127,61 @@ func (d *DraftConfig) ApplyDefaultVariables() error { return nil } +func (d *DraftConfig) ApplyDefaultVariablesForVersion(version string) error { + v, err := semver.Parse(version) + if err != nil { + return fmt.Errorf("invalid version: %w", err) + } + + expectedConfigVersionRange, err := semver.ParseRange(d.Versions) + if err != nil { + return fmt.Errorf("invalid config version range: %w", err) + } + + if !expectedConfigVersionRange(v) { + return fmt.Errorf("version %s is outside of config version range %s", version, d.Versions) + } + + for _, variable := range d.Variables { + if variable.Value == "" { + expectedRange, err := semver.ParseRange(variable.Versions) + if err != nil { + return fmt.Errorf("invalid variable versions: %w", err) + } + + if !expectedRange(v) { + log.Infof("Variable %s versions %s is outside input version %s, skipping", variable.Name, variable.Versions, version) + continue + } + + if variable.Default.ReferenceVar != "" { + referenceVar, err := d.GetVariable(variable.Default.ReferenceVar) + if err != nil { + return fmt.Errorf("apply default variables: %w", err) + } + + defaultVal, err := d.recurseReferenceVars(referenceVar, referenceVar, true) + if err != nil { + return fmt.Errorf("apply default variables: %w", err) + } + log.Infof("Variable %s defaulting to value %s", variable.Name, defaultVal) + variable.Value = defaultVal + } + + if variable.Value == "" { + if variable.Default.Value != "" { + log.Infof("Variable %s defaulting to value %s", variable.Name, variable.Default.Value) + variable.Value = variable.Default.Value + } else { + return errors.New("variable " + variable.Name + " has no default value") + } + } + } + } + + return nil +} + // recurseReferenceVars recursively checks each variable's ReferenceVar if it doesn't have a custom input. If there's no more ReferenceVars, it will return the default value of the last ReferenceVar. func (d *DraftConfig) recurseReferenceVars(referenceVar *BuilderVar, variableCheck *BuilderVar, isFirst bool) (string, error) { if !isFirst && referenceVar.Name == variableCheck.Name { diff --git a/pkg/config/draftconfig_template_test.go b/pkg/config/draftconfig_template_test.go index 7b937100..5a48d036 100644 --- a/pkg/config/draftconfig_template_test.go +++ b/pkg/config/draftconfig_template_test.go @@ -88,7 +88,7 @@ func loadTemplatesWithValidation() error { return fmt.Errorf("template %s has no template name", path) } - if _, ok := allTemplates[currTemplate.TemplateName]; ok { + if _, ok := allTemplates[strings.ToLower(currTemplate.TemplateName)]; ok { return fmt.Errorf("template %s has a duplicate template name", path) } @@ -96,6 +96,13 @@ func loadTemplatesWithValidation() error { return fmt.Errorf("template %s has an invalid type: %s", path, currTemplate.Type) } + // version range check once we define versions + // if _, err := semver.ParseRange(currTemplate.Versions); err != nil { + // return fmt.Errorf("template %s has an invalid version range: %s", path, currTemplate.Versions) + // } + + referenceVarMap := map[string]*BuilderVar{} + allVariables := map[string]*BuilderVar{} for _, variable := range currTemplate.Variables { if variable.Name == "" { return fmt.Errorf("template %s has a variable with no name", path) @@ -108,7 +115,56 @@ func loadTemplatesWithValidation() error { if _, ok := validVariableKinds[variable.Kind]; !ok { return fmt.Errorf("template %s has an invalid variable kind: %s", path, variable.Kind) } + + // version range check once we define versions + // if _, err := semver.ParseRange(variable.Versions); err != nil { + // return fmt.Errorf("template %s has an invalid version range: %s", path, variable.Versions) + // } + + allVariables[variable.Name] = variable + if variable.Default.ReferenceVar != "" { + referenceVarMap[variable.Name] = variable + } + } + + for _, currVar := range referenceVarMap { + refVar, ok := allVariables[currVar.Default.ReferenceVar] + if !ok { + return fmt.Errorf("template %s has a variable %s with reference to a non-existent variable: %s", path, currVar.Name, currVar.Default.ReferenceVar) + } + + if currVar.Name == refVar.Name { + return fmt.Errorf("template %s has a variable with cyclical reference to itself: %s", path, currVar.Name) + } + + if isCyclicalVariableReference(currVar, refVar, allVariables, map[string]bool{}) { + return fmt.Errorf("template %s has a variable with cyclical reference to itself: %s", path, currVar.Name) + } } + + allTemplates[strings.ToLower(currTemplate.TemplateName)] = currTemplate return nil }) } + +func isCyclicalVariableReference(initialVar, currRefVar *BuilderVar, allVariables map[string]*BuilderVar, visited map[string]bool) bool { + if initialVar.Name == currRefVar.Name { + return true + } + + if _, ok := visited[currRefVar.Name]; ok { + return true + } + + if currRefVar.Default.ReferenceVar == "" { + return false + } + + refVar, ok := allVariables[currRefVar.Default.ReferenceVar] + if !ok { + return false + } + + visited[currRefVar.Name] = true + return isCyclicalVariableReference(initialVar, refVar, allVariables, visited) +} diff --git a/pkg/config/draftconfig_test.go b/pkg/config/draftconfig_test.go index fddf83c5..22c8e223 100644 --- a/pkg/config/draftconfig_test.go +++ b/pkg/config/draftconfig_test.go @@ -197,3 +197,171 @@ func TestApplyDefaultVariables(t *testing.T) { }) } } + +func TestApplyDefaultVariablesForVersion(t *testing.T) { + tests := []struct { + testName string + version string + draftConfig DraftConfig + customInputs map[string]string + want map[string]string + wantErrMsg string + }{ + { + testName: "excludeOutOfVersionRangeVariables", + version: "0.0.1", + draftConfig: DraftConfig{ + Versions: ">=0.0.1 <=0.0.2", + Variables: []*BuilderVar{ + { + Name: "var1", + Value: "", + Versions: ">=0.0.1", + Default: BuilderVarDefault{ + Value: "default-value-1", + }, + }, + { + Name: "var2", + Value: "custom-value-2", + Versions: ">=0.0.2", + Default: BuilderVarDefault{ + Value: "default-value-2", + }, + }, + }, + }, + want: map[string]string{ + "var1": "default-value-1", + }, + }, + { + testName: "emptyInputVersion", + version: "", + draftConfig: DraftConfig{ + Versions: ">=0.0.1 <=0.0.2", + Variables: []*BuilderVar{ + { + Name: "var1", + Value: "", + Versions: ">=0.0.1", + Default: BuilderVarDefault{ + Value: "default-value-1", + }, + }, + { + Name: "var2", + Value: "", + Versions: ">=0.0.2", + Default: BuilderVarDefault{ + Value: "default-value-2", + }, + }, + }, + }, + wantErrMsg: "invalid version: Version string empty", + }, + { + testName: "inputVersionOutOfRange", + version: "0.0.3", + draftConfig: DraftConfig{ + Versions: ">=0.0.1 <=0.0.2", + Variables: []*BuilderVar{ + { + Name: "var1", + Value: "", + Versions: ">=0.0.1", + Default: BuilderVarDefault{ + Value: "default-value-1", + }, + }, + { + Name: "var2", + Value: "", + Versions: ">=0.0.2", + Default: BuilderVarDefault{ + Value: "default-value-2", + }, + }, + }, + }, + wantErrMsg: "version 0.0.3 is outside of config version range >=0.0.1 <=0.0.2", + }, + { + testName: "overwriteDevfaultValue", + version: "0.0.2", + draftConfig: DraftConfig{ + Versions: ">=0.0.1 <=0.0.2", + Variables: []*BuilderVar{ + { + Name: "var1", + Value: "custom-value-1", + Versions: ">=0.0.1", + Default: BuilderVarDefault{ + Value: "default-value-1", + }, + }, + { + Name: "var2", + Value: "custom-value-2", + Versions: ">=0.0.2", + Default: BuilderVarDefault{ + Value: "default-value-2", + }, + }, + }, + }, + want: map[string]string{ + "var1": "custom-value-1", + "var2": "custom-value-2", + }, + }, + { + testName: "referenceVarOverwrite", + version: "0.0.2", + draftConfig: DraftConfig{ + Versions: ">=0.0.1 <=0.0.2", + Variables: []*BuilderVar{ + { + Name: "var1", + Value: "", + Versions: ">=0.0.1", + Default: BuilderVarDefault{ + ReferenceVar: "var2", + }, + }, + { + Name: "var2", + Value: "custom-value-2", + Versions: ">=0.0.2", + Default: BuilderVarDefault{ + Value: "default-value-2", + }, + }, + }, + }, + want: map[string]string{ + "var1": "custom-value-2", + "var2": "custom-value-2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + if err := tt.draftConfig.ApplyDefaultVariablesForVersion(tt.version); err != nil && err.Error() != tt.wantErrMsg { + t.Error(err) + } else { + for k, v := range tt.want { + variable, err := tt.draftConfig.GetVariable(k) + if err != nil { + t.Error(err) + } + + if variable.Value != v { + t.Errorf("got: %s, want: %s, for test: %s", variable.Value, v, tt.testName) + } + } + } + }) + } +} diff --git a/pkg/handlers/template.go b/pkg/handlers/template.go new file mode 100644 index 00000000..c6f81bf2 --- /dev/null +++ b/pkg/handlers/template.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "bytes" + "fmt" + "io/fs" + "path/filepath" + "strings" + tmpl "text/template" + + "github.com/Azure/draft/pkg/config" + "github.com/Azure/draft/pkg/templatewriter" + log "github.com/sirupsen/logrus" +) + +type Template struct { + Config *config.DraftConfig + + templateFiles fs.FS + templateWriter templatewriter.TemplateWriter + src string + dest string + version string +} + +// GetTemplate returns a template by name, version, and destination +func GetTemplate(name, version, dest string, templateWriter templatewriter.TemplateWriter) (*Template, error) { + template, ok := templateConfigs[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("template not found: %s", name) + } + + if version == "" { + version = template.Config.DefaultVersion + log.Println("version not provided, using default version: ", version) + } + + if !IsValidVersion(template.Config.Versions, version) { + return nil, fmt.Errorf("invalid version: %s", version) + } + + if dest == "" { + dest = "." + log.Println("destination not provided, using current directory") + } + + if _, err := filepath.Abs(dest); err != nil { + return nil, fmt.Errorf("invalid destination: %s", dest) + } + + template.dest = dest + template.version = version + template.templateWriter = templateWriter + + return template, nil +} + +func (t *Template) Generate() error { + if err := t.validate(); err != nil { + log.Printf("template validation failed: %s", err.Error()) + return err + } + + if err := t.Config.ApplyDefaultVariablesForVersion(t.version); err != nil { + return fmt.Errorf("create workflow files: %w", err) + } + + if err := generateTemplate(t); err != nil { + return err + } + return generateTemplate(t) +} + +func (t *Template) validate() error { + if t == nil { + return fmt.Errorf("template is nil") + } + + if t.Config == nil { + return fmt.Errorf("template draft config is nil") + } + + if t.src == "" { + return fmt.Errorf("template source is empty") + } + + if t.dest == "" { + return fmt.Errorf("template destination is empty") + } + + if t.templateFiles == nil { + return fmt.Errorf("template files is nil") + } + + if t.version == "" { + return fmt.Errorf("template version is empty") + } + + return nil +} + +func generateTemplate(template *Template) error { + err := fs.WalkDir(template.templateFiles, template.src, func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + if strings.EqualFold(d.Name(), "draft.yaml") { + return nil + } + + if err := writeTemplate(template, path); err != nil { + return err + } + + return nil + }) + + return err +} + +func writeTemplate(draftTemplate *Template, inputFile string) error { + file, err := fs.ReadFile(draftTemplate.templateFiles, inputFile) + if err != nil { + return err + } + + // Parse the template file, missingkey=error ensures an error will be returned if any variable is missing during template execution. + tmpl, err := tmpl.New("template").Option("missingkey=error").Parse(string(file)) + if err != nil { + return err + } + + // Execute the template with variableMap + var buf bytes.Buffer + err = tmpl.Execute(&buf, draftTemplate) + if err != nil { + return err + } + + if err = draftTemplate.templateWriter.WriteFile(fmt.Sprintf("%s/%s", draftTemplate.dest, filepath.Base(inputFile)), buf.Bytes()); err != nil { + return err + } + + return nil +} diff --git a/pkg/handlers/template_utils.go b/pkg/handlers/template_utils.go new file mode 100644 index 00000000..22922be5 --- /dev/null +++ b/pkg/handlers/template_utils.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/Azure/draft/pkg/config" + "github.com/Azure/draft/template" + "github.com/blang/semver/v4" + log "github.com/sirupsen/logrus" +) + +var templateConfigs map[string]*Template + +func init() { + if err := loadTemplates(); err != nil { + log.Fatalf("failed to init templates: %s", err.Error()) + } +} + +// GetTemplates returns all templates +func GetTemplates() map[string]*Template { + return templateConfigs +} + +func loadTemplates() error { + templateConfigs = make(map[string]*Template) + return fs.WalkDir(template.Templates, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if !strings.EqualFold(d.Name(), "draft.yaml") { + return nil + } + + draftConfig, err := config.NewConfigFromFS(template.Templates, path) + if err != nil { + return err + } + + if _, ok := templateConfigs[strings.ToLower(draftConfig.TemplateName)]; ok { + return fmt.Errorf("duplicate template name: %s", draftConfig.TemplateName) + } + + newTemplate := &Template{ + Config: draftConfig, + src: filepath.Dir(path), + templateFiles: template.Templates, + } + + templateConfigs[strings.ToLower(draftConfig.TemplateName)] = newTemplate + return nil + }) +} + +// IsValidVersion checks if a version is valid for a given version range +func IsValidVersion(versionRange, version string) bool { + v, err := semver.Parse(version) + if err != nil { + return false + } + + expectedRange, err := semver.ParseRange(versionRange) + if err != nil { + return false + } + + return expectedRange(v) +} diff --git a/pkg/handlers/template_utils_test.go b/pkg/handlers/template_utils_test.go new file mode 100644 index 00000000..25da381e --- /dev/null +++ b/pkg/handlers/template_utils_test.go @@ -0,0 +1,20 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetTemplate(t *testing.T) { + loadedTemplates := GetTemplates() + assert.Positive(t, len(loadedTemplates)) +} + +func TestLoadTemplates(t *testing.T) { + templateConfigs = nil + err := loadTemplates() + assert.Nil(t, err) + loadedTemplates := GetTemplates() + assert.Positive(t, len(loadedTemplates)) +} diff --git a/pkg/workflows/manifests/deployment.yaml b/pkg/workflows/manifests/deployment.yaml new file mode 100644 index 00000000..d339c904 --- /dev/null +++ b/pkg/workflows/manifests/deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test + labels: + app: test + kubernetes.azure.com/generator: draft +spec: + replicas: 1 + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - name: test + image: test + ports: + - containerPort: 8000 \ No newline at end of file diff --git a/pkg/workflows/overlays/production/deployment.yaml b/pkg/workflows/overlays/production/deployment.yaml new file mode 100644 index 00000000..11621fba --- /dev/null +++ b/pkg/workflows/overlays/production/deployment.yaml @@ -0,0 +1,12 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test + labels: + kubernetes.azure.com/generator: draft +spec: + template: + spec: + containers: + - name: test + image: acr.test:latest \ No newline at end of file diff --git a/template/README.md b/template/README.md new file mode 100644 index 00000000..d2390baf --- /dev/null +++ b/template/README.md @@ -0,0 +1,43 @@ +# TEMPLATE DEFINITION DOCS + +## Definitions + +All templates are defined within the `./template` directory with a cluster of go template files accompanied by a `draft.yaml` file. + +### draft.yaml + +The `draft.yaml` file contains the metadata needed to define a Template in Draft. The structure of the `draft.yaml` is as follows: + +- `templateName` - The name of the template +- `type` - The type of template +- `description` - Description of template contents/functionality +- `versions` - the range/list of version definitions for this template +- `defaultVersions` - If no version is passed to a template this will be used +- `parameters` - a struct containing information on each parameter to the template + - `name` - the parameter name associated to the gotemplate variable + - `description` - description of what the parameter is used for + - `type` - defines the type of the parameter + - `kind` - defines the kind of parameter, useful for prompting and validation within portal/cli/vsce + - `required` - defines if the parameter is required for the template + - `default` - struct containing information on specific parameters default value + - `value` - the parameters default value + - `referenceVar` - the variable to reference if one is not provided + - `versions` - the versions this item is used for + +For the `type` parameters at the template level we currently have 4 definitions: +- `deployment` - the base k8s deployment + service + namespace +- `dockerfile` - representing a dockerfile for a specific language +- `workflow` - representing a GitHub Action, ADO Pipeline, or similar +- `manifest` - a generic k8s manifest. Think PDB, Ingress, HPA that can be added to an existing `deployment` + +For the `type` parameter at the variable level, this is in line with structured types: `int`, `float`, `string`, `bool`, `object`. + +For the `kind` parameter, this will be used for validation and transformation logic on the input. As an example, `azureResourceGroup` and `azureResourceName` can be validated as defined. + +### Validation + +Within the [draft config teamplate tests](../pkg/config/draftconfig_template_test.go) there is validation logic to make sure all `draft.yaml` definitions adhere to: +- Unique `templateName`'s +- Valid Template `type`'s +- Valid parameter `type`'s +- Valid parameter `kind`'s \ No newline at end of file diff --git a/template/dockerfiles/gradlew/draft.yaml b/template/dockerfiles/gradlew/draft.yaml index 182c5117..22e07882 100644 --- a/template/dockerfiles/gradlew/draft.yaml +++ b/template/dockerfiles/gradlew/draft.yaml @@ -1,6 +1,6 @@ language: gradle displayName: Gradle -templateName: "dockerfile-gradle" +templateName: "dockerfile-gradlew" description: "This template is used to create a Dockerfile for a Gradle application" type: "dockerfile" variables: