From 39c7d4698cfbbf70c902772b045168922d0f5cb0 Mon Sep 17 00:00:00 2001 From: Sanskar Jaiswal Date: Fri, 4 Mar 2022 00:28:14 +0530 Subject: [PATCH] Update helmtestserver to sign charts using openpgp Add functionality to sign packaged charts using an openpgp keyring. Add tests related to helmtestserver. This helps test consumers to in chart verification using a provenance file. Signed-off-by: Sanskar Jaiswal --- helmtestserver/go.mod | 2 +- helmtestserver/server.go | 60 +++++++++++++- helmtestserver/server_test.go | 82 +++++++++++++++++++ helmtestserver/testdata/helmchart/.helmignore | 23 ++++++ helmtestserver/testdata/helmchart/Chart.yaml | 24 ++++++ .../testdata/helmchart/templates/NOTES.txt | 22 +++++ .../testdata/helmchart/templates/_helpers.tpl | 62 ++++++++++++++ .../helmchart/templates/deployment.yaml | 61 ++++++++++++++ .../templates/tests/test-connection.yaml | 15 ++++ helmtestserver/testdata/helmchart/values.yaml | 62 ++++++++++++++ 10 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 helmtestserver/server_test.go create mode 100644 helmtestserver/testdata/helmchart/.helmignore create mode 100644 helmtestserver/testdata/helmchart/Chart.yaml create mode 100644 helmtestserver/testdata/helmchart/templates/NOTES.txt create mode 100644 helmtestserver/testdata/helmchart/templates/_helpers.tpl create mode 100644 helmtestserver/testdata/helmchart/templates/deployment.yaml create mode 100644 helmtestserver/testdata/helmchart/templates/tests/test-connection.yaml create mode 100644 helmtestserver/testdata/helmchart/values.yaml diff --git a/helmtestserver/go.mod b/helmtestserver/go.mod index 424f9785..439b0900 100644 --- a/helmtestserver/go.mod +++ b/helmtestserver/go.mod @@ -6,6 +6,7 @@ replace github.com/fluxcd/pkg/testserver => ../testserver require ( github.com/fluxcd/pkg/testserver v0.2.0 + golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 helm.sh/helm/v3 v3.8.0 sigs.k8s.io/yaml v1.3.0 ) @@ -102,7 +103,6 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca // indirect go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect - golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 // indirect golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d // indirect golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect diff --git a/helmtestserver/server.go b/helmtestserver/server.go index c1e7e344..67fe8183 100644 --- a/helmtestserver/server.go +++ b/helmtestserver/server.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +Copyright 2020, 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,9 +17,12 @@ limitations under the License. package helmtestserver import ( + "crypto/rand" + "encoding/hex" "os" "path/filepath" + "golang.org/x/crypto/openpgp" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/repo" "sigs.k8s.io/yaml" @@ -27,6 +30,10 @@ import ( "github.com/fluxcd/pkg/testserver" ) +const ( + keyRingName = "TestUser" +) + // NewTempHelmServer returns a HTTP HelmServer with a newly created // temp dir as repository docroot. func NewTempHelmServer() (*HelmServer, error) { @@ -69,9 +76,58 @@ func (s *HelmServer) PackageChart(path string) error { // with the given version, to be served by the HelmServer. It returns an // error in case of a packaging failure. func (s *HelmServer) PackageChartWithVersion(path, version string) error { + return s.packageChart(path, version, "") +} + +// PackageSignedChartWithVersion attempts to package the chart at the given path +// with the given version and sign it using an internally generated PGP keyring, to be served +// by the HelmServer. publicKeyPath is the path where the public key should be written to, which +// can be used to verify this chart. It returns an error in case of a packaging failure. +func (s *HelmServer) PackageSignedChartWithVersion(path, version, publicKeyPath string) error { + return s.packageChart(path, version, publicKeyPath) +} + +func (s *HelmServer) packageChart(path, version, publicKeyPath string) error { pkg := action.NewPackage() - pkg.Destination = s.HTTPServer.Root() + pkg.Destination = s.Root() pkg.Version = version + if publicKeyPath != "" { + randBytes := make([]byte, 16) + rand.Read(randBytes) + secretKeyPath := filepath.Join(s.Root(), "secret-"+hex.EncodeToString(randBytes)+".pgp") + if err := generateKeyring(secretKeyPath, publicKeyPath); err != nil { + return err + } + defer os.Remove(secretKeyPath) + pkg.Keyring = secretKeyPath + pkg.Key = keyRingName + pkg.Sign = true + } _, err := pkg.Run(path, nil) return err } + +func generateKeyring(privateKeyPath, publicKeyPath string) error { + entity, err := openpgp.NewEntity(keyRingName, "", "", nil) + if err != nil { + return err + } + priv, err := os.Create(privateKeyPath) + defer priv.Close() + if err != nil { + return err + } + pub, err := os.Create(publicKeyPath) + defer pub.Close() + if err != nil { + return err + } + if err := entity.SerializePrivate(priv, nil); err != nil { + return err + } + if err := entity.Serialize(pub); err != nil { + return err + } + + return nil +} diff --git a/helmtestserver/server_test.go b/helmtestserver/server_test.go new file mode 100644 index 00000000..b3514682 --- /dev/null +++ b/helmtestserver/server_test.go @@ -0,0 +1,82 @@ +/* +Copyright 2022 The Flux 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 helmtestserver + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/openpgp" + "helm.sh/helm/v3/pkg/downloader" +) + +func TestPackageSignedChartWithVersion(t *testing.T) { + server, err := NewTempHelmServer() + defer os.RemoveAll(server.Root()) + if err != nil { + t.Fatal(err) + } + publicKeyPath := filepath.Join(server.Root(), "pub.pgp") + packagedChartPath := filepath.Join(server.Root(), "helmchart-0.1.0.tgz") + if err := server.PackageSignedChartWithVersion("./testdata/helmchart", "0.1.0", publicKeyPath); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(packagedChartPath); err != nil { + t.Fatal(err) + } + + out, err := os.Open(publicKeyPath) + defer out.Close() + if err != nil { + t.Fatal(err) + } + + if _, err = openpgp.ReadKeyRing(out); err != nil { + t.Fatal(err) + } + + if _, err = os.Stat(fmt.Sprintf("%s.prov", packagedChartPath)); err != nil { + t.Fatal(err) + } + + if _, err = downloader.VerifyChart(packagedChartPath, publicKeyPath); err != nil { + t.Fatal(err) + } +} + +func TestGenerateIndex(t *testing.T) { + server, err := NewTempHelmServer() + defer os.RemoveAll(server.Root()) + if err != nil { + t.Fatal(err) + } + + if err := server.PackageChartWithVersion("./testdata/helmchart", "0.1.0"); err != nil { + t.Fatal(err) + } + + if err := server.GenerateIndex(); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(fmt.Sprintf("%s/%s", server.Root(), "index.yaml")); err != nil { + t.Fatal(err) + } +} diff --git a/helmtestserver/testdata/helmchart/.helmignore b/helmtestserver/testdata/helmchart/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/helmtestserver/testdata/helmchart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helmtestserver/testdata/helmchart/Chart.yaml b/helmtestserver/testdata/helmchart/Chart.yaml new file mode 100644 index 00000000..c4239c94 --- /dev/null +++ b/helmtestserver/testdata/helmchart/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: helmchart +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/helmtestserver/testdata/helmchart/templates/NOTES.txt b/helmtestserver/testdata/helmchart/templates/NOTES.txt new file mode 100644 index 00000000..41d5cffa --- /dev/null +++ b/helmtestserver/testdata/helmchart/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "helmchart.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "helmchart.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "helmchart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "helmchart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/helmtestserver/testdata/helmchart/templates/_helpers.tpl b/helmtestserver/testdata/helmchart/templates/_helpers.tpl new file mode 100644 index 00000000..28bcb84b --- /dev/null +++ b/helmtestserver/testdata/helmchart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "helmchart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "helmchart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "helmchart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "helmchart.labels" -}} +helm.sh/chart: {{ include "helmchart.chart" . }} +{{ include "helmchart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "helmchart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "helmchart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "helmchart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "helmchart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/helmtestserver/testdata/helmchart/templates/deployment.yaml b/helmtestserver/testdata/helmchart/templates/deployment.yaml new file mode 100644 index 00000000..0c260cc9 --- /dev/null +++ b/helmtestserver/testdata/helmchart/templates/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "helmchart.fullname" . }} + labels: + {{- include "helmchart.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "helmchart.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "helmchart.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "helmchart.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helmtestserver/testdata/helmchart/templates/tests/test-connection.yaml b/helmtestserver/testdata/helmchart/templates/tests/test-connection.yaml new file mode 100644 index 00000000..5e64cd3a --- /dev/null +++ b/helmtestserver/testdata/helmchart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "helmchart.fullname" . }}-test-connection" + labels: + {{- include "helmchart.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "helmchart.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/helmtestserver/testdata/helmchart/values.yaml b/helmtestserver/testdata/helmchart/values.yaml new file mode 100644 index 00000000..6285efd8 --- /dev/null +++ b/helmtestserver/testdata/helmchart/values.yaml @@ -0,0 +1,62 @@ +# Default values for helmchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {}