Procházet zdrojové kódy

secrets creation on postrendering

Alexander Belanger před 5 roky
rodič
revize
70427e1f03

+ 2 - 0
go.mod

@@ -14,6 +14,7 @@ require (
 	github.com/coreos/rkt v1.30.0
 	github.com/creack/pty v1.1.11 // indirect
 	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
+	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
@@ -27,6 +28,7 @@ require (
 	github.com/go-redis/redis/v7 v7.4.0
 	github.com/go-redis/redis/v8 v8.3.1
 	github.com/go-test/deep v1.0.7
+	github.com/google/go-cmp v0.5.2
 	github.com/google/go-github v17.0.0+incompatible
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/googleapis/gnostic v0.2.2 // indirect

+ 2 - 0
go.sum

@@ -1476,6 +1476,8 @@ k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M
 k8s.io/client-go v0.16.8/go.mod h1:WmPuN0yJTKHXoklExKxzo3jSXmr3EnN+65uaTb5VuNs=
 k8s.io/client-go v0.18.8 h1:SdbLpIxk5j5YbFr1b7fq8S7mDgDjYmUxSbszyoesoDM=
 k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU=
+k8s.io/client-go v1.5.1 h1:XaX/lo2/u3/pmFau8HN+sB5C/b4dc4Dmm2eXjBH4p1E=
+k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwnJo9o=
 k8s.io/code-generator v0.16.8/go.mod h1:wFdrXdVi/UC+xIfLi+4l9elsTT/uEF61IfcN2wOLULQ=
 k8s.io/code-generator v0.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 k8s.io/component-base v0.16.8/go.mod h1:Q8UWOWShpP3MZZny4n/15gOncfaaVtc9SbCdkM5MhUE=

+ 323 - 0
internal/helm/postrenderer.go

@@ -0,0 +1,323 @@
+package helm
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"regexp"
+	"strings"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"gopkg.in/yaml.v2"
+	"helm.sh/helm/v3/pkg/postrender"
+
+	"github.com/docker/distribution/reference"
+)
+
+// DockerSecretsPostRenderer is a Helm post-renderer that adds image pull secrets to
+// pod specs that would otherwise be unable to pull an image.
+//
+// The post-renderer currently looks for two types of registries: GCR and ECR (TODO: DOCR
+// and Dockerhub). It also detects if the image pull secret is necessary: if GCR image pulls
+// occur in a GKE cluster in the same project, or if ECR image pulls exist in an EKS cluster
+// in the same organization + region, an image pull is not necessary.
+type DockerSecretsPostRenderer struct {
+	Cluster   *models.Cluster
+	Repo      repository.Repository
+	Agent     *kubernetes.Agent
+	Namespace string
+
+	registries map[string]*models.Registry
+
+	podSpecs []resource
+}
+
+// while manifests are map[string]interface{} at the top level,
+// nested keys will be of type map[interface{}]interface{}
+type resource map[interface{}]interface{}
+
+func NewDockerSecretsPostRenderer() (postrender.PostRenderer, error) {
+	// Registries is a map of registry URLs to registry ids. Input
+	// registries should be parsed of protocol, trailing slashes, and
+	// whitespace -- TODO
+
+	return &DockerSecretsPostRenderer{
+		podSpecs: make([]resource, 0),
+	}, nil
+}
+
+func (d *DockerSecretsPostRenderer) Run(
+	renderedManifests *bytes.Buffer,
+) (modifiedManifests *bytes.Buffer, err error) {
+	bufCopy := bytes.NewBuffer(renderedManifests.Bytes())
+
+	linkedRegs, err := d.getRegistriesToLink(bufCopy)
+
+	// if we encountered an error here, we'll render the manifests anyway
+	// without modification
+	if err != nil {
+		return renderedManifests, nil
+	}
+
+	// create the necessary secrets
+	secrets, err := d.Agent.CreateImagePullSecrets(
+		d.Repo,
+		d.Namespace,
+		linkedRegs,
+	)
+
+	fmt.Println(secrets, err)
+
+	return renderedManifests, nil
+}
+
+func (d *DockerSecretsPostRenderer) getRegistriesToLink(renderedManifests *bytes.Buffer) (map[string]*models.Registry, error) {
+	// create a map of registry names to registries: these are the registries
+	// that a secret will be generated for, if it does not exist
+	linkedRegs := make(map[string]*models.Registry)
+
+	resources, err := d.decodeRenderedManifests(renderedManifests)
+
+	if err != nil {
+		return linkedRegs, err
+	}
+
+	// read the pod specs into the post-renderer object
+	d.getPodSpecs(resources)
+
+	for _, podSpec := range d.podSpecs {
+		// get all images
+		images := d.getImageList(podSpec)
+
+		// read the image url
+		for _, image := range images {
+			named, err := reference.ParseNormalizedNamed(image)
+
+			if err != nil {
+				continue
+			}
+
+			domain := reference.Domain(named)
+			path := reference.Path(named)
+
+			regName := domain
+
+			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
+				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
+			}
+
+			// check if the integration is native to the cluster/registry combination
+			isNative := d.isRegistryNative(regName)
+
+			if isNative {
+				continue
+			}
+
+			reg, exists := d.registries[regName]
+
+			if !exists {
+				continue
+			}
+
+			// if the registry exists, add it to the map
+			linkedRegs[regName] = reg
+		}
+	}
+
+	return linkedRegs, nil
+}
+
+func (d *DockerSecretsPostRenderer) decodeRenderedManifests(
+	renderedManifests *bytes.Buffer,
+) ([]resource, error) {
+	// use the yaml decoder to parse the multi-document yaml.
+	decoder := yaml.NewDecoder(renderedManifests)
+
+	resources := make([]resource, 0)
+
+	for {
+		res := make(resource)
+		err := decoder.Decode(&res)
+		if err == io.EOF {
+			break
+		}
+
+		if err != nil {
+			return nil, err
+		}
+
+		resources = append(resources, res)
+	}
+
+	return resources, nil
+}
+
+func (d *DockerSecretsPostRenderer) getPodSpecs(resources []resource) {
+	for _, res := range resources {
+		kindVal, hasKind := res["kind"]
+		if !hasKind {
+			continue
+		}
+
+		kind, ok := kindVal.(string)
+		if !ok {
+			continue
+		}
+
+		// manifests of list type will have an items field, items should
+		// be recursively parsed
+		if itemsVal, isList := res["items"]; isList {
+			if items, ok := itemsVal.([]interface{}); ok {
+				// convert items to resource
+				resArr := make([]resource, 0)
+				for _, item := range items {
+					if arrVal, ok := item.(resource); ok {
+						resArr = append(resArr, arrVal)
+					}
+				}
+
+				d.getPodSpecs(resArr)
+			}
+
+			continue
+		}
+
+		// otherwise, get the pod spec based on the type of resource
+		podSpec := getPodSpecFromResource(kind, res)
+
+		if podSpec == nil {
+			continue
+		}
+
+		d.podSpecs = append(d.podSpecs, podSpec)
+	}
+
+	return
+}
+
+func (d *DockerSecretsPostRenderer) getImageList(podSpec resource) []string {
+	images := make([]string, 0)
+
+	containersVal, hasContainers := podSpec["containers"]
+
+	if !hasContainers {
+		return images
+	}
+
+	containers, ok := containersVal.([]interface{})
+
+	if !ok {
+		return images
+	}
+
+	for _, container := range containers {
+		_container, ok := container.(resource)
+
+		if !ok {
+			continue
+		}
+
+		image, ok := _container["image"].(string)
+
+		if !ok {
+			continue
+		}
+
+		images = append(images, image)
+	}
+
+	return images
+}
+
+var ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?`)
+
+func (d *DockerSecretsPostRenderer) isRegistryNative(regName string) bool {
+	isNative := false
+
+	if strings.Contains(regName, "gcr") && d.Cluster.AuthMechanism == models.GCP {
+		// get the project id of the cluster
+		gcpInt, err := d.Repo.GCPIntegration.ReadGCPIntegration(d.Cluster.GCPIntegrationID)
+
+		if err != nil {
+			return false
+		}
+
+		gkeProjectID, err := integrations.GCPProjectIDFromJSON(gcpInt.GCPKeyData)
+
+		if err != nil {
+			return false
+		}
+
+		// parse the project id of the gcr url
+		if regNameArr := strings.Split(regName, "/"); len(regNameArr) >= 2 {
+			gcrProjectID := regNameArr[1]
+
+			isNative = gcrProjectID == gkeProjectID
+		}
+	} else if strings.Contains(regName, "ecr") && d.Cluster.AuthMechanism == models.AWS {
+		matches := ecrPattern.FindStringSubmatch(regName)
+
+		if len(matches) < 3 {
+			return false
+		}
+
+		eksAccountID := matches[1]
+		eksRegion := matches[3]
+
+		awsInt, err := d.Repo.AWSIntegration.ReadAWSIntegration(d.Cluster.AWSIntegrationID)
+
+		if err != nil {
+			return false
+		}
+
+		err = awsInt.PopulateAWSArn()
+
+		if err != nil {
+			return false
+		}
+
+		parsedARN, err := arn.Parse(awsInt.AWSArn)
+
+		if err != nil {
+			return false
+		}
+
+		isNative = parsedARN.AccountID == eksAccountID && parsedARN.Region == eksRegion
+	}
+
+	return isNative
+}
+
+func getPodSpecFromResource(kind string, res resource) resource {
+	switch kind {
+	case "Pod":
+		return getNestedResource(res, "spec")
+	case "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet":
+		return getNestedResource(res, "spec", "template", "spec")
+	case "PodTemplate":
+		return getNestedResource(res, "template", "spec")
+	case "CronJob":
+		return getNestedResource(res, "spec", "jobTemplate", "spec", "template", "spec")
+	}
+
+	return nil
+}
+
+func getNestedResource(res resource, keys ...string) resource {
+	curr := res
+	var ok bool
+
+	for _, key := range keys {
+		curr, ok = curr[key].(resource)
+
+		if !ok {
+			return nil
+		}
+	}
+
+	return curr
+}

+ 118 - 0
internal/helm/postrenderer_test.go

@@ -0,0 +1,118 @@
+package helm_test
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/porter-dev/porter/internal/helm"
+)
+
+func TestDockerSecretsPostRenderer(t *testing.T) {
+	testCases := []struct {
+		name   string
+		input  *bytes.Buffer
+		output *bytes.Buffer
+	}{
+		{
+			name: "it appends relevant image pull secret for nested lists of resources",
+			input: bytes.NewBuffer([]byte(`apiVersion: v1
+kind: PodTemplateList
+metadata:
+  annotations:
+    annotation-1: some-annotation
+  name: image-secret-test
+items:
+- kind: PodTemplate
+  template:
+    spec:
+      containers:
+      - command:
+        - sh
+        - -c
+        - echo 'foo'
+        env:
+        - name: SOME_ENV
+          value: env_value
+        image: example.com/bitnami/nginx:1.16.1-debian-10-r42
+        name: container-name
+      restartPolicy: Never
+- kind: PodTemplate
+  template:
+    spec:
+      containers:
+      - command:
+        - sh
+        - -c
+        - echo 'bar'
+        env:
+        - name: SOME_ENV
+          value: env_value
+        image: example.com/bitnami/nginx:1.16.1-debian-10-r42
+        name: container-name
+      restartPolicy: Never
+---
+kind: Unknown
+other: doc
+`)),
+			output: bytes.NewBuffer([]byte(`apiVersion: v1
+items:
+- kind: PodTemplate
+  template:
+    spec:
+      containers:
+      - command:
+        - sh
+        - -c
+        - echo 'foo'
+        env:
+        - name: SOME_ENV
+          value: env_value
+        image: example.com/bitnami/nginx:1.16.1-debian-10-r42
+        name: container-name
+      imagePullSecrets:
+      - name: secret-1
+      restartPolicy: Never
+- kind: PodTemplate
+  template:
+    spec:
+      containers:
+      - command:
+        - sh
+        - -c
+        - echo 'bar'
+        env:
+        - name: SOME_ENV
+          value: env_value
+        image: example.com/bitnami/nginx:1.16.1-debian-10-r42
+        name: container-name
+      imagePullSecrets:
+      - name: secret-1
+      restartPolicy: Never
+kind: PodTemplateList
+metadata:
+  annotations:
+    annotation-1: some-annotation
+  name: image-secret-test
+---
+kind: Unknown
+other: doc
+`)),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			r, err := helm.NewDockerSecretsPostRenderer()
+			if err != nil {
+				t.Fatalf("%+v", err)
+			}
+
+			renderedManifests, err := r.Run(tc.input)
+
+			if got, want := renderedManifests.String(), tc.output.String(); !cmp.Equal(got, want) {
+				t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, got))
+			}
+		})
+	}
+}

+ 80 - 0
internal/kubernetes/agent.go

@@ -2,6 +2,7 @@ package kubernetes
 
 import (
 	"bufio"
+	"bytes"
 	"context"
 	"fmt"
 	"io"
@@ -13,6 +14,8 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/registry"
+	"github.com/porter-dev/porter/internal/repository"
 
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
@@ -20,6 +23,7 @@ import (
 	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
@@ -332,3 +336,79 @@ func (a *Agent) provision(
 		metav1.CreateOptions{},
 	)
 }
+
+// CreateImagePullSecrets will create the required image pull secrets and
+// return a map from the registry name to the name of the secret.
+func (a *Agent) CreateImagePullSecrets(
+	repo repository.Repository,
+	namespace string,
+	linkedRegs map[string]*models.Registry,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	for key, val := range linkedRegs {
+		_reg := registry.Registry(*val)
+
+		data, err := _reg.GetDockerConfigJSON(repo)
+
+		if err != nil {
+			return nil, err
+		}
+
+		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
+
+		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+			context.TODO(),
+			secretName,
+			metav1.GetOptions{},
+		)
+
+		// if not found, create the secret
+		if err != nil && errors.IsNotFound(err) {
+			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.CreateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			continue
+		} else if err != nil {
+			return nil, err
+		}
+
+		// otherwise, check that the secret contains the correct data: if
+		// if doesn't, update it
+		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
+			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
+				context.TODO(),
+				&v1.Secret{
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+				},
+				metav1.UpdateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// add secret name to the map
+		res[key] = secretName
+	}
+
+	return res, nil
+}

+ 38 - 0
internal/models/integrations/gcp.go

@@ -2,6 +2,7 @@ package integrations
 
 import (
 	"context"
+	"encoding/json"
 
 	"golang.org/x/oauth2/google"
 	"gorm.io/gorm"
@@ -112,3 +113,40 @@ func (g *GCPIntegration) GetBearerToken(
 
 	return tok.AccessToken, nil
 }
+
+// credentialsFile is the unmarshalled representation of a GCP credentials file.
+// Source; golang.org/x/oauth2/google
+type credentialsFile struct {
+	Type string `json:"type"` // serviceAccountKey or userCredentialsKey
+
+	// Service Account fields
+	ClientEmail  string `json:"client_email"`
+	PrivateKeyID string `json:"private_key_id"`
+	PrivateKey   string `json:"private_key"`
+	TokenURL     string `json:"token_uri"`
+	ProjectID    string `json:"project_id"`
+
+	// User Credential fields
+	// (These typically come from gcloud auth.)
+	ClientSecret string `json:"client_secret"`
+	ClientID     string `json:"client_id"`
+	RefreshToken string `json:"refresh_token"`
+
+	// External Account fields
+	Audience                       string `json:"audience"`
+	SubjectTokenType               string `json:"subject_token_type"`
+	TokenURLExternal               string `json:"token_url"`
+	TokenInfoURL                   string `json:"token_info_url"`
+	ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
+	// CredentialSource               externalaccount.CredentialSource `json:"credential_source"`
+	QuotaProjectID string `json:"quota_project_id"`
+}
+
+func GCPProjectIDFromJSON(jsonData []byte) (string, error) {
+	var f credentialsFile
+	if err := json.Unmarshal(jsonData, &f); err != nil {
+		return "", err
+	}
+
+	return f.ProjectID, nil
+}

+ 109 - 0
internal/registry/registry.go

@@ -1,9 +1,11 @@
 package registry
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/aws/aws-sdk-go/service/ecr"
@@ -11,6 +13,9 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	"github.com/docker/cli/cli/config/configfile"
+	"github.com/docker/cli/cli/config/types"
 )
 
 // Registry wraps the gorm Registry model
@@ -309,3 +314,107 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 
 	return res, nil
 }
+
+// GetDockerConfigJSON returns a dockerconfigjson file contents with "auths"
+// populated.
+func (r *Registry) GetDockerConfigJSON(repo repository.Repository) ([]byte, error) {
+	var conf *configfile.ConfigFile
+	var err error
+
+	// switch on the auth mechanism to get a token
+	if r.AWSIntegrationID != 0 {
+		conf, err = r.getECRDockerConfigFile(repo)
+	}
+
+	if r.GCPIntegrationID != 0 {
+		conf, err = r.getGCRDockerConfigFile(repo)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return json.Marshal(conf)
+}
+
+func (r *Registry) getECRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	aws, err := repo.AWSIntegration.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, err
+	}
+
+	ecrSvc := ecr.New(sess)
+
+	output, err := ecrSvc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	token := *output.AuthorizationData[0].AuthorizationToken
+
+	decodedToken, err := base64.StdEncoding.DecodeString(token)
+
+	if err != nil {
+		return nil, err
+	}
+
+	parts := strings.SplitN(string(decodedToken), ":", 2)
+
+	if len(parts) < 2 {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			key: types.AuthConfig{
+				Username: parts[0],
+				Password: parts[1],
+				Auth:     token,
+			},
+		},
+	}, nil
+}
+
+func (r *Registry) getGCRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	token, err := r.GetGCRToken(repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			key: types.AuthConfig{
+				Username: "oauth2accesstoken",
+				Password: string(token.Token),
+				Auth:     string(token.Token),
+			},
+		},
+	}, nil
+}