diff --git a/Makefile b/Makefile index 3290c097..6ad41fc1 100644 --- a/Makefile +++ b/Makefile @@ -44,5 +44,5 @@ cover-html: cover-generate @$(GOCMD) tool cover -html=$(REPOROOT)/coverage.txt .PHONY: test -test: +test: $(GOTEST) -v ./... diff --git a/README.md b/README.md index 0e1635e4..d0e7b13c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ charts: - name: theserver namespace: bar version: 0.0.1 - + - name: myservice namespace: foo version: 1.0.0 @@ -118,7 +118,7 @@ kubectl ### Contexts -**Ankh** configs are driven by *contexts*, like kubectl. +**Ankh** configs are driven by *contexts*, like kubectl. ``` $ cat ~/.ankh/config @@ -161,7 +161,7 @@ include: #### Context-aware yaml config -One of the primary features of Ankh is the ability to write context-aware yaml configuration for Helm charts. Often, it's necessary to have separate values for classes of operating environments, like `dev` and `production`. For example, we may want to set the log level or +One of the primary features of Ankh is the ability to write context-aware yaml configuration for Helm charts. Often, it's necessary to have separate values for classes of operating environments, like `dev` and `production`. For example, we may want to set the log level or ##### In a Helm chart: @@ -214,13 +214,13 @@ charts: - name: theserver namespace: bar version: 0.0.1 - + - name: myservice namespace: foo version: 1.0.0 ``` -When invoked, Ankh will operate over both the `haste-server` and `myservice` charts. +When invoked, Ankh will operate over both the `haste-server` and `myservice` charts. ## YAML schemas @@ -266,7 +266,7 @@ When invoked, Ankh will operate over both the `haste-server` and `myservice` cha ##### `Slack Message Variables` | Variable | Description -| ------------- | :---: +| ------------- | :---: | `%USER%` | Current username | | `%CHART%` | Current chart being used | | `%VERSION%` | Version of the primary container | @@ -284,7 +284,7 @@ Example format: `format: "_%USER%_ is releasing *%CHART%@%VERSION%* to *%TARGET% | ------------- | :---: | :-------------: | | kube-context | string | The kube context to use. This must be a valid context name present in your kube config (tyipcally ~/.kube/config or $KUBECONFIG). Prefer `kube-server` instead, which is less dependent on local configuration. | | kube-server | string | The kube server to use. This must be a valid Kubernetes API server. Similar to the `server` field in kubectl's `cluster` object. This can be used in place of `kube-context`, and should be preferred. | -| environment-class | string | Optional. The environment class to use. | +| environment-class | string | Optional. The environment class to use. | | resource-profile | string | Optional. The resource profile to use. | | release | string | Optional. The release name to use. This is passed to Helm as --release | | helm-registry-url | string | Optional. The URL to the Helm chart repo to use. Overrides the global Helm registry. Either this or the global registry must be defined. | @@ -301,7 +301,7 @@ Example format: `format: "_%USER%_ is releasing *%CHART%@%VERSION%* to *%TARGET% | Field | Type | Description | | ------------- | :---: | :-------------: | | name | string | The chart name. Must be the name of a chart in a Helm registry | -| namespace | string | The namespace to use when running `helm` and `kubectl`. Overrides `namespace` in an Ankh file. | +| namespace | string | The namespace to use when running `helm` and `kubectl`. Overrides `namespace` in an Ankh file. | | version | string | Optional. The chart version, if pulling from a Helm registry. | | path | string | Optional. The path to a local chart directory. Can be used instead of a remote `version` in a Helm registry. | | default-values | RawYaml | Optional. Values to use in all contexts. | diff --git a/ankh/main.go b/ankh/main.go index b8df4a86..9658b587 100644 --- a/ankh/main.go +++ b/ankh/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "io/ioutil" + "math/rand" "os" "os/signal" "path" @@ -78,67 +79,28 @@ func printContexts(ankhConfig *ankh.AnkhConfig) { } } -func promptForMissingConfigs(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile) error { - if ctx.NoPrompt { - for i := 0; i < len(ankhFile.Charts); i++ { - chart := &ankhFile.Charts[i] - - // Fetch and merge chart metadata - meta, err := helm.FetchChartMeta(ctx, chart) - if err != nil { - return fmt.Errorf("Error fetching chart \"%v\": %v", chart.Name, err) - } - mergo.Merge(&chart.ChartMeta, meta) - - // This logic is unfortunately duplicated in this function. - // The gist is that if ctx.Namespace is set then we have a - // command line override, which we'll use later. If the namespace - // is set on the ChartMeta, then we'll prioritize using that. - if ctx.Namespace == nil && chart.ChartMeta.Namespace == nil { - return fmt.Errorf("Missing namespace for chart \"%v\". To use this chart "+ - "without a namespace, use `ankh --namespace \"\" ...`", - chart.Name) - } - } - return nil - } - +func reconcileMissingConfigs(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile) error { // Make sure that we don't use the tag argument for more than one Chart. // When this happens, it is almost always an error, because a tag value // is typically only valid/intended for a single chart. tagArgumentUsedForChart := "" - // Prompt for a chart argument if there is no chart argument and - // there is no AnkhFile present on the filesystem. - if len(ankhFile.Charts) == 0 { - if _, err := os.Stat(ctx.AnkhFilePath); os.IsNotExist(err) { - ctx.Logger.Infof("No chart specified as an argument, and no `charts` found in an Ankh file") - charts, err := helm.GetChartNames(ctx) - if err != nil { - return err - } - - selectedChart, err := util.PromptForSelection(charts, "Select a chart") - if err != nil { - return err - } - - ankhFile.Charts = []ankh.Chart{ankh.Chart{Name: selectedChart}} - ctx.Logger.Infof("Using chart \"%v\" based on prompt selection", selectedChart) - } - } - // Prompt for chart versions if any are missing for i := 0; i < len(ankhFile.Charts); i++ { chart := &ankhFile.Charts[i] if chart.Path == "" && chart.Version == "" { + ctx.Logger.Infof("Found chart \"%v\" without a version", chart.Name) + if ctx.NoPrompt { + ctx.Logger.Fatalf("Chart \"%v\" missing version (and no 'path' set either, not prompting due to --no-prompt)", + chart.Name) + } + versions, err := helm.ListVersions(ctx, chart.Name, true) if err != nil { return err } - ctx.Logger.Infof("Found chart \"%v\" without a version", chart.Name) selectedVersion, err := util.PromptForSelection(strings.Split(strings.Trim(versions, "\n "), "\n"), fmt.Sprintf("Select a version for chart \"%v\"", chart.Name)) if err != nil { @@ -161,14 +123,22 @@ func promptForMissingConfigs(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile // If namespace is set on the command line, we'll use that as an // override later during executeChartsOnNamespace, so don't check // for anything here. + // - command line override, ankh file, chart meta. if ctx.Namespace == nil { - if ankhFile.Namespace != nil && chart.ChartMeta.Namespace == nil { + if ankhFile.Namespace != nil { + extraLog := "" + if chart.ChartMeta.Namespace == nil { + extraLog = " (overriding namespace \"%v\" from ankh.yaml present in the chart)" + } ctx.Logger.Infof("Using namespace \"%v\" from Ankh file "+ - "for chart \"%v\" which has no explicit namespace set", - *ankhFile.Namespace, chart.Name) + "for chart \"%v\"%v", + *ankhFile.Namespace, chart.Name, extraLog) chart.ChartMeta.Namespace = ankhFile.Namespace } else if chart.ChartMeta.Namespace == nil { ctx.Logger.Infof("Found chart \"%v\" without a namespace", chart.Name) + if ctx.NoPrompt { + ctx.Logger.Fatalf("Chart \"%v\" missing namespace (not prompting due to --no-prompt)", chart.Name) + } if len(ctx.AnkhConfig.Namespaces) > 0 { selectedNamespace, err := util.PromptForSelection(ctx.AnkhConfig.Namespaces, fmt.Sprintf("Select a namespace for chart '%v' (or re-run with -n/--namespace to provide your own)", chart.Name)) @@ -236,6 +206,20 @@ func promptForMissingConfigs(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile } } + // Treat any existing `tag` in `default-values` for this chart as the next-most authoritative + for k, v := range chart.DefaultValues { + if k == tagKey { + ctx.Logger.Infof("Using tag value \"%v=%s\" based on default-values present in the Ankh file", tagKey, v) + t, ok := v.(string) + if !ok { + ctx.Logger.Fatalf("Could not use value '%+v' from default-values in chart %v "+ + "as a string value for tagKey '%v'", v, chart.Name, tagKey) + } + chart.Tag = &t + break + } + } + // For certain operations, we can assume a safe `unset` value for tagKey // for the sole purpose of templating the Helm chart. The value won't be used // meaningfully (like it would be with apply), so we choose this method instead @@ -266,6 +250,11 @@ func promptForMissingConfigs(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile // If we stil don't have a chart.Tag value, prompt. if chart.Tag == nil { + if ctx.NoPrompt { + ctx.Logger.Fatalf("Chart \"%v\" missing value for `tagKey` (configured to be '%v', not prompting due to --no-prompt)", + tagKey, chart.Name) + } + image := "" if chart.ChartMeta.TagImage != "" { // No need to prompt for an image name if we already have one in the chart metdata @@ -297,10 +286,10 @@ func promptForMissingConfigs(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile ctx.Logger.Infof("Using implicit \"--set tag %v=%s\" based on prompt selection", tagKey, tag) chart.Tag = &tag } else if image != "" { - complaint := fmt.Sprintf("Could not determine a tag value, and we check for this because `tagKey` is configured to be `%v`. "+ + complaint := fmt.Sprintf("Chart \"%v\" missing value for `tagKey` (configured to be `%v`). "+ "You may want to try passing a tag value explicitly using `ankh --set %v=... `, or simply ignore "+ "this error entirely using `ankh --ignore-config-errors ...` (not recommended)", - tagKey, tagKey) + chart.Name, tagKey, tagKey) if ctx.IgnoreConfigErrors { ctx.Logger.Warnf("%v", complaint) } else { @@ -520,7 +509,7 @@ func executeChartsOnNamespace(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFil } func executeAnkhFile(ctx *ankh.ExecutionContext, ankhFile *ankh.AnkhFile) { - err := promptForMissingConfigs(ctx, ankhFile) + err := reconcileMissingConfigs(ctx, ankhFile) check(err) logExecuteAnkhFile(ctx, ankhFile) @@ -594,7 +583,9 @@ func executeContext(ctx *ankh.ExecutionContext, rootAnkhFile *ankh.AnkhFile) { } check(err) + ctx.WorkingPath = path.Dir(ankhFilePath) executeAnkhFile(ctx, &ankhFile) + ctx.WorkingPath = "" log.Infof("Finished satisfying dependency: %v", dep) } @@ -602,7 +593,22 @@ func executeContext(ctx *ankh.ExecutionContext, rootAnkhFile *ankh.AnkhFile) { if len(rootAnkhFile.Charts) > 0 { executeAnkhFile(ctx, rootAnkhFile) } else if len(dependencies) == 0 { - ctx.Logger.Fatalf("No charts nor dependencies provided, nothing to do") + if ctx.NoPrompt { + ctx.Logger.Fatalf("No charts nor dependencies provided, nothing to do") + } else if ctx.AnkhConfig.Helm.Registry != "" { + // Prompt for a chart + ctx.Logger.Infof("No chart specified as an argument, and no `charts` found in an Ankh file") + charts, err := helm.GetChartNames(ctx) + check(err) + + selectedChart, err := util.PromptForSelection(charts, "Select a chart") + check(err) + + rootAnkhFile.Charts = []ankh.Chart{ankh.Chart{Name: selectedChart}} + ctx.Logger.Infof("Using chart \"%v\" based on prompt selection", selectedChart) + + executeAnkhFile(ctx, rootAnkhFile) + } } } @@ -688,7 +694,7 @@ func main() { }) datadir = app.String(cli.StringOpt{ Name: "datadir", - Value: path.Join(os.Getenv("HOME"), ".ankh", "data"), + Value: path.Join("/tmp", ".ankh", "data"), Desc: "The data directory for Ankh template history", EnvVar: "ANKHDATADIR", }) @@ -743,7 +749,7 @@ func main() { Environment: *environment, Namespace: namespaceOpt, Tag: tagOpt, - DataDir: path.Join(*datadir, fmt.Sprintf("%v", time.Now().Unix())), + DataDir: path.Join(*datadir, fmt.Sprintf("%v-%v", time.Now().Unix(), rand.Intn(100000))), Logger: log, HelmSetValues: helmVars, IgnoreContextAndEnv: ctx.IgnoreContextAndEnv, diff --git a/config/testdata/testconfig.yaml b/config/testdata/testconfig.yaml index ed34a1fe..33abeaa1 100644 --- a/config/testdata/testconfig.yaml +++ b/config/testdata/testconfig.yaml @@ -1,26 +1,18 @@ -# current-context defines which context you currently have "selected". It's -# common for this value to change frequently as you move around to different -# clusters. -current-context: minikube - -# supported-environment-classes define a list of your supported environment classes. Used for -# validation of ankh files. It's best practice to keep this value in sync with -# the rest of your teams so there isn't drift. -supported-environment-classes: - - dev - - production - -# supported-resource-profiles define a list of your supported profiles. Used for -# validation of ankh files. Resource profiles are typically used for values that relate -# to how beefy your app needs to be. If you're running a production cluster that -# customers can hit, you'll probably want to support a value like "natural". -# Alternatively if you want your app to run on a resource strapped local -# cluster, you might consider also supporting something like "constrained". It's -# best practice to keep this value in sync with the rest of your teams so there -# isn't drift. -supported-resource-profiles: - - natural - - constrained +# the helm registry instructs ankh where to pull charts from +helm: + registry: https://kubernetes-charts.storage.googleapis.com/ + +docker: + registry: https://registry.docker.io/ + +# enables sending of release message to specified slack team and channel. +slack: + token: foobar123 + username: random-foobar + icon-url: foobar.com/myimage.jpg + format: "_%USER%_ is releasing *%CHART%@%VERSION%* to *%TARGET%*" + rollbackFormat: "_%USER%_ is rolling back *%CHART%* in *%TARGET%*" + pretext: Release notification # contexts are the different ways in which your ankh files can be deployed to # kubernetes clusters. Each key in this object is a context and the names can be @@ -30,32 +22,19 @@ contexts: # kube-context ties this context to a `kubectl` context kube-context: minikube + # ...or use kube-server to simply use a URL for accessing Kubernetes + #kube-server: some-kube-server.coolcompany.net + # environment-class should be one of your `supported-environment-classes` defined above environment-class: dev # resource-profile should be one of your `supported-resource-profiles` defined above resource-profile: natural - # release name provided to helm - empty by default - release: "" - - # helm-registry-url instructs ankh where to pull charts from - helm-registry-url: https://kubernetes-charts.storage.googleapis.com/ - - # cluster-admin controls if the `admin-dependencies` in an ankh file are - # executed before the rest of the `dependencies` - cluster-admin: true + # release name provided to helm + release: minikube # global can be any nested objects with values that need to be passed to # every chart. Arrays are not supported within `global`. global: foo: bar - -# enables sending of release message to specified slack team and channel. -slack: - token: foobar123 - username: random-foobar - icon-url: foobar.com/myimage.jpg - format: "_%USER%_ is releasing *%CHART%@%VERSION%* to *%TARGET%*" - rollbackFormat: "_%USER%_ is rolling back *%CHART%* in *%TARGET%*" - pretext: Release notification diff --git a/context/context.go b/context/context.go index 00ac6242..13948dd1 100644 --- a/context/context.go +++ b/context/context.go @@ -44,6 +44,7 @@ type ExecutionContext struct { Verbose, Quiet, ShouldCatchSignals, CatchSignals, DryRun, Describe, WarnOnConfigError, IgnoreContextAndEnv, IgnoreConfigErrors, NoPrompt bool + WorkingPath string AnkhConfigPath string KubeConfigPath string Context string @@ -202,12 +203,21 @@ func (ankhConfig *AnkhConfig) ValidateAndInit(ctx *ExecutionContext, context str CurrentContextUnused: kubeContext.Name, } - kubeConfigPath := path.Join(ctx.DataDir, "kubeconfig.yaml") + kubeConfigDir := path.Join(ctx.DataDir, "kubeconfig", + // Extra forward slashes for the scheme seems wrong. So change them + // to underscores, or whatever. + strings.Replace(selectedContext.KubeServer, "/", "_", -1)) + if err := os.MkdirAll(kubeConfigDir, 0755); err != nil { + return []error{err} + } + + kubeConfigPath := path.Join(kubeConfigDir, "kubeconfig.yaml") kubeConfigBytes, err := yaml.Marshal(kubeConfig) if err != nil { return []error{err} } + ctx.Logger.Debugf("Using kubeConfigPath %v", kubeConfigPath) if err := ioutil.WriteFile(kubeConfigPath, kubeConfigBytes, 0644); err != nil { return []error{err} } @@ -257,11 +267,13 @@ type ChartFiles struct { } type Chart struct { - Path string - Name string - Version string - Tag *string - ChartMeta ChartMeta `yaml:"meta"` + Path string + Name string + Version string + Tag *string + // Overrides any global Helm registry + HelmRegistry string + ChartMeta ChartMeta `yaml:"meta"` // DefaultValues are values that apply unconditionally, with lower precedence than values supplied in the fields below. DefaultValues map[string]interface{} `yaml:"default-values"` // Values, by environment-class, resource-profile, or release. MapSlice preserves map ordering so we can regex search from top to bottom. diff --git a/docs/sample-ankh-config.yaml b/docs/sample-ankh-config.yaml index c2b5e45d..33abeaa1 100644 --- a/docs/sample-ankh-config.yaml +++ b/docs/sample-ankh-config.yaml @@ -1,15 +1,40 @@ +# the helm registry instructs ankh where to pull charts from +helm: + registry: https://kubernetes-charts.storage.googleapis.com/ + +docker: + registry: https://registry.docker.io/ + +# enables sending of release message to specified slack team and channel. +slack: + token: foobar123 + username: random-foobar + icon-url: foobar.com/myimage.jpg + format: "_%USER%_ is releasing *%CHART%@%VERSION%* to *%TARGET%*" + rollbackFormat: "_%USER%_ is rolling back *%CHART%* in *%TARGET%*" + pretext: Release notification + # contexts are the different ways in which your ankh files can be deployed to # kubernetes clusters. Each key in this object is a context and the names can be # whatever you like. contexts: minikube: - # kube-context ties this context to a `kubectl` context in your local kube config + # kube-context ties this context to a `kubectl` context kube-context: minikube + + # ...or use kube-server to simply use a URL for accessing Kubernetes + #kube-server: some-kube-server.coolcompany.net + + # environment-class should be one of your `supported-environment-classes` defined above environment-class: dev + + # resource-profile should be one of your `supported-resource-profiles` defined above resource-profile: natural + + # release name provided to helm release: minikube - helm-registry-url: https://kubernetes-charts.storage.googleapis.com/ - docker-registry-url: https://registry.docker.io/ - # global can be any nested objects with values that need to be passed to every chart. + + # global can be any nested objects with values that need to be passed to + # every chart. Arrays are not supported within `global`. global: foo: bar diff --git a/helm/helm.go b/helm/helm.go index f19ccb8a..9d5adf39 100644 --- a/helm/helm.go +++ b/helm/helm.go @@ -42,13 +42,19 @@ func findChartFilesImpl(ctx *ankh.ExecutionContext, chart ankh.Chart) (ankh.Char name := chart.Name version := chart.Version + chartPath := chart.Path dirErr := os.ErrNotExist - if version == "" && chart.Path != "" { - ctx.Logger.Debugf("Considering directory %v for chart %v", chart.Path, name) - _, dirErr = os.Stat(chart.Path) + if version == "" && chartPath != "" { + if ctx.WorkingPath != "" { + chartPath = filepath.Join(ctx.WorkingPath, chart.Path) + ctx.Logger.Debugf("Using chartPath %v since WorkingPath is %v", + chartPath, ctx.WorkingPath) + } + ctx.Logger.Debugf("Considering directory %v for chart %v", chartPath, name) + _, dirErr = os.Stat(chartPath) if dirErr != nil { return files, fmt.Errorf("Could not use directory %v for chart %v: %v", - chart.Path, name, dirErr) + chartPath, name, dirErr) } } @@ -66,17 +72,22 @@ func findChartFilesImpl(ctx *ankh.ExecutionContext, chart ankh.Chart) (ankh.Char // make changes to the ankh specific yaml files before passing them as `-f` // args to `helm template` if dirErr == nil { - if err := util.CopyDir(chart.Path, filepath.Join(tmpDir, name)); err != nil { + if err := util.CopyDir(chartPath, filepath.Join(tmpDir, name)); err != nil { return files, err } } else { - // TODO: Eventually, only support the global helm registry + // Check for registies in the following order of precedence: + // - global, context, chart. registry := ctx.AnkhConfig.Helm.Registry - if registry == "" { + if ctx.AnkhConfig.CurrentContext.HelmRegistryURL != "" { + // TODO: Deprecate me registry = ctx.AnkhConfig.CurrentContext.HelmRegistryURL } + if chart.HelmRegistry != "" { + registry = chart.HelmRegistry + } if registry == "" { - return files, fmt.Errorf("No helm registry configured. Set `helm.registry` globally, or `See README.md on where to specify a helm registry.") + return files, fmt.Errorf("No helm registry configured. Set `helm.registry` globally, or see README.md on where to specify a helm registry.") } // We cannot pull down a chart without a version @@ -622,7 +633,7 @@ func Publish(ctx *ankh.ExecutionContext) error { return fmt.Errorf("error running helm command '%v': %v%v", strings.Join(helmCmd.Args, " "), err, outputMsg) } - ctx.Logger.Infof("Finished packaging '%v:%v'", chartYaml.Name, chartYaml.Version) + ctx.Logger.Infof("Finished packaging '%v-%v'", chartYaml.Name, chartYaml.Version) // Open up and read the contents of the package in order to PUT it upstream localTarballFile, err := os.Open(localTarballPath)