diff --git a/.github/governance.yml b/.github/governance.yml index e45e8f6..e78e916 100644 --- a/.github/governance.yml +++ b/.github/governance.yml @@ -39,4 +39,4 @@ issue: - release-process # kargo-render component areas multiple: true - needs: true + needs: false diff --git a/README.md b/README.md index eca5e96..2a50b73 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,10 @@ [![codecov](https://codecov.io/gh/akuity/kargo-render/branch/main/graph/badge.svg?token=MRKMA584M9)](https://codecov.io/gh/akuity/kargo-render) [![Netlify Status](https://api.netlify.com/api/v1/badges/f5d7d99b-ca3a-4477-a10b-67fb7a8328a9/deploy-status)](https://app.netlify.com/sites/docs-kargo-render-akuity-io/deploys) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) +[![Discord](https://img.shields.io/discord/1138942074998235187?logo=discord&logoColor=ffffff&label=discord +)](http://akuity.community) - + Placeholder diff --git a/argocd-schema.json b/argocd-schema.json new file mode 100644 index 0000000..e038da4 --- /dev/null +++ b/argocd-schema.json @@ -0,0 +1,234 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "argocd-schema.json", + "definitions": { + "helm": { + "description": "Helm holds helm specific options", + "properties": { + "fileParameters": { + "description": "FileParameters are file parameters to the helm template", + "items": { + "description": "HelmFileParameter is a file parameter that's passed to helm template during manifest generation", + "properties": { + "name": { + "description": "Name is the name of the Helm parameter", + "type": "string" + }, + "path": { + "description": "Path is the path to the file containing the values for the Helm parameter", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ignoreMissingValueFiles": { + "description": "IgnoreMissingValueFiles prevents helm template from failing when valueFiles do not exist locally by not appending them to helm template --values", + "type": "boolean" + }, + "parameters": { + "description": "Parameters is a list of Helm parameters which are passed to the helm template command upon manifest generation", + "items": { + "description": "HelmParameter is a parameter that's passed to helm template during manifest generation", + "properties": { + "forceString": { + "description": "ForceString determines whether to tell Helm to interpret booleans and numbers as strings", + "type": "boolean" + }, + "name": { + "description": "Name is the name of the Helm parameter", + "type": "string" + }, + "value": { + "description": "Value is the value for the Helm parameter", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "passCredentials": { + "description": "PassCredentials pass credentials to all domains (Helm's --pass-credentials)", + "type": "boolean" + }, + "releaseName": { + "description": "ReleaseName is the Helm release name to use. If omitted it will use the application name", + "type": "string" + }, + "skipCrds": { + "description": "SkipCrds skips custom resource definition installation step (Helm's --skip-crds)", + "type": "boolean" + }, + "valueFiles": { + "description": "ValuesFiles is a list of Helm value files to use when generating a template", + "items": { + "type": "string" + }, + "type": "array" + }, + "values": { + "description": "Values specifies Helm values to be passed to helm template, typically defined as a block. ValuesObject takes precedence over Values, so use one or the other.", + "type": "string" + }, + "valuesObject": { + "description": "ValuesObject specifies Helm values to be passed to helm template, defined as a map. This takes precedence over Values.", + "type": "object", + "x-kubernetes-preserve-unknown-fields": true + }, + "version": { + "description": "Version is the Helm version to use for templating (\"3\")", + "type": "string" + } + }, + "type": "object" + }, + "kustomize": { + "description": "Kustomize holds kustomize specific options", + "properties": { + "commonAnnotations": { + "additionalProperties": { + "type": "string" + }, + "description": "CommonAnnotations is a list of additional annotations to add to rendered manifests", + "type": "object" + }, + "commonAnnotationsEnvsubst": { + "description": "CommonAnnotationsEnvsubst specifies whether to apply env variables substitution for annotation values", + "type": "boolean" + }, + "commonLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "CommonLabels is a list of additional labels to add to rendered manifests", + "type": "object" + }, + "forceCommonAnnotations": { + "description": "ForceCommonAnnotations specifies whether to force applying common annotations to resources for Kustomize apps", + "type": "boolean" + }, + "forceCommonLabels": { + "description": "ForceCommonLabels specifies whether to force applying common labels to resources for Kustomize apps", + "type": "boolean" + }, + "images": { + "description": "Images is a list of Kustomize image override specifications", + "items": { + "description": "KustomizeImage represents a Kustomize image definition in the format [old_image_name=]:", + "type": "string" + }, + "type": "array" + }, + "namePrefix": { + "description": "NamePrefix is a prefix appended to resources for Kustomize apps", + "type": "string" + }, + "nameSuffix": { + "description": "NameSuffix is a suffix appended to resources for Kustomize apps", + "type": "string" + }, + "namespace": { + "description": "Namespace sets the namespace that Kustomize adds to all resources", + "type": "string" + }, + "replicas": { + "description": "Replicas is a list of Kustomize Replicas override specifications", + "items": { + "properties": { + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "description": "Number of replicas", + "x-kubernetes-int-or-string": true + }, + "name": { + "description": "Name of Deployment or StatefulSet", + "type": "string" + } + }, + "required": [ + "count", + "name" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "description": "Version controls which version of Kustomize to use for rendering manifests", + "type": "string" + } + }, + "type": "object" + }, + "plugin": { + "description": "Plugin holds config management plugin specific options", + "properties": { + "env": { + "description": "Env is a list of environment variable entries", + "items": { + "description": "EnvEntry represents an entry in the application's environment", + "properties": { + "name": { + "description": "Name is the name of the variable, usually expressed in uppercase", + "type": "string" + }, + "value": { + "description": "Value is the value of the variable", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "parameters": { + "items": { + "properties": { + "array": { + "description": "Array is the value of an array type parameter.", + "items": { + "type": "string" + }, + "type": "array" + }, + "map": { + "additionalProperties": { + "type": "string" + }, + "description": "Map is the value of a map type parameter.", + "type": "object" + }, + "name": { + "description": "Name is the name identifying a parameter.", + "type": "string" + }, + "string": { + "description": "String_ is the value of a string type parameter.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } +} diff --git a/branches.go b/branches.go index c4708be..0fe4435 100644 --- a/branches.go +++ b/branches.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/ghodss/yaml" "github.com/pkg/errors" @@ -144,19 +145,90 @@ func switchToCommitBranch(rc requestContext) (string, error) { } } - // Clean existing output paths - for appName, appConfig := range rc.target.branchConfig.AppConfigs { - var outputDir string - if appConfig.OutputPath != "" { - outputDir = filepath.Join(rc.repo.WorkingDir(), appConfig.OutputPath) - } else { - outputDir = filepath.Join(rc.repo.WorkingDir(), appName) - } - if err := os.RemoveAll(outputDir); err != nil { - return "", errors.Wrapf(err, "error deleting %q", outputDir) - } + // Clean the branch so we can replace its contents wholesale + if err := cleanCommitBranch( + rc.repo.WorkingDir(), + rc.target.branchConfig.PreservedPaths, + ); err != nil { + return "", errors.Wrap(err, "error cleaning commit branch") } logger.Debug("cleaned commit branch") return commitBranch, nil } + +// cleanCommitBranch deletes the entire contents of the specified directory +// EXCEPT for the paths specified by preservedPaths. +func cleanCommitBranch(dir string, preservedPaths []string) error { + _, err := cleanDir( + dir, + normalizePreservedPaths( + dir, + append(preservedPaths, ".git", ".kargo-render"), + ), + ) + return err +} + +// normalizePreservedPaths converts the relative paths in the preservedPaths +// argument to absolute paths relative to the workingDir argument. It also +// removes any trailing path separators from the paths. +func normalizePreservedPaths( + workingDir string, + preservedPaths []string, +) []string { + normalizedPreservedPaths := make([]string, len(preservedPaths)) + for i, preservedPath := range preservedPaths { + if strings.HasSuffix(preservedPath, string(os.PathSeparator)) { + preservedPath = preservedPath[:len(preservedPath)-1] + } + normalizedPreservedPaths[i] = filepath.Join(workingDir, preservedPath) + } + return normalizedPreservedPaths +} + +// cleanDir recursively deletes the entire contents of the directory specified +// by the absolute path dir EXCEPT for any paths specified by the preservedPaths +// argument. The function returns true if dir is left empty afterwards and false +// otherwise. +func cleanDir(dir string, preservedPaths []string) (bool, error) { + items, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, item := range items { + path := filepath.Join(dir, item.Name()) + if isPathPreserved(path, preservedPaths) { + continue + } + if item.IsDir() { + var isEmpty bool + if isEmpty, err = cleanDir(path, preservedPaths); err != nil { + return false, err + } + if isEmpty { + if err = os.Remove(path); err != nil { + return false, err + } + } + } else if err = os.Remove(path); err != nil { + return false, err + } + } + if items, err = os.ReadDir(dir); err != nil { + return false, err + } + return len(items) == 0, nil +} + +// isPathPreserved returns true if the specified path is among those specified +// by the preservedPaths argument. Both path and preservedPaths MUST be absolute +// paths. Paths to directories MUST NOT end with a trailing path separator. +func isPathPreserved(path string, preservedPaths []string) bool { + for _, preservedPath := range preservedPaths { + if path == preservedPath { + return true + } + } + return false +} diff --git a/branches_test.go b/branches_test.go index 626d701..9750eae 100644 --- a/branches_test.go +++ b/branches_test.go @@ -1,6 +1,7 @@ package render import ( + "fmt" "os" "path/filepath" "testing" @@ -93,3 +94,166 @@ func TestWriteBranchMetadata(t *testing.T) { require.NoError(t, err) require.True(t, exists) } + +func TestCleanCommitBranch(t *testing.T) { + const subdirCount = 50 + const fileCount = 50 + // Create dummy repo dir + dir, err := createDummyCommitBranchDir(subdirCount, fileCount) + defer os.RemoveAll(dir) + require.NoError(t, err) + // Double-check the setup + dirEntries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, dirEntries, subdirCount+fileCount+2) + // Delete + err = cleanCommitBranch(dir, []string{}) + require.NoError(t, err) + // .git should not have been deleted + _, err = os.Stat(filepath.Join(dir, ".git")) + require.NoError(t, err) + // .kargo-render should not have been deleted + _, err = os.Stat(filepath.Join(dir, ".kargo-render")) + require.NoError(t, err) + // Everything else should be deleted + dirEntries, err = os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, dirEntries, 2) +} + +func TestNormalizePreservedPaths(t *testing.T) { + preservedPaths := []string{ + "foo/bar", + "bat/baz/", + } + normalizedPreservedPaths := + normalizePreservedPaths("fake-work-dir", preservedPaths) + require.Equal( + t, + []string{ + filepath.Join("fake-work-dir", "foo", "bar"), + filepath.Join("fake-work-dir", "bat", "baz"), + }, + normalizedPreservedPaths, + ) +} + +func TestCleanDir(t *testing.T) { + dir, err := os.MkdirTemp("", "") + defer os.RemoveAll(dir) + require.NoError(t, err) + + // This is what the test directory structure will look like: + // . + // ├── foo preserved directly + // │   └── foo.txt preserved because foo is + // ├── bar preserved because bar/bar.txt is + // │   └── bar.txt preserved directly + // ├── baz deleted because empty + // │   └── baz.txt deleted + // └── keep.txt preserved directly + + // Create the test directory structure + fooDir := filepath.Join(dir, "foo") + err = os.Mkdir(fooDir, 0755) + require.NoError(t, err) + fooFile := filepath.Join(fooDir, "foo.txt") + err = os.WriteFile(fooFile, []byte("foo"), 0600) + require.NoError(t, err) + + barDir := filepath.Join(dir, "bar") + err = os.Mkdir(barDir, 0755) + require.NoError(t, err) + barFile := filepath.Join(barDir, "bar.txt") + err = os.WriteFile(barFile, []byte("bar"), 0600) + require.NoError(t, err) + + bazDir := filepath.Join(dir, "baz") + err = os.Mkdir(bazDir, 0755) + require.NoError(t, err) + bazFile := filepath.Join(bazDir, "baz.txt") + err = os.WriteFile(bazFile, []byte("baz"), 0600) + require.NoError(t, err) + + keepFile := filepath.Join(dir, "keep.txt") + err = os.WriteFile(keepFile, []byte("keep"), 0600) + require.NoError(t, err) + + preservedPaths := []string{ + fooDir, + barFile, + keepFile, + } + + isEmpty, err := cleanDir(dir, preservedPaths) + require.NoError(t, err) + require.False(t, isEmpty) + + // Validate what was deleted and what wasn't + + // All of foo/ remains + _, err = os.Stat(fooDir) + require.NoError(t, err) + _, err = os.Stat(fooFile) + require.NoError(t, err) + + // All of bar/ remains + _, err = os.Stat(barDir) + require.NoError(t, err) + _, err = os.Stat(barFile) + require.NoError(t, err) + + // All of baz/ is gone + _, err = os.Stat(bazDir) + require.True(t, os.IsNotExist(err)) + + // keep.txt remains + _, err = os.Stat(keepFile) + require.NoError(t, err) +} + +func TestIsPathPreserved(t *testing.T) { + preservedPaths := []string{ + "/foo/bar", + "/foo/bat", + } + require.True(t, isPathPreserved("/foo/bar", preservedPaths)) + require.True(t, isPathPreserved("/foo/bat", preservedPaths)) + require.False(t, isPathPreserved("/foo/baz", preservedPaths)) +} + +func createDummyCommitBranchDir(dirCount, fileCount int) (string, error) { + // Create a directory + dir, err := os.MkdirTemp("", "") + if err != nil { + return dir, err + } + // Add a dummy .git/ subdir + if err = os.Mkdir(filepath.Join(dir, ".git"), 0755); err != nil { + return dir, err + } + // Add a dummy .kargo-render/ subdir + if err = os.Mkdir(filepath.Join(dir, ".kargo-render"), 0755); err != nil { + return dir, err + } + // Add some other dummy dirs + for i := 0; i < dirCount; i++ { + if err = os.Mkdir( + filepath.Join(dir, fmt.Sprintf("dir-%d", i)), + 0755, + ); err != nil { + return dir, err + } + } + // Add some dummy files + for i := 0; i < fileCount; i++ { + file, err := os.Create(filepath.Join(dir, fmt.Sprintf("file-%d", i))) + if err != nil { + return dir, err + } + if err = file.Close(); err != nil { + return dir, err + } + } + return dir, nil +} diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index e87f12a..335d8e8 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -3,9 +3,21 @@ package cli import ( "fmt" "os" + + log "github.com/sirupsen/logrus" ) func Run() { + // These two lines are required to suppress undesired log output from the Argo + // CD repo server, which Kargo Render uses as a library. This does NOT + // interfere with using the Kargo Render CLI's own --debug flag. + os.Setenv("ARGOCD_LOG_LEVEL", "PANIC") + log.SetLevel(log.PanicLevel) + // This line makes all log output go to stderr, leaving stdout for actual + // program output only. This is important for cases where machine readable + // output (e.g. JSON) is requested. + log.SetOutput(os.Stderr) + cmd, err := newRootCommand() if err != nil { fmt.Println(err) diff --git a/config.go b/config.go index c521a79..b37fe6b 100644 --- a/config.go +++ b/config.go @@ -12,20 +12,30 @@ import ( "github.com/pkg/errors" "github.com/xeipuuv/gojsonschema" + "github.com/akuity/kargo-render/internal/argocd" "github.com/akuity/kargo-render/internal/file" - "github.com/akuity/kargo-render/internal/helm" - "github.com/akuity/kargo-render/internal/kustomize" - "github.com/akuity/kargo-render/internal/ytt" _ "embed" ) //go:embed schema.json var configSchemaBytes []byte -var configSchemaJSONLoader gojsonschema.JSONLoader + +//go:embed argocd-schema.json +var argocdConfigSchemaBytes []byte + +var configSchema *gojsonschema.Schema func init() { - configSchemaJSONLoader = gojsonschema.NewBytesLoader(configSchemaBytes) + sl := gojsonschema.NewSchemaLoader() + if err := sl.AddSchema("argocd-schema.json", gojsonschema.NewBytesLoader(argocdConfigSchemaBytes)); err != nil { + panic(fmt.Sprintf("error adding Argo CD schema: %s", err)) + } + + var err error + if configSchema, err = sl.Compile(gojsonschema.NewBytesLoader(configSchemaBytes)); err != nil { + panic(fmt.Sprintf("error compiling schema: %s", err)) + } } // repoConfig encapsulates all Kargo Render configuration options for a @@ -48,7 +58,7 @@ func (r *repoConfig) GetBranchConfig(name string) (branchConfig, error) { } submatches := regex.FindStringSubmatch(name) if len(submatches) > 0 { - return cfg.expand(submatches), nil + return cfg.expand(submatches) } } } @@ -69,22 +79,42 @@ type branchConfig struct { // PRs encapsulates details about how to manage any pull requests associated // with this branch. PRs pullRequestConfig `json:"prs,omitempty"` + // PreservedPaths specifies paths relative to the root of the repository that + // should be exempted from pre-render cleaning (deletion) of + // environment-specific branch contents. This is useful for preserving any + // environment-specific files that are manually maintained. Typically there + // are very few such files, if any at all, with an environment-specific + // CODEOWNERS file at the root of the repository being the most emblematic + // exception. Paths may be to files or directories. Any path to a directory + // will cause that directory's entire contents to be preserved. + PreservedPaths []string `json:"preservedPaths,omitempty"` } -func (b branchConfig) expand(values []string) branchConfig { +func (b branchConfig) expand(values []string) (branchConfig, error) { cfg := b cfg.AppConfigs = map[string]appConfig{} for appName, appConfig := range b.AppConfigs { - cfg.AppConfigs[appName] = appConfig.expand(values) + var err error + if cfg.AppConfigs[appName], err = appConfig.expand(values); err != nil { + return cfg, errors.Wrapf( + err, + "error expanding app config for app %q", + appName, + ) + } } - return cfg + + for i, path := range b.PreservedPaths { + b.PreservedPaths[i] = file.ExpandPath(path, values) + } + return cfg, nil } // appConfig encapsulates application-specific Kargo Render configuration. type appConfig struct { // ConfigManagement encapsulates configuration management options to be // used with this branch and app. - ConfigManagement configManagementConfig `json:"configManagement,omitempty"` + ConfigManagement argocd.ConfigManagementConfig `json:"configManagement"` // OutputPath specifies a path relative to the root of the repository where // rendered manifests for this app will be stored in this branch. OutputPath string `json:"outputPath,omitempty"` @@ -93,40 +123,14 @@ type appConfig struct { CombineManifests bool `json:"combineManifests,omitempty"` } -func (a appConfig) expand(values []string) appConfig { +func (a appConfig) expand(values []string) (appConfig, error) { cfg := a - cfg.ConfigManagement = a.ConfigManagement.expand(values) - cfg.OutputPath = file.ExpandPath(a.OutputPath, values) - return cfg -} - -// configManagementConfig is a wrapper around more specific configuration for -// one of three supported configuration management tools: helm, kustomize, or -// ytt. Only one of its fields may be non-nil. -type configManagementConfig struct { // nolint: revive - // Helm encapsulates optional Helm configuration options. - Helm *helm.Config `json:"helm,omitempty"` - // Kustomize encapsulates optional Kustomize configuration options. - Kustomize *kustomize.Config `json:"kustomize,omitempty"` - // Ytt encapsulates optional ytt configuration options. - Ytt *ytt.Config `json:"ytt,omitempty"` -} - -func (c configManagementConfig) expand(values []string) configManagementConfig { - cfg := c - if c.Helm != nil { - helmCfg := c.Helm.Expand(values) - cfg.Helm = &helmCfg - } - if c.Kustomize != nil { - kustomizeCfg := c.Kustomize.Expand(values) - cfg.Kustomize = &kustomizeCfg - } - if c.Ytt != nil { - yttCfg := c.Ytt.Expand(values) - cfg.Ytt = &yttCfg + var err error + if cfg.ConfigManagement, err = a.ConfigManagement.Expand(values); err != nil { + return cfg, errors.Wrap(err, "error expanding config management config") } - return cfg + cfg.OutputPath = file.ExpandPath(a.OutputPath, values) + return cfg, nil } // pullRequestConfig encapsulates details related to PR management for a branch. @@ -198,10 +202,8 @@ func normalizeAndValidate(configBytes []byte) ([]byte, error) { return nil, errors.Wrap(err, "error normalizing Kargo Render configuration") } - validationResult, err := gojsonschema.Validate( - configSchemaJSONLoader, - gojsonschema.NewBytesLoader(configBytes), - ) + + validationResult, err := configSchema.Validate(gojsonschema.NewBytesLoader(configBytes)) if err != nil { return nil, errors.Wrap(err, "error validating Kargo Render configuration") } diff --git a/config_test.go b/config_test.go index 56a78bb..5252b29 100644 --- a/config_test.go +++ b/config_test.go @@ -7,10 +7,6 @@ import ( "testing" "github.com/stretchr/testify/require" - - "github.com/akuity/kargo-render/internal/helm" - "github.com/akuity/kargo-render/internal/kustomize" - "github.com/akuity/kargo-render/internal/ytt" ) func TestLoadRepoConfig(t *testing.T) { @@ -140,6 +136,72 @@ func TestNormalizeAndValidate(t *testing.T) { require.NoError(t, err) }, }, + { + name: "valid kustomize", + assertions: func(err error) { + require.NoError(t, err) + }, + config: []byte(`configVersion: v1alpha1 +branchConfigs: + - name: env/prod + appConfigs: + my-proj: + configManagement: + path: env/prod/my-proj + kustomize: + buildOptions: "--load-restrictor LoadRestrictionsNone" + outputPath: prod/my-proj + combineManifests: true`), + }, + { + name: "valid helm", + assertions: func(err error) { + require.NoError(t, err) + }, + config: []byte(`configVersion: v1alpha1 +branchConfigs: + - name: env/prod + appConfigs: + my-proj: + configManagement: + path: env/prod/my-proj + helm: + namespace: my-namespace + outputPath: prod/my-proj + combineManifests: true`), + }, + { + name: "valid no config management tool", + assertions: func(err error) { + require.NoError(t, err) + }, + config: []byte(`configVersion: v1alpha1 +branchConfigs: + - name: env/prod + appConfigs: + my-proj: + configManagement: + path: env/prod/my-proj + outputPath: prod/my-proj + combineManifests: true`), + }, + { + name: "invalid property", + assertions: func(err error) { + require.Error(t, err) + }, + config: []byte(`configVersion: v1alpha1 +branchConfigs: + - name: env/prod + appConfigs: + my-proj: + configManagement: + path: env/prod/my-proj + unknown: + hello: world + outputPath: prod/my-proj + combineManifests: true`), + }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { @@ -155,94 +217,3 @@ func TestNormalizeAndValidate(t *testing.T) { }) } } - -func TestExpandBranchConfig(t *testing.T) { - const val = "foo" - testCfg := branchConfig{ - AppConfigs: map[string]appConfig{ - "my-kustomize-app": { - ConfigManagement: configManagementConfig{ - Kustomize: &kustomize.Config{ - Path: "${0}", - }, - }, - OutputPath: "${0}", - }, - "my-helm-app": { - ConfigManagement: configManagementConfig{ - Helm: &helm.Config{ - ChartPath: "${0}", - ValuesPaths: []string{"${0}", "${0}"}, - }, - }, - OutputPath: "${0}", - }, - "my-ytt-app": { - ConfigManagement: configManagementConfig{ - Ytt: &ytt.Config{ - Paths: []string{"${0}", "${0}"}, - }, - }, - OutputPath: "${0}", - }, - }, - } - cfg := testCfg.expand([]string{val}) - require.Equal( - t, - val, - cfg.AppConfigs["my-kustomize-app"].ConfigManagement.Kustomize.Path, - ) - require.Equal( - t, - val, - cfg.AppConfigs["my-kustomize-app"].OutputPath, - ) - require.Equal( - t, - val, - cfg.AppConfigs["my-helm-app"].ConfigManagement.Helm.ChartPath, - ) - require.Equal( - t, - []string{val, val}, - cfg.AppConfigs["my-helm-app"].ConfigManagement.Helm.ValuesPaths, - ) - require.Equal( - t, - val, - cfg.AppConfigs["my-helm-app"].OutputPath, - ) - require.Equal( - t, - []string{val, val}, - cfg.AppConfigs["my-ytt-app"].ConfigManagement.Ytt.Paths, - ) - require.Equal( - t, - val, - cfg.AppConfigs["my-ytt-app"].OutputPath, - ) - // Check that the original testCfg.AppConfigs haven't been touched. - // References to maps are pointers, hence the extra care. - require.Equal( - t, - "${0}", - testCfg.AppConfigs["my-kustomize-app"].ConfigManagement.Kustomize.Path, - ) - require.Equal( - t, - "${0}", - testCfg.AppConfigs["my-helm-app"].ConfigManagement.Helm.ChartPath, - ) - require.Equal( - t, - []string{"${0}", "${0}"}, - testCfg.AppConfigs["my-helm-app"].ConfigManagement.Helm.ValuesPaths, - ) - require.Equal( - t, - []string{"${0}", "${0}"}, - testCfg.AppConfigs["my-ytt-app"].ConfigManagement.Ytt.Paths, - ) -} diff --git a/docs/docs/10-overview.md b/docs/docs/10-overview.md index e0118ab..67e057e 100644 --- a/docs/docs/10-overview.md +++ b/docs/docs/10-overview.md @@ -31,8 +31,21 @@ which you wish to render and store manifests. Kargo Render does the rest and you can point applicable configuration of your preferred GitOps-enabled CD platform at the environment branch. +## Kargo Render & Argo CD + +Kargo Render is compatible with Argo CD manifest rendering. Behind the scenes, Kargo Render uses +Argo CD repo server to generate final manifests. That includes the same config management settings as well as features +like automatic tool detection and +[git parameter overrides](https://argo-cd.readthedocs.io/en/stable/user-guide/parameters/#store-overrides-in-git). +This ensures painless back-and-forth switching between native Argo CD manifest generation and Kargo Render. + ## Getting started +[Kargo](https://kargo.akuity.io/), the application lifecycle platform +for Kubernetes, leverages Kargo Render to orchestrate manifests changes promotion. +You can also use Kargo Render as a standalone tool: + + 1. Kargo Render can be integrated into your GitOps practice in a variety of ways. Regardless of your entrypoint into its functionality, it relies on a common bit of configuration -- `kargo-render.yaml`. Read more about that diff --git a/docs/docs/20-environment-branches.md b/docs/docs/20-environment-branches.md index 8a31c75..1f67e66 100644 --- a/docs/docs/20-environment-branches.md +++ b/docs/docs/20-environment-branches.md @@ -13,8 +13,8 @@ begins with understanding some common difficulties encountered by To keep Kubernetes manifests concise and manageable, most GitOps practitioners incorporate some manner of configuration management tooling into their -deployments. [Kustomize](https://kustomize.io/), [ytt](https://carvel.dev/ytt/), -and [Helm](https://helm.sh/) are three popular examples of such tools. Although +deployments. [Kustomize](https://kustomize.io/), +and [Helm](https://helm.sh/) are two popular examples of such tools. Although they may employ widely varied approaches, tools in this class all enable the same fundamental capability -- maintaining a common set of "base" configuration that can be amended or patched in some way to suit each of the environments to @@ -22,7 +22,7 @@ which you might deploy your application. Continuous delivery platforms, like [Argo CD](https://argoproj.github.io/cd/) or [Flux](https://fluxcd.io/), commonly integrate with tools such as these. Argo -CD, for instance, can easily detect the use of Kustomize or Helm (but not ytt) +CD, for instance, can easily detect the use of Kustomize or Helm and utilize embedded versions of those tools to render such configuration into plain manifests that are appropriate for a given environment. While, at a glance, this may seem convenient, relying on these integrations to perform diff --git a/docs/docs/30-how-to-guides/10-configuration.mdx b/docs/docs/30-how-to-guides/10-configuration.mdx index 148b46e..3463f8e 100644 --- a/docs/docs/30-how-to-guides/10-configuration.mdx +++ b/docs/docs/30-how-to-guides/10-configuration.mdx @@ -62,6 +62,12 @@ examples that follow will involve _two_ applications -- `foo` and `bar`. When we render manifests for an environment branch, we wish to render manifests for _both_ apps. +## Config management tool configuration + +Behind the scene Kargo Render uses Argo CD Repo Server to render manifests. More +information about supported settings is available in Argo CD +[documentation](https://argo-cd.readthedocs.io/en/stable/user-guide/application_sources/). + @@ -78,37 +84,31 @@ branchConfigs: appConfigs: foo: configManagement: - kustomize: - path: env/test/foo # test overlay for app "foo" + path: env/test/foo # test overlay for app "foo" outputPath: foo bar: configManagement: - kustomize: - path: env/test/bar # test overlay for app "bar" + path: env/test/bar # test overlay for app "bar" outputPath: bar - name: env/stage appConfigs: foo: configManagement: - kustomize: - path: env/stage/foo # stage overlay for app "foo" + path: env/stage/foo # stage overlay for app "foo" outputPath: foo bar: configManagement: - kustomize: - path: env/stage/bar # stage overlay for app "bar" + path: env/stage/bar # stage overlay for app "bar" outputPath: bar - name: env/prod appConfigs: foo: configManagement: - kustomize: - path: env/prod/foo # prod overlay for app "foo" + path: env/prod/foo # prod overlay for app "foo" outputPath: foo bar: configManagement: - kustomize: - path: env/prod/bar # prod overlay for app "bar" + path: env/prod/bar # prod overlay for app "bar" outputPath: bar ``` @@ -118,71 +118,6 @@ for more information. - - -For each environment branch, the configuration below specifies the multiple -paths that should be used as arguments to the `ytt` command for each -application. It also specifies where within each environment branch to store the -rendered manifests for each application. - -```yaml -configVersion: v1alpha1 -branchConfigs: -- name: env/test - appConfigs: - foo: - configManagement: - ytt: - paths: # use all of these paths as args to the ytt command - - base/foo - - env/test/foo - outputPath: foo - bar: - configManagement: - ytt: - paths: # use all of these paths as args to the ytt command - - base/bar - - env/test/bar - outputPath: bar -- name: env/stage - appConfigs: - foo: - configManagement: - ytt: - paths: # use all of these paths as args to the ytt command - - base/foo - - env/stage/foo - outputPath: foo - bar: - configManagement: - ytt: - paths: # use all of these paths as args to the ytt command - - base/bar - - env/stage/bar - outputPath: bar -- name: env/prod - appConfigs: - foo: - configManagement: - ytt: - paths: # use all of these paths as args to the ytt command - - base/foo - - env/prod/foo - outputPath: foo - bar: - configManagement: - ytt: - paths: # use all of these paths as args to the ytt command - - base/bar - - env/prod/bar - outputPath: bar -``` - -Refer directly to [ytt's documentation](https://carvel.dev/ytt/) for more -information. - - - For each environment branch, the configuration below specifies a release name, @@ -197,60 +132,60 @@ branchConfigs: appConfigs: foo: configManagement: + path: charts/foo helm: releaseName: foo namespace: foo #optional - chartPath: charts/foo - valuesPaths: + valueFiles: - env/test/foo/values.yaml outputPath: foo bar: configManagement: + path: charts/bar helm: releaseName: bar namespace: bar #optional - chartPath: charts/bar - valuesPaths: + valuesFiles: - env/test/bar/values.yaml outputPath: bar - name: env/stage appConfigs: foo: configManagement: + path: charts/foo helm: releaseName: foo namespace: foo #optional - chartPath: charts/foo - valuesPaths: + valuesFiles: - env/stage/foo/values.yaml outputPath: foo bar: configManagement: + path: charts/bar helm: releaseName: bar namespace: bar #optional - chartPath: charts/bar - valuesPaths: + valuesFiles: - env/stage/bar/values.yaml outputPath: bar - name: env/prod appConfigs: foo: configManagement: + path: charts/foo helm: releaseName: foo namespace: foo #optional - chartPath: charts/foo - valuesPaths: + valuesFiles: - env/prod/foo/values.yaml outputPath: foo bar: configManagement: + path: charts/bar helm: releaseName: bar namespace: bar #optional - chartPath: charts/bar - valuesPaths: + valuesFiles: - env/prod/bar/values.yaml outputPath: bar ``` @@ -290,38 +225,11 @@ branchConfigs: appConfigs: foo: configManagement: - kustomize: - path: env/${1}/foo - outputPath: foo - bar: - configManagement: - kustomize: - path: env/${1}/bar - outputPath: bar -``` - - - - - -```yaml -configVersion: v1alpha1 -branchConfigs: -- pattern: env/(\w+) - appConfigs: - foo: - configManagement: - ytt: - paths: - - base/foo - - env/${1}/foo + path: env/${1}/foo outputPath: foo bar: configManagement: - ytt: - paths: - - base/bar - - env/${1}/bar + path: env/${1}/bar outputPath: bar ``` @@ -336,20 +244,20 @@ branchConfigs: appConfigs: foo: configManagement: + path: charts/foo helm: releaseName: foo namespace: foo #optional - chartPath: charts/foo - valuesPaths: + valuesFiles: - env/${1}/foo/values.yaml outputPath: foo bar: configManagement: + path: charts/bar helm: releaseName: bar namespace: bar #optional - chartPath: charts/bar - valuesPaths: + valuesFiles: - env/${1}/bar/values.yaml outputPath: bar ``` diff --git a/docs/docs/30-how-to-guides/20-github-actions.md b/docs/docs/30-how-to-guides/20-github-actions.md index 00aba17..06edf31 100644 --- a/docs/docs/30-how-to-guides/20-github-actions.md +++ b/docs/docs/30-how-to-guides/20-github-actions.md @@ -11,7 +11,7 @@ GitHub Actions, Kargo Render can be run as an action. :::info The Kargo Render action utilizes the official Kargo Render Docker image and therefore has guaranteed access to compatible versions of -Git, Helm, Kustomize, and ytt, which are included on that image. +Git, Helm, and Kustomize, which are included on that image. ::: Example usage: diff --git a/docs/docs/30-how-to-guides/30-docker-image.md b/docs/docs/30-how-to-guides/30-docker-image.md index 8e8b2ff..f6368ea 100644 --- a/docs/docs/30-how-to-guides/30-docker-image.md +++ b/docs/docs/30-how-to-guides/30-docker-image.md @@ -36,7 +36,7 @@ not limited to, popular choices such as [CircleCI](https://circleci.com/) or :::caution The `kargo-render` CLI is not designed to be run anywhere except within a container based on the official Kargo Render image. The official Kargo Render -image provides compatible versions of Kustomize, ytt, and Helm that cannot be +image provides compatible versions of Kustomize and Helm that cannot be guaranteed to exist on other systems. ::: @@ -44,5 +44,5 @@ guaranteed to exist on other systems. If you're using Kargo Render's [Go module](./go-module) to interact programmatically with Kargo Render, you might _also_ consider utilizing the Kargo Render Docker image as a base image for your own software since it will -guarantee the existence of compatible versions of Kustomize, ytt, and Helm. +guarantee the existence of compatible versions of Kustomize, and Helm. ::: \ No newline at end of file diff --git a/docs/docs/40-contributor-guide/10-hacking-on-kargo-render.md b/docs/docs/40-contributor-guide/10-hacking-on-kargo-render.md index 02eb901..c0bc0ef 100644 --- a/docs/docs/40-contributor-guide/10-hacking-on-kargo-render.md +++ b/docs/docs/40-contributor-guide/10-hacking-on-kargo-render.md @@ -66,7 +66,7 @@ make hack-build ``` :::note -Because Kargo Render is dependent on compatible versions of Git, Kustomize, ytt, +Because Kargo Render is dependent on compatible versions of Git, Kustomize, and Helm binaries, there is seldom, if ever, a reason to build or execute the Kargo Render binaries outside the context of a container that provides those dependencies. diff --git a/docs/docs/50-roadmap.md b/docs/docs/50-roadmap.md index 2953f45..0d1f558 100644 --- a/docs/docs/50-roadmap.md +++ b/docs/docs/50-roadmap.md @@ -9,4 +9,8 @@ Kargo Render is highly experimental at this time and breaking changes should be anticipated between pre-GA minor releases. ::: -Placeholder +## Argo CD Config Management Plugins + +An ability to configure and use Argo CD +[config management plugins](https://argo-cd.readthedocs.io/en/stable/operator-manual/config-management-plugins/) +for manifest generation. diff --git a/hack/fetch-argocd-schema.sh b/hack/fetch-argocd-schema.sh new file mode 100755 index 0000000..072e294 --- /dev/null +++ b/hack/fetch-argocd-schema.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$( cd "$(dirname "$0")" && pwd ) +ARGOCD_VERSION=$(cat "$SCRIPT_PATH"/../go.mod | grep github.com/argoproj/argo-cd | cut -d' ' -f2-) +curl https://raw.githubusercontent.com/argoproj/argo-cd/$ARGOCD_VERSION/manifests/crds/application-crd.yaml | yq -o=json | \ + jq '{"$schema": "http://json-schema.org/draft-07/schema#", "$id": "argocd-schema.json", "definitions": {helm: .spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.source.properties.helm, kustomize: .spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.source.properties.kustomize, plugin: .spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.source.properties.plugin}} ' > \ + "$SCRIPT_PATH"/../argocd-schema.json \ No newline at end of file diff --git a/internal/argocd/render.go b/internal/argocd/render.go new file mode 100644 index 0000000..67cb349 --- /dev/null +++ b/internal/argocd/render.go @@ -0,0 +1,151 @@ +package argocd + +import ( + "context" + "encoding/json" + + argoappv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/argoproj/argo-cd/v2/reposerver/apiclient" + "github.com/argoproj/argo-cd/v2/reposerver/repository" + "github.com/argoproj/argo-cd/v2/util/git" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/akuity/kargo-render/internal/file" + "github.com/akuity/kargo-render/internal/manifests" +) + +// ConfigManagementConfig is a wrapper around more specific configuration for +// the configuration management tools. Only one of its fields may be non-nil. +type ConfigManagementConfig struct { + Path string `json:"path,omitempty"` + Helm *ApplicationSourceHelm `json:"helm,omitempty"` + Kustomize *ApplicationSourceKustomize `json:"kustomize,omitempty"` + Directory *argoappv1.ApplicationSourceDirectory `json:"directory,omitempty"` + Plugin *argoappv1.ApplicationSourcePlugin `json:"plugin,omitempty"` +} + +// ApplicationSourceHelm holds configuration for Helm-based applications. +type ApplicationSourceHelm struct { + argoappv1.ApplicationSourceHelm + + Namespace string `json:"namespace,omitempty"` + K8SVersion string `json:"k8sVersion,omitempty"` + APIVersions []string `json:"apiVersions,omitempty"` + + RepoURL string `json:"repoURL,omitempty"` + Chart string `json:"chart,omitempty"` +} + +// ApplicationSourceKustomize holds configuration for Kustomize-based +// applications. +type ApplicationSourceKustomize struct { + argoappv1.ApplicationSourceKustomize + BuildOptions string `json:"buildOptions,omitempty"` +} + +func expand(item map[string]interface{}, values []string) { + for k, v := range item { + switch value := v.(type) { + case string: + item[k] = file.ExpandPath(value, values) + case map[string]interface{}: + expand(value, values) + case []interface{}: + for i, v := range value { + switch v := v.(type) { + case string: + value[i] = file.ExpandPath(v, values) + case map[string]interface{}: + expand(v, values) + } + } + } + } +} + +func (c ConfigManagementConfig) Expand( + values []string, +) (ConfigManagementConfig, error) { + data, err := json.Marshal(c) + if err != nil { + return c, err + } + var cfgMap map[string]interface{} + if err = json.Unmarshal(data, &cfgMap); err != nil { + return c, err + } + expand(cfgMap, values) + data, err = json.Marshal(cfgMap) + if err != nil { + return c, err + } + var cfg ConfigManagementConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return c, err + } + return cfg, nil +} + +func Render( + ctx context.Context, path string, + cfg ConfigManagementConfig, +) ([]byte, error) { + src := argoappv1.ApplicationSource{ + Plugin: cfg.Plugin, + } + var apiVersions []string + var namespace string + var k8sVersion string + if cfg.Helm != nil { + src.Helm = &cfg.Helm.ApplicationSourceHelm + apiVersions = cfg.Helm.APIVersions + namespace = cfg.Helm.Namespace + k8sVersion = cfg.Helm.K8SVersion + } + var kustomizeOptions *argoappv1.KustomizeOptions + if cfg.Kustomize != nil { + src.Kustomize = &cfg.Kustomize.ApplicationSourceKustomize + kustomizeOptions = &argoappv1.KustomizeOptions{ + BuildOptions: cfg.Kustomize.BuildOptions, + } + } + + res, err := repository.GenerateManifests( + ctx, + path, + // Seems ok for these next two arguments to be empty strings. If this is + // last mile rendering, we might be doing this in a directory outside of any + // repo. And event for regular rendering, we have already checked the + // revision we want. + "", // Repo root + "", // Revision + &apiclient.ManifestRequest{ + // Both of these fields need to be non-nil + Repo: &argoappv1.Repository{}, + ApplicationSource: &src, + KustomizeOptions: kustomizeOptions, + ApiVersions: apiVersions, + Namespace: namespace, + KubeVersion: k8sVersion, + }, + true, + &git.NoopCredsStore{}, // No need for this + // Allow any quantity of generated manifests + resource.MustParse("0"), + nil, + ) + if err != nil { + return nil, + errors.Wrap(err, "error generating manifests using Argo CD repo server") + } + + // res.Manifests contains JSON manifests. We want YAML. + yamlManifests, err := manifests.JSONStringsToYAMLBytes(res.Manifests) + if err != nil { + return nil, err + } + + // Glue the manifests together + return manifests.CombineYAML(yamlManifests), nil +} diff --git a/internal/argocd/render_test.go b/internal/argocd/render_test.go new file mode 100644 index 0000000..c38cd62 --- /dev/null +++ b/internal/argocd/render_test.go @@ -0,0 +1,30 @@ +package argocd + +import ( + "testing" + + argoappv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/stretchr/testify/require" +) + +func TestExpand(t *testing.T) { + cfg := ConfigManagementConfig{ + Path: "charts/foo", + Helm: &ApplicationSourceHelm{ + Namespace: "foo", + ApplicationSourceHelm: argoappv1.ApplicationSourceHelm{ + ReleaseName: "foo", + ValueFiles: []string{"env/${1}/foo/values.yaml"}, + Parameters: []argoappv1.HelmParameter{{ + Name: "env", + Value: "${1}", + }}, + }, + }, + } + expandedCfg, err := cfg.Expand([]string{"foo", "bar"}) + require.NoError(t, err) + + require.Equal(t, "env/bar/foo/values.yaml", expandedCfg.Helm.ValueFiles[0]) + require.Equal(t, "bar", expandedCfg.Helm.Parameters[0].Value) +} diff --git a/internal/helm/config.go b/internal/helm/config.go deleted file mode 100644 index 65fad9e..0000000 --- a/internal/helm/config.go +++ /dev/null @@ -1,38 +0,0 @@ -package helm - -import "github.com/akuity/kargo-render/internal/file" - -// Config encapsulates optional Helm configuration options. -type Config struct { - // ReleaseName specifies the release name that will be used when executing the - // `helm template` command. - ReleaseName string `json:"releaseName,omitempty"` - // ChartPath is a path to a directory, relative to the root of the repository, - // where a Helm chart can be located. This is used as an argument in the - // `helm template` command. By convention, if left unspecified, the value - // `base/` is assumed. - ChartPath string `json:"chartPath,omitempty"` - // Values are paths to Helm values files (e.g. values.yaml), relative to the - // root of the repository. Each of these will be used as a value for the - // `--values` flag in the `helm template` command. By convention, if left - // unspecified, one path will be assumed: /values.yaml. - ValuesPaths []string `json:"valuesPaths,omitempty"` - // Namespace is the Kubernetes namespace in which the Helm chart will be - // rendered. This is used as an argument in the `helm template` command. By - // convention, if left unspecified, the value `default` is assumed. - Namespace string `json:"namespace,omitempty"` -} - -// Expand expands all file/directory paths referenced by this configuration -// object, replacing placeholders of the form ${n} where n is a non-negative -// integer, with corresponding values from the provided string array. The -// modified object is returned. -func (c Config) Expand(values []string) Config { - cfg := c - cfg.ChartPath = file.ExpandPath(c.ChartPath, values) - cfg.ValuesPaths = make([]string, len(c.ValuesPaths)) - for i, pathTemplate := range c.ValuesPaths { - cfg.ValuesPaths[i] = file.ExpandPath(pathTemplate, values) - } - return cfg -} diff --git a/internal/helm/config_test.go b/internal/helm/config_test.go deleted file mode 100644 index f1dce44..0000000 --- a/internal/helm/config_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package helm - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestConfigExpand(t *testing.T) { - const val = "foo" - testCfg := Config{ - ChartPath: "${0}", - ValuesPaths: []string{"${0}", "${0}"}, - } - cfg := testCfg.Expand([]string{val}) - require.Equal(t, cfg.ChartPath, val) - require.Equal(t, cfg.ValuesPaths, []string{val, val}) - // Check that original testCfg.ValuesPaths are untouched. - // Slice references are pointers, hence the extra care. - require.Equal(t, []string{"${0}", "${0}"}, testCfg.ValuesPaths) -} diff --git a/internal/helm/helm.go b/internal/helm/helm.go deleted file mode 100644 index 4ea03c3..0000000 --- a/internal/helm/helm.go +++ /dev/null @@ -1,65 +0,0 @@ -package helm - -import ( - "context" - - argoappv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" - "github.com/argoproj/argo-cd/v2/reposerver/apiclient" - "github.com/argoproj/argo-cd/v2/reposerver/repository" - "github.com/argoproj/argo-cd/v2/util/git" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/api/resource" - - "github.com/akuity/kargo-render/internal/manifests" -) - -// Render delegates, in-process to the Argo CD repo server to render plain YAML -// manifests from a Helm chart. -func Render( - ctx context.Context, - releaseName string, - namespace string, - chartPath string, - valuesPaths []string, -) ([]byte, error) { - res, err := repository.GenerateManifests( - ctx, - chartPath, - // Seems ok for these next two arguments to be empty strings. If this is - // last mile rendering, we might be doing this in a directory outside of any - // repo. And event for regular rendering, we have already checked the - // revision we want. - "", // Repo root - "", // Revision - &apiclient.ManifestRequest{ - Namespace: namespace, - // Both of these fields need to be non-nil - Repo: &argoappv1.Repository{}, - ApplicationSource: &argoappv1.ApplicationSource{ - Helm: &argoappv1.ApplicationSourceHelm{ - ReleaseName: releaseName, - ValueFiles: valuesPaths, - }, - }, - }, - true, - &git.NoopCredsStore{}, // No need for this - // TODO: Don't completely understand this next arg, but @alexmt says this is - // right. Something to do with caching? - resource.MustParse("0"), - nil, - ) - if err != nil { - return nil, - errors.Wrap(err, "error generating manifests using Argo CD repo server") - } - - // res.Manifests contains JSON manifests. We want YAML. - yamlManifests, err := manifests.JSONStringsToYAMLBytes(res.Manifests) - if err != nil { - return nil, err - } - - // Glue the manifests together - return manifests.CombineYAML(yamlManifests), nil -} diff --git a/internal/kustomize/config.go b/internal/kustomize/config.go deleted file mode 100644 index 335b9f2..0000000 --- a/internal/kustomize/config.go +++ /dev/null @@ -1,32 +0,0 @@ -package kustomize - -import "github.com/akuity/kargo-render/internal/file" - -// Config encapsulates optional Kustomize configuration options. -type Config struct { - // Path is a path to a directory, relative to the root of the repository, - // where environment-specific Kustomize configuration for this branch can be - // located. This will be the directory from which `kustomize build` is - // executed. By convention, if left unspecified, the path is assumed to be - // identical to the name of the branch. - Path string `json:"path,omitempty"` - // EnableHelm specifies whether Kustomize's Helm Chart Inflator should be - // enabled. If left unspecified, it defaults to false -- not enabled. - EnableHelm bool `json:"enableHelm,omitempty"` - // LoadRestrictor specifies whether the Kustomization may load files from - // outside its root. If set to 'LoadRestrictionsNone', the Kustomization - // may load files from outside its root. If left unspecified, it defaults - // to `LoadRestrictionsRootOnly` - which restricts the Kustomization - // to only loading files from inside its root. - LoadRestrictor string `json:"loadRestrictor,omitempty"` -} - -// Expand expands all file/directory paths referenced by this configuration -// object, replacing placeholders of the form ${n} where n is a non-negative -// integer, with corresponding values from the provided string array. The -// modified object is returned. -func (c Config) Expand(values []string) Config { - cfg := c - cfg.Path = file.ExpandPath(c.Path, values) - return cfg -} diff --git a/internal/kustomize/config_test.go b/internal/kustomize/config_test.go deleted file mode 100644 index a4518ad..0000000 --- a/internal/kustomize/config_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package kustomize - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestConfigExpand(t *testing.T) { - const val = "foo" - testCfg := Config{ - Path: "${0}", - } - cfg := testCfg.Expand([]string{val}) - require.Equal(t, cfg.Path, val) -} diff --git a/internal/kustomize/kustomize.go b/internal/kustomize/kustomize.go index 9d38e29..bce781e 100644 --- a/internal/kustomize/kustomize.go +++ b/internal/kustomize/kustomize.go @@ -25,7 +25,6 @@ func Render( ctx context.Context, path string, images []string, - cfg Config, ) ([]byte, error) { kustomizeImages := make(argoappv1.KustomizeImages, len(images)) for i, image := range images { @@ -51,7 +50,6 @@ func Render( Images: kustomizeImages, }, }, - KustomizeOptions: buildKustomizeOptions(cfg), }, true, &git.NoopCredsStore{}, // No need for this @@ -74,21 +72,3 @@ func Render( // Glue the manifests together return manifests.CombineYAML(yamlManifests), nil } - -func buildKustomizeOptions(cfg Config) *argoappv1.KustomizeOptions { - buildOptions := "" - - if cfg.EnableHelm { - buildOptions += "--enable-helm " - } - - if cfg.LoadRestrictor != "" { - buildOptions += fmt.Sprintf("--load-restrictor %s", cfg.LoadRestrictor) - } else { - buildOptions += "--load-restrictor LoadRestrictionsRootOnly" - } - - return &argoappv1.KustomizeOptions{ - BuildOptions: buildOptions, - } -} diff --git a/internal/kustomize/kustomize_test.go b/internal/kustomize/kustomize_test.go deleted file mode 100644 index a22bbbd..0000000 --- a/internal/kustomize/kustomize_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package kustomize - -import ( - "testing" - - argoappv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" - "github.com/stretchr/testify/require" -) - -func TestBuildKustomizeOptions(t *testing.T) { - testCases := []struct { - name string - cfg Config - expected *argoappv1.KustomizeOptions - }{ - { - name: "Enable helm, no load restrictor", - cfg: Config{EnableHelm: true, LoadRestrictor: ""}, - expected: &argoappv1.KustomizeOptions{ - BuildOptions: "--enable-helm --load-restrictor LoadRestrictionsRootOnly", // nolint:all - }, - }, - { - name: "Disable helm, provide load restrictor", - cfg: Config{EnableHelm: false, LoadRestrictor: "LoadRestrictionsNone"}, - expected: &argoappv1.KustomizeOptions{ - BuildOptions: "--load-restrictor LoadRestrictionsNone", - }, - }, - { - name: "Disable helm, no load restrictor", - cfg: Config{EnableHelm: false, LoadRestrictor: ""}, - expected: &argoappv1.KustomizeOptions{ - BuildOptions: "--load-restrictor LoadRestrictionsRootOnly", - }, - }, - { - name: "Enable helm, provide load restrictor", - cfg: Config{EnableHelm: true, LoadRestrictor: "LoadRestrictionsNone"}, - expected: &argoappv1.KustomizeOptions{ - BuildOptions: "--enable-helm --load-restrictor LoadRestrictionsNone", - }, - }, - } - - for _, tc := range testCases { - actual := buildKustomizeOptions(tc.cfg) - require.Equal(t, tc.expected.BuildOptions, actual.BuildOptions) - } -} diff --git a/internal/ytt/config.go b/internal/ytt/config.go deleted file mode 100644 index 92d5463..0000000 --- a/internal/ytt/config.go +++ /dev/null @@ -1,26 +0,0 @@ -package ytt - -import "github.com/akuity/kargo-render/internal/file" - -// Config encapsulates optional ytt configuration options. -type Config struct { - // Paths are paths to directories or files, relative to the root of the - // repository, containing YTT templates or data. Each of these will be used as - // a value for the `--file` flag in the `ytt` command. By convention, if left - // unspecified, two paths are assumed: base/ and a path identical to the name - // of the branch. - Paths []string `json:"paths,omitempty"` -} - -// Expand expands all file/directory paths referenced by this configuration -// object, replacing placeholders of the form ${n} where n is a non-negative -// integer, with corresponding values from the provided string array. The -// modified object is returned. -func (c Config) Expand(values []string) Config { - cfg := c - cfg.Paths = make([]string, len(c.Paths)) - for i, pathTemplate := range c.Paths { - cfg.Paths[i] = file.ExpandPath(pathTemplate, values) - } - return cfg -} diff --git a/internal/ytt/config_test.go b/internal/ytt/config_test.go deleted file mode 100644 index 13b2995..0000000 --- a/internal/ytt/config_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package ytt - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestConfigExpand(t *testing.T) { - const val = "foo" - testCfg := Config{ - Paths: []string{"${0}", "${0}"}, - } - cfg := testCfg.Expand([]string{val}) - require.Equal(t, cfg.Paths, []string{val, val}) - // Check that original testCfg.Paths are untouched. - // Slice references are pointers, hence the extra care. - require.Equal(t, []string{"${0}", "${0}"}, testCfg.Paths) -} diff --git a/internal/ytt/ytt.go b/internal/ytt/ytt.go deleted file mode 100644 index dea5b8b..0000000 --- a/internal/ytt/ytt.go +++ /dev/null @@ -1,21 +0,0 @@ -package ytt - -import ( - "context" - "os/exec" - - libExec "github.com/akuity/kargo-render/internal/exec" -) - -// Render shells out to the ytt binary to render the provided paths into plain -// YAML manifests. Unlike in the case of Kustomize and Helm, this is not done -// with the help of the Argo CD repo server, since that does not yet support -// ytt. -func Render(_ context.Context, paths []string) ([]byte, error) { - cmdArgs := make([]string, len(paths)*2) - for i, path := range paths { - cmdArgs[i*2] = "--file" - cmdArgs[i*2+1] = path - } - return libExec.Exec(exec.Command("ytt", cmdArgs...)) -} diff --git a/kargo-logo.png b/kargo-logo.png new file mode 100644 index 0000000..9913044 Binary files /dev/null and b/kargo-logo.png differ diff --git a/logo.png b/logo.png deleted file mode 100644 index a610496..0000000 Binary files a/logo.png and /dev/null differ diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..08c8870 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,7 @@ +[build] + base = "docs/" + command = "yarn build" + publish = "build/" + +[context.deploy-preview] + ignore = "git diff --quiet main -- docs" diff --git a/rendering.go b/rendering.go index dcc3b3c..82624b4 100644 --- a/rendering.go +++ b/rendering.go @@ -7,7 +7,6 @@ import ( "path/filepath" "github.com/pkg/errors" - log "github.com/sirupsen/logrus" "github.com/akuity/kargo-render/internal/kustomize" "github.com/akuity/kargo-render/internal/strings" @@ -32,66 +31,11 @@ func (s *service) preRender( var err error for appName, appConfig := range rc.target.branchConfig.AppConfigs { appLogger := logger.WithField("app", appName) - if appConfig.ConfigManagement.Helm != nil { - chartPath := appConfig.ConfigManagement.Helm.ChartPath - if chartPath == "" { - chartPath = "base" - } - valuesPaths := appConfig.ConfigManagement.Helm.ValuesPaths - if len(valuesPaths) == 0 { - valuesPaths = - []string{filepath.Join(rc.request.TargetBranch, "values.yaml")} - } - appLogger = appLogger.WithFields(log.Fields{ - "configManagement": "helm", - "releaseName": appConfig.ConfigManagement.Helm.ReleaseName, - "namespace": appConfig.ConfigManagement.Helm.Namespace, - "chartPath": chartPath, - "valuesPaths": valuesPaths, - }) - chartPath = filepath.Join(repoDir, chartPath) - absValuesPaths := make([]string, len(valuesPaths)) - for i, valuesPath := range valuesPaths { - absValuesPaths[i] = filepath.Join(repoDir, valuesPath) - } - manifests[appName], err = s.helmRenderFn( - ctx, - appConfig.ConfigManagement.Helm.ReleaseName, - appConfig.ConfigManagement.Helm.Namespace, - chartPath, - absValuesPaths, - ) - } else if appConfig.ConfigManagement.Ytt != nil { - paths := appConfig.ConfigManagement.Ytt.Paths - if len(paths) == 0 { - paths = []string{"base", rc.request.TargetBranch} - } - appLogger = appLogger.WithFields(log.Fields{ - "configManagement": "ytt", - "paths": paths, - }) - absPaths := make([]string, len(paths)) - for i, path := range paths { - absPaths[i] = filepath.Join(repoDir, path) - } - manifests[appName], err = s.yttRenderFn(ctx, absPaths) - } else { - path := appConfig.ConfigManagement.Kustomize.Path - if path == "" { - path = rc.request.TargetBranch - } - appLogger = appLogger.WithFields(log.Fields{ - "configManagement": "kustomize", - "path": path, - }) - path = filepath.Join(repoDir, path) - manifests[appName], err = s.kustomizeRenderFn( - ctx, - path, - nil, - *appConfig.ConfigManagement.Kustomize, - ) - } + manifests[appName], err = s.renderFn( + ctx, + filepath.Join(repoDir, appConfig.ConfigManagement.Path), + appConfig.ConfigManagement, + ) if err != nil { return nil, err } @@ -196,7 +140,7 @@ func renderLastMile( ) } if manifests[appName], err = - kustomize.Render(ctx, appDir, images, kustomize.Config{}); err != nil { + kustomize.Render(ctx, appDir, images); err != nil { return nil, nil, errors.Wrapf( err, "error rendering manifests from %q", diff --git a/rendering_test.go b/rendering_test.go deleted file mode 100644 index 736ee96..0000000 --- a/rendering_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package render - -import ( - "context" - "testing" - - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" - - "github.com/akuity/kargo-render/internal/helm" - "github.com/akuity/kargo-render/internal/kustomize" - "github.com/akuity/kargo-render/internal/ytt" -) - -func TestPreRender(t *testing.T) { - const testAppName = "test-app" - fakeManifest := []byte("fake-manifest") - testCases := []struct { - name string - rc requestContext - service *service - assertions func(manifests map[string][]byte, err error) - }{ - { - name: "error pre-rendering with helm", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - testAppName: { - ConfigManagement: configManagementConfig{ - Helm: &helm.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - helmRenderFn: func( - context.Context, - string, - string, - string, - []string, - ) ([]byte, error) { - return nil, errors.New("something went wrong") - }, - }, - assertions: func(_ map[string][]byte, err error) { - require.Error(t, err) - require.Equal(t, "something went wrong", err.Error()) - }, - }, - { - name: "success pre-rendering with helm", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - testAppName: { - ConfigManagement: configManagementConfig{ - Helm: &helm.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - helmRenderFn: func( - context.Context, - string, - string, - string, - []string, - ) ([]byte, error) { - return fakeManifest, nil - }, - }, - assertions: func(manifests map[string][]byte, err error) { - require.NoError(t, err) - require.Equal(t, fakeManifest, manifests[testAppName]) - }, - }, - { - name: "error pre-rendering with ytt", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - "test-app": { - ConfigManagement: configManagementConfig{ - Ytt: &ytt.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - yttRenderFn: func(context.Context, []string) ([]byte, error) { - return nil, errors.New("something went wrong") - }, - }, - assertions: func(_ map[string][]byte, err error) { - require.Error(t, err) - require.Equal(t, "something went wrong", err.Error()) - }, - }, - { - name: "success pre-rendering with ytt", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - "test-app": { - ConfigManagement: configManagementConfig{ - Ytt: &ytt.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - yttRenderFn: func(context.Context, []string) ([]byte, error) { - return fakeManifest, nil - }, - }, - assertions: func(manifests map[string][]byte, err error) { - require.NoError(t, err) - require.Equal(t, fakeManifest, manifests[testAppName]) - }, - }, - { - name: "error pre-rendering with kustomize", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - "test-app": { - ConfigManagement: configManagementConfig{ - Kustomize: &kustomize.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - kustomizeRenderFn: func( - context.Context, - string, - []string, - kustomize.Config, - ) ([]byte, error) { - return nil, errors.New("something went wrong") - }, - }, - assertions: func(manifests map[string][]byte, err error) { - require.Error(t, err) - require.Equal(t, "something went wrong", err.Error()) - }, - }, - { - name: "success pre-rendering with kustomize", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - "test-app": { - ConfigManagement: configManagementConfig{ - Kustomize: &kustomize.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - kustomizeRenderFn: func( - context.Context, - string, - []string, - kustomize.Config, - ) ([]byte, error) { - return fakeManifest, nil - }, - }, - assertions: func(manifests map[string][]byte, err error) { - require.NoError(t, err) - require.Equal(t, fakeManifest, manifests[testAppName]) - }, - }, - { - name: "safeguards against empty manifests", - rc: requestContext{ - target: targetContext{ - branchConfig: branchConfig{ - AppConfigs: map[string]appConfig{ - "test-app": { - ConfigManagement: configManagementConfig{ - Kustomize: &kustomize.Config{}, - }, - }, - }, - }, - }, - }, - service: &service{ - kustomizeRenderFn: func( - context.Context, - string, - []string, - kustomize.Config, - ) ([]byte, error) { - return []byte{}, nil // This is probably a mistake! - }, - }, - assertions: func(manifests map[string][]byte, err error) { - require.Error(t, err) - require.Contains( - t, - err.Error(), - "contain 0 bytes; this looks like a mistake", - ) - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - testCase.rc.logger = &logrus.Entry{ - Logger: logrus.New(), - } - testCase.assertions( - testCase.service.preRender( - context.Background(), - testCase.rc, - "fake/repo/path", - ), - ) - }) - } -} diff --git a/schema.json b/schema.json index 6db64b9..b023a15 100644 --- a/schema.json +++ b/schema.json @@ -40,6 +40,12 @@ }, "prs": { "$ref": "#/definitions/pullRequestConfig" + }, + "preservedPaths": { + "type": "array", + "items": { + "$ref": "#/definitions/relativePath" + } } } }, @@ -62,90 +68,68 @@ "configManagementConfig": { "type": "object", - "oneOf": [ - { - "required": ["helm"], - "additionalProperties": false, - "properties": { - "helm": { - "$ref": "#/definitions/helmConfig" - } - } - }, - { - "required": ["kustomize"], - "additionalProperties": false, - "properties": { - "kustomize": { - "$ref": "#/definitions/kustomizeConfig" - } - } - }, - { - "required": ["ytt"], - "additionalProperties": false, - "properties": { - "ytt": { - "$ref": "#/definitions/yttConfig" - } - } - } - ] - }, - - "helmConfig": { - "type": "object", - "required": ["releaseName"], - "additionalProperties": false, + "required": ["path"], "properties": { - "releaseName": { - "type": "string", - "pattern": "^\\w[\\w-]*\\w$" - }, - "namespace": { - "type": "string", - "pattern": "^\\w[\\w-]*\\w$" - }, - "chartPath": { + "path": { "$ref": "#/definitions/relativePath" - }, - "valuesPaths": { - "type": "array", - "items": { - "$ref": "#/definitions/relativePath" + } + }, + "unevaluatedProperties": false, + "oneOf": [{ + "required": ["helm"], + "properties": { + "helm": { + "properties": { + "namespace": { + "type": "string" + }, + "k8sVersion": { + "type": "string" + }, + "apiVersions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "allOf": [{ + "#ref": "argocd-schema.json#/definitions/helm" + }] } } - } - }, - - "kustomizeConfig": { - "type": "object", - "additionalProperties": false, - "properties": { - "path": { - "$ref": "#/definitions/relativePath" - }, - "enableHelm": { - "type": "boolean" - }, - "loadRestrictor": { - "type": "string", - "enum": ["LoadRestrictionsRootOnly", "LoadRestrictionsNone"] + }, { + "required": ["kustomize"], + "properties": { + "kustomize": { + "properties": { + "buildOptions": { + "type": "string" + } + }, + "allOf": [{ + "#ref": "argocd-schema.json#/definitions/kustomize" + }] + } } - } - }, - - "yttConfig": { - "type": "object", - "additionalProperties": false, - "properties": { - "paths": { - "type": "array", - "items": { - "$ref": "#/definitions/relativePath" + }, { + "required": ["plugin"], + "properties": { + "plugin": { + "$ref": "argocd-schema.json#/definitions/plugin" } } - } + }, { + "additionalProperties": false, + "properties": { + "path": { + "$ref": "#/definitions/relativePath" + }, + "helm": false, + "kustomize": false, + "plugin": false + } + }] }, "pullRequestConfig": { diff --git a/service.go b/service.go index d12e4ba..c188c8f 100644 --- a/service.go +++ b/service.go @@ -10,10 +10,8 @@ import ( uuid "github.com/satori/go.uuid" log "github.com/sirupsen/logrus" - "github.com/akuity/kargo-render/internal/helm" - "github.com/akuity/kargo-render/internal/kustomize" + "github.com/akuity/kargo-render/internal/argocd" "github.com/akuity/kargo-render/internal/manifests" - "github.com/akuity/kargo-render/internal/ytt" "github.com/akuity/kargo-render/pkg/git" ) @@ -29,24 +27,11 @@ type Service interface { } type service struct { - logger *log.Logger - - // These behaviors are overridable for testing purposes - helmRenderFn func( - ctx context.Context, - releaseName string, - namespace string, - chartPath string, - valuesPaths []string, - ) ([]byte, error) - - yttRenderFn func(ctx context.Context, paths []string) ([]byte, error) - - kustomizeRenderFn func( + logger *log.Logger + renderFn func( ctx context.Context, path string, - images []string, - cfg kustomize.Config, + cfg argocd.ConfigManagementConfig, ) ([]byte, error) } @@ -62,10 +47,8 @@ func NewService(opts *ServiceOptions) Service { logger := log.New() logger.SetLevel(log.Level(opts.LogLevel)) return &service{ - logger: logger, - helmRenderFn: helm.Render, - yttRenderFn: ytt.Render, - kustomizeRenderFn: kustomize.Render, + logger: logger, + renderFn: argocd.Render, } } @@ -159,10 +142,8 @@ func (s *service) RenderManifests( if len(rc.target.branchConfig.AppConfigs) == 0 { rc.target.branchConfig.AppConfigs = map[string]appConfig{ "app": { - ConfigManagement: configManagementConfig{ - Kustomize: &kustomize.Config{ - Path: rc.request.TargetBranch, - }, + ConfigManagement: argocd.ConfigManagementConfig{ + Path: rc.request.TargetBranch, }, }, } diff --git a/service_test.go b/service_test.go index 8ef1b23..77314f7 100644 --- a/service_test.go +++ b/service_test.go @@ -16,9 +16,7 @@ func TestNewService(t *testing.T) { svc, ok := s.(*service) require.True(t, ok) require.NotNil(t, svc.logger) - require.NotNil(t, svc.helmRenderFn) - require.NotNil(t, svc.yttRenderFn) - require.NotNil(t, svc.kustomizeRenderFn) + require.NotNil(t, svc.renderFn) } func TestWriteAppManifests(t *testing.T) {