Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pkg/scaffold/role,pkg/operator-sdk/new.go: generate role rules from helm chart #1188

Merged
merged 9 commits into from
Apr 11, 2019
6 changes: 5 additions & 1 deletion Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion cmd/operator-sdk/new/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"sigs.k8s.io/controller-runtime/pkg/client/config"
)

func NewCmd() *cobra.Command {
Expand Down Expand Up @@ -281,6 +282,15 @@ func doHelmScaffold() error {
valuesPath := filepath.Join("<project_dir>", helm.HelmChartsDir, chart.GetMetadata().GetName(), "values.yaml")
crSpec := fmt.Sprintf("# Default values copied from %s\n\n%s", valuesPath, chart.GetValues().GetRaw())

k8sCfg, err := config.GetConfig()
if err != nil {
return fmt.Errorf("failed to get kubernetes config: %s", err)
}
roleScaffold, err := helm.CreateRoleScaffold(k8sCfg, chart, isClusterScoped)
if err != nil {
return fmt.Errorf("failed to generate role scaffold: %s", err)
}

s := &scaffold.Scaffold{}
err = s.Execute(cfg,
&helm.Dockerfile{},
Expand All @@ -289,7 +299,7 @@ func doHelmScaffold() error {
ChartName: chart.GetMetadata().GetName(),
},
&scaffold.ServiceAccount{},
&scaffold.Role{IsClusterScoped: isClusterScoped},
roleScaffold,
&scaffold.RoleBinding{IsClusterScoped: isClusterScoped},
&helm.Operator{IsClusterScoped: isClusterScoped},
&scaffold.CRD{Resource: resource},
Expand Down
51 changes: 38 additions & 13 deletions internal/pkg/scaffold/helm/chart.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package helm

import (
"bytes"
"fmt"
"io/ioutil"
"os"
Expand Down Expand Up @@ -134,7 +135,20 @@ func CreateChart(projectDir string, opts CreateChartOptions) (*scaffold.Resource
if err != nil {
return nil, nil, err
}
log.Infof("Created %s/%s/", HelmChartsDir, c.GetMetadata().GetName())

relChartPath := filepath.Join(HelmChartsDir, c.GetMetadata().GetName())
absChartPath := filepath.Join(projectDir, relChartPath)
if err := fetchChartDependencies(absChartPath); err != nil {
joelanford marked this conversation as resolved.
Show resolved Hide resolved
return nil, nil, err
}

// Reload chart in case dependencies changed
c, err = chartutil.Load(absChartPath)
if err != nil {
return nil, nil, err
}

log.Infof("Created %s", relChartPath)
return r, c, nil
}

Expand All @@ -159,7 +173,7 @@ func scaffoldChart(destDir, apiVersion, kind string) (*scaffold.Resource, *chart
return nil, nil, err
}

chart, err := chartutil.LoadDir(chartPath)
chart, err := chartutil.Load(chartPath)
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -198,17 +212,7 @@ func fetchChart(destDir string, opts CreateChartOptions) (*scaffold.Resource, *c
}

func createChartFromDisk(destDir, source string, isDir bool) (*chart.Chart, error) {
var (
chart *chart.Chart
err error
)

// If source is a file or directory, attempt to load it
if isDir {
chart, err = chartutil.LoadDir(source)
} else {
chart, err = chartutil.LoadFile(source)
}
chart, err := chartutil.Load(source)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -265,3 +269,24 @@ func createChartFromRemote(destDir string, opts CreateChartOptions) (*chart.Char

return createChartFromDisk(destDir, chartArchive, false)
}

func fetchChartDependencies(chartPath string) error {
helmHome, ok := os.LookupEnv(environment.HomeEnvVar)
if !ok {
helmHome = environment.DefaultHelmHome
}
getters := getter.All(environment.EnvSettings{})

out := &bytes.Buffer{}
man := &downloader.Manager{
Out: out,
ChartPath: chartPath,
HelmHome: helmpath.Home(helmHome),
Getters: getters,
}
if err := man.Build(); err != nil {
fmt.Println(out.String())
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit but can we wrap this and other errors like in Line 148. Either here or in the caller.

Otherwise we could end up with error messages that aren't too clear like in #1281
Right now I think this could end up showing something like:

failed to create helm chart: requirements.yaml cannot be opened: ....

https://github.com/helm/helm/blob/master/pkg/downloader/manager.go#L83

whereas I'd prefer to see:

failed to create helm chart: failed to fetch/build chart dependencies: requirements.yaml cannot be opened: ....

}
return nil
}
241 changes: 241 additions & 0 deletions internal/pkg/scaffold/helm/role.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Copyright 2019 The Operator-SDK Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package helm

import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"

"github.com/operator-framework/operator-sdk/internal/pkg/scaffold"

"github.com/ghodss/yaml"
log "github.com/sirupsen/logrus"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/version"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"k8s.io/helm/pkg/chartutil"
"k8s.io/helm/pkg/manifest"
"k8s.io/helm/pkg/proto/hapi/chart"
"k8s.io/helm/pkg/renderutil"
"k8s.io/helm/pkg/tiller"
)

// CreateRoleScaffold generates a role scaffold from the provided helm chart. It
// renders a release manifest using the chart's default values and uses the Kubernetes
// discovery API to lookup each resource in the resulting manifest.
func CreateRoleScaffold(cfg *rest.Config, chart *chart.Chart, isClusterScoped bool) (*scaffold.Role, error) {
log.Info("Generating RBAC rules")

roleScaffold := &scaffold.Role{
IsClusterScoped: isClusterScoped,
SkipDefaultRules: true,
// TODO: enable metrics in helm operator
joelanford marked this conversation as resolved.
Show resolved Hide resolved
SkipMetricsRules: true,
CustomRules: []rbacv1.PolicyRule{
// We need this rule so tiller can read namespaces to ensure they exist
{
APIGroups: []string{""},
Resources: []string{"namespaces"},
Verbs: []string{"get"},
},

// We need this rule for leader election and release state storage to work
{
APIGroups: []string{""},
Resources: []string{"configmaps", "secrets"},
Verbs: []string{rbacv1.VerbAll},
},
},
}

clusterResourceRules, namespacedResourceRules, err := generateRoleRules(cfg, chart)
if err != nil {
log.Warnf("Using default RBAC rules: failed to generate RBAC rules: %s", err)
roleScaffold.SkipDefaultRules = false
}

if !isClusterScoped {
// If there are cluster-scoped resources, but we're creating a namespace-scoped operator,
// log all of the cluster-scoped resources, and return a helpful error message.
for _, rule := range clusterResourceRules {
for _, resource := range rule.Resources {
log.Errorf("Resource %s.%s is cluster-scoped, but --cluster-scoped was not set.", resource, rule.APIGroups[0])
}
}
if len(clusterResourceRules) > 0 {
return nil, errors.New("must use --cluster-scoped with chart containing cluster-scoped resources")
}

// If we're here, there are no cluster-scoped resources, so add just the rules for namespaced resources
roleScaffold.CustomRules = append(roleScaffold.CustomRules, namespacedResourceRules...)
} else {
// For a cluster-scoped operator, add all of the rules
roleScaffold.CustomRules = append(roleScaffold.CustomRules, append(clusterResourceRules, namespacedResourceRules...)...)
}

log.Warn("The RBAC rules generated in deploy/role.yaml are based on the chart's default manifest." +
" Some rules may be missing for resources that are only enabled with custom values, and" +
" some existing rules may be overly broad. Double check the rules generated in deploy/role.yaml" +
" to ensure they meet the operator's permission requirements.")

return roleScaffold, nil
}

func generateRoleRules(cfg *rest.Config, chart *chart.Chart) ([]rbacv1.PolicyRule, []rbacv1.PolicyRule, error) {
kubeVersion, serverResources, err := getServerVersionAndResources(cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to get server info: %s", err)
}

manifests, err := getDefaultManifests(chart, kubeVersion)
if err != nil {
return nil, nil, fmt.Errorf("failed to get default manifest: %s", err)
}

// Use maps of sets of resources, keyed by their group. This helps us
// de-duplicate resources within a group as we traverse the manifests.
clusterGroups := map[string]map[string]struct{}{}
namespacedGroups := map[string]map[string]struct{}{}

for _, m := range manifests {
name := m.Name
content := strings.TrimSpace(m.Content)

// Ignore NOTES.txt, helper manifests, and empty manifests.
b := filepath.Base(name)
if b == "NOTES.txt" {
continue
}
if strings.HasPrefix(b, "_") {
continue
}
if content == "" || content == "---" {
continue
}

// Extract the gvk from the template
resource := unstructured.Unstructured{}
err := yaml.Unmarshal([]byte(content), &resource)
if err != nil {
log.Warnf("Skipping rule generation for %s. Failed to parse manifest: %s", name, err)
continue
}
groupVersion := resource.GetAPIVersion()
group := resource.GroupVersionKind().Group
kind := resource.GroupVersionKind().Kind

// If we don't have the group or the kind, we won't be able to
// create a valid role rule, log a warning and continue.
if groupVersion == "" {
log.Warnf("Skipping rule generation for %s. Failed to determine resource apiVersion.", name)
continue
}
if kind == "" {
log.Warnf("Skipping rule generation for %s. Failed to determine resource kind.", name)
continue
}

if resourceName, namespaced, ok := getResource(serverResources, groupVersion, kind); ok {
if !namespaced {
if clusterGroups[group] == nil {
clusterGroups[group] = map[string]struct{}{}
}
clusterGroups[group][resourceName] = struct{}{}
} else {
if namespacedGroups[group] == nil {
namespacedGroups[group] = map[string]struct{}{}
}
namespacedGroups[group][resourceName] = struct{}{}
}
} else {
log.Warnf("Skipping rule generation for %s. Failed to determine resource scope for %s.", name, resource.GroupVersionKind())
continue
}
}

// convert map[string]map[string]struct{} to []rbacv1.PolicyRule
clusterRules := buildRulesFromGroups(clusterGroups)
namespacedRules := buildRulesFromGroups(namespacedGroups)

return clusterRules, namespacedRules, nil
}

func getServerVersionAndResources(cfg *rest.Config) (*version.Info, []*metav1.APIResourceList, error) {
dc, err := discovery.NewDiscoveryClientForConfig(cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to create discovery client: %s", err)
}
kubeVersion, err := dc.ServerVersion()
if err != nil {
return nil, nil, fmt.Errorf("failed to get kubernetes server version: %s", err)
}
serverResources, err := dc.ServerResources()
if err != nil {
return nil, nil, fmt.Errorf("failed to get kubernetes server resources: %s", err)
}
return kubeVersion, serverResources, nil
}

func getDefaultManifests(c *chart.Chart, kubeVersion *version.Info) ([]tiller.Manifest, error) {
renderOpts := renderutil.Options{
ReleaseOptions: chartutil.ReleaseOptions{
IsInstall: true,
IsUpgrade: false,
},
KubeVersion: fmt.Sprintf("%s.%s", kubeVersion.Major, kubeVersion.Minor),
}

renderedTemplates, err := renderutil.Render(c, &chart.Config{}, renderOpts)
if err != nil {
return nil, fmt.Errorf("failed to render chart templates: %s", err)
}
return tiller.SortByKind(manifest.SplitManifests(renderedTemplates)), nil
}

func getResource(namespacedResourceList []*metav1.APIResourceList, groupVersion, kind string) (string, bool, bool) {
for _, apiResourceList := range namespacedResourceList {
if apiResourceList.GroupVersion == groupVersion {
for _, apiResource := range apiResourceList.APIResources {
if apiResource.Kind == kind {
return apiResource.Name, apiResource.Namespaced, true
}
}
}
}
return "", false, false
}

func buildRulesFromGroups(groups map[string]map[string]struct{}) []rbacv1.PolicyRule {
rules := []rbacv1.PolicyRule{}
for group, resourceNames := range groups {
resources := []string{}
for resource := range resourceNames {
resources = append(resources, resource)
}
sort.Strings(resources)
rules = append(rules, rbacv1.PolicyRule{
APIGroups: []string{group},
Resources: resources,
Verbs: []string{rbacv1.VerbAll},
})
}
return rules
}
Loading