Skip to content

Commit

Permalink
up: upgrade base docker image; add envtest integration tests (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
isindir authored Sep 6, 2021
1 parent 8bf23f4 commit 5e22bfd
Show file tree
Hide file tree
Showing 17 changed files with 367 additions and 69 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ keys
vendor
build/_output
build/_test
index.html

############################################################
############################################################
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ COPY controllers/ controllers/
RUN CGO_ENABLED=0 GO111MODULE=on go build -a -o manager main.go

# https://hub.docker.com/_/ubuntu?tab=tags&page=1&ordering=last_updated
FROM ubuntu:focal-20210723
FROM ubuntu:focal-20210827

RUN apt-get -y update \
&& apt-get -y upgrade \
Expand Down
20 changes: 17 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
GO := GOPROXY=https://proxy.golang.org go
SOPS_SEC_OPERATOR_VERSION := 0.3.3
SOPS_SEC_OPERATOR_VERSION := 0.3.4

# https://github.com/kubernetes-sigs/controller-tools/releases
CONTROLLER_GEN_VERSION := "v0.6.2"
Expand All @@ -8,7 +8,9 @@ CONTROLLER_RUNTIME_VERSION := "v0.9.6"
# https://github.com/kubernetes-sigs/kustomize/releases
KUSTOMIZE_VERSION := "v4.2.0"
# use `setup-envtest list` to obtain the list of available versions
KUBE_VERSION := "1.21.2"
# until fixed, can't use newer version, see:
# https://github.com/kubernetes-sigs/controller-runtime/issues/1571
KUBE_VERSION := "1.20.2"

# Use existing cluster instead of starting processes
USE_EXISTING_CLUSTER ?= true
Expand All @@ -21,6 +23,9 @@ BUILDX_PLATFORMS ?= linux/amd64,linux/arm64
# Produce CRDs that work back to Kubernetes 1.16
CRD_OPTIONS ?= crd:crdVersions=v1

TMP_COVER_FILE="cover.out"
TMP_COVER_HTML_FILE="index.html"

# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
ifeq (,$(shell go env GOBIN))
GOBIN=$(shell go env GOPATH)/bin
Expand Down Expand Up @@ -58,6 +63,7 @@ clean: ## Cleans dependency directories.
rm -fr ./vendor
rm -fr ./testbin
rm -fr ./bin
rm -f $(TMP_COVER_HTML_FILE) $(TMP_COVER_FILE)

tidy: ## Fetches all go dependencies.
$(GO) mod tidy
Expand Down Expand Up @@ -98,7 +104,11 @@ vet: ## Run go vet against code.
go vet ./...

test: setup-envtest manifests generate fmt vet ## Run tests.
KUBEBUILDER_ASSETS="$(shell $(SETUP_ENVTEST) use -p path --force ${KUBE_VERSION})" go test ./... -coverprofile cover.out
SOPS_AGE_RECIPIENTS="age1pnmp2nq5qx9z4lpmachyn2ld07xjumn98hpeq77e4glddu96zvms9nn7c8" SOPS_AGE_KEY_FILE="${PWD}/config/age-test-key/key-file.txt" KUBEBUILDER_ASSETS="$(shell $(SETUP_ENVTEST) use -p path --force ${KUBE_VERSION})" go test ./... -coverpkg=./controllers/... -coverprofile=$(TMP_COVER_FILE)

cover: test ## Run tests with coverage.
$(GO) tool cover -func=$(TMP_COVER_FILE)
$(GO) tool cover -o $(TMP_COVER_HTML_FILE) -html=$(TMP_COVER_FILE)

##@ Build

Expand Down Expand Up @@ -176,6 +186,10 @@ SETUP_ENVTEST = $(shell pwd)/bin/setup-envtest
setup-envtest: ## Download setup-envtest locally if necessary.
$(call go-get-tool,$(SETUP_ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest@latest)

GINKGO = $(shell pwd)/ginkgo
setup-ginkgo: ## Download ginkgo locally
$(call go-get-tool,$(GINKGO),github.com/onsi/ginkgo/ginkgo)

# go-get-tool will 'go get' any package $2 and install it to $1
PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST))))
define go-get-tool
Expand Down
4 changes: 2 additions & 2 deletions chart/helm3/sops-secrets-operator/Chart.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
apiVersion: v2
version: 0.9.3
appVersion: 0.3.3
version: 0.9.4
appVersion: 0.3.4
type: application
description: Helm chart deploys sops-secrets-operator
name: sops-secrets-operator
Expand Down
2 changes: 1 addition & 1 deletion chart/helm3/sops-secrets-operator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ The following table lists the configurable parameters of the Sops-secrets-operat
| healthProbes.readiness | object | `{"initialDelaySeconds":5,"periodSeconds":10}` | Readiness probe configuration |
| image.pullPolicy | string | `"Always"` | Operator image pull policy |
| image.repository | string | `"isindir/sops-secrets-operator"` | Operator image name |
| image.tag | string | `"0.3.3"` | Operator image tag |
| image.tag | string | `"0.3.4"` | Operator image tag |
| imagePullSecrets | list | `[]` | Secrets to pull image from private docker repository |
| kubeconfig | object | `{"enabled":false,"path":null}` | Paths to a kubeconfig. Only required if out-of-cluster. |
| logging | object | `{"encoder":"json","level":"info","stacktraceLevel":"error"}` | Logging configuration section suggested values Development Mode (encoder=consoleEncoder,logLevel=Debug,stackTraceLevel=Warn). Production Mode (encoder=jsonEncoder,logLevel=Info,stackTraceLevel=Error) (default) |
Expand Down
6 changes: 3 additions & 3 deletions chart/helm3/sops-secrets-operator/tests/operator_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ tests:
app.kubernetes.io/instance: sops
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: sops-secrets-operator
app.kubernetes.io/version: 0.3.3
helm.sh/chart: sops-secrets-operator-0.9.3
app.kubernetes.io/version: 0.3.4
helm.sh/chart: sops-secrets-operator-0.9.4

# template metadata and spec selector
- it: should correctly render template metadata and spec selector
Expand Down Expand Up @@ -140,7 +140,7 @@ tests:
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: isindir/sops-secrets-operator:0.3.3
value: isindir/sops-secrets-operator:0.3.4
- equal:
path: spec.template.spec.containers[0].imagePullPolicy
value: Always
Expand Down
2 changes: 1 addition & 1 deletion chart/helm3/sops-secrets-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ image:
# -- Operator image name
repository: isindir/sops-secrets-operator
# -- Operator image tag
tag: 0.3.3
tag: 0.3.4
# -- Operator image pull policy
pullPolicy: Always

Expand Down
25 changes: 25 additions & 0 deletions config/age-test-key/00-raw-test-secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
apiVersion: isindir.github.com/v1alpha3
kind: SopsSecret
metadata:
name: test-sopssecret
namespace: default
spec:
secretTemplates:
- name: test-stringdata-token
stringData:
token: Wb4ziZdELkdUf6m6KtNd7iRjjQRvSeJno5meH4NAGHFmpqJyEsekZ2WjX232s4Gj
- name: test-data-token
data:
token: V2I0emlaZEVMa2RVZjZtNkt0TmQ3aVJqalFSdlNlSm5vNW1lSDROQUdIRm1wcUp5RXNla1oyV2pYMjMyczRHag==
- name: test-labels-annotations-jenkins-secret
labels:
"jenkins.io/credentials-type": "usernamePassword"
annotations:
"jenkins.io/credentials-description" : "credentials from Kubernetes"
stringData:
username: myUsername
password: 'Pa$$word'
- name: test-type-docker-login
type: 'kubernetes.io/dockerconfigjson'
stringData:
.dockerconfigjson: '{"auths":{"index.docker.io":{"username":"imyuser","password":"mypass","email":"[email protected]","auth":"aW15dXNlcjpteXBhc3M="}}}'
45 changes: 45 additions & 0 deletions config/age-test-key/00-test-secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
apiVersion: isindir.github.com/v1alpha3
kind: SopsSecret
metadata:
name: test-sopssecret
namespace: default
spec:
secretTemplates:
- name: ENC[AES256_GCM,data:6V4Ucx7n6A11VkrZtCiyqUSeKKFC,iv:5E1Xu6eFqkE4kM2q7Yf9dtoc8399uyEhfnj2Ni9BUc0=,tag:J8NkiHxnG6stFZkGF090gA==,type:str]
stringData:
token: ENC[AES256_GCM,data:A5Q72PY++0z7uKfIX2jSHHcOBoLj3MltCnPPl6v74MmflcqmZRCC3fR1EdzaaOAj1ILKJHBMMKf73eLo/W+Cmw==,iv:/xpIAjjX05PmdPznshlO0vsHR3irXHhDV6YdjL6n6w4=,tag:tDfGF+NVVnoYGw78N1/UAg==,type:str]
- name: ENC[AES256_GCM,data:pCSWLt3EBykhIClt9t8W,iv:CdzcJzAZFIpnvcUcQQyHAuIdYcp2S3ufwrGiWKyOCFo=,tag:xB6jysJ1LcI1kpvwuBpOoQ==,type:str]
data:
token: ENC[AES256_GCM,data:1eRxH3SpEsvL/LCgV0uTdbZUzq1aoTa2/urzJKf/CStET4nbCpqDR8VIvl4sb7Ym4UdblKggAd0IxcVCuQEnpdnLs0fhRi0aaVqyJBKjD4ewmFamNpBWEA==,iv:tgR7wSjxezstm+e1WuMLlZ6Gs6zVkfo6yDavOUZV4OA=,tag:STLqjwHQqXcQ/xPWrV9Skg==,type:str]
- name: ENC[AES256_GCM,data:YPpvKUryfv2HRzeSaPV0WfBG3Ob4Ag26Lvy4CmylMA5wPkKqaZQ=,iv:PCn6Zp+iEsF+CSK7An+VkXhg1JD3TJ+nE8gVeKTg2T0=,tag:MuLpy+KxHWCevJUxq/tFbg==,type:str]
labels:
jenkins.io/credentials-type: ENC[AES256_GCM,data:Wrqn2XG5KjUKOzzBcRrJTQ==,iv:NEm+ZM5KBK+eF/eWgdNcT1190eIjOEaGTNamVfveXLY=,tag:DejUX7y+CC2V7qQZbN5GNw==,type:str]
annotations:
jenkins.io/credentials-description: ENC[AES256_GCM,data:6DxJ5dSqKPd5cTlVc2h+cvcdCYxVXnHvKIp8,iv:KeBAk4B8je2qKxV5xPMU+5i9v3UI3bIeY1NdhvSyRPg=,tag:Cw/rPrYy/PRcdUcMzUNYQQ==,type:str]
stringData:
username: ENC[AES256_GCM,data:2331v9GLgGmspQ==,iv:3gOdC9ICqE/IGOoTESCpOBb6Z+MV4pJ/e1T/WulLPtg=,tag:OQILlBVUoBNodkhvrI5IlQ==,type:str]
password: ENC[AES256_GCM,data:YKZtVH5w5uRNTds=,iv:mK/us3zPUtKa1nUv+Lw8A9DZHvuoHsX5i3i0G/9XJLc=,tag:+HMWTSQeYD9HK3fYldzeGw==,type:str]
- name: ENC[AES256_GCM,data:AdqTEEJdkr61Lr6Rgc7n/lh7vwoclA==,iv:8ROnJOl78gE9i2TBKyhAa4RhnQmNnIBgNjlupJlrhCA=,tag:OMYJjY+MmXH/9AYnF7uJqg==,type:str]
type: ENC[AES256_GCM,data:rLjSr2aByTeACO2ucCj6h81qdS9QNmLs2QTB4wjJ,iv:KBmpgMCaK79uVdAsVxZMRqe1CQGDXxPESvwKtSPh6+s=,tag:s/4f6MnEIDoj1doDYUjaHQ==,type:str]
stringData:
.dockerconfigjson: ENC[AES256_GCM,data:EBB6l/PQVfEMCg6MYe1yUwGV8RxPyAUMom7DvtTDdce8lY1wztEWyrmCHPLXE/IkIq3/Iz6QTyyMFFBUERJovz5aYSwBAMeX6ocle9SG3wMWe1/cySYIQednL+PF1GWNUaIn1MEMO7PP1VopEuZSdWtHvXxfrubWC2mRMmhHaw==,iv:HV/C0ceSQcFveWzXBVZMfGSHV/bRBeB7ka/zxrHnrY8=,tag:HILN9fQBDbWFxjjnkBkp4Q==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1pnmp2nq5qx9z4lpmachyn2ld07xjumn98hpeq77e4glddu96zvms9nn7c8
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoNUpVbVhNbWluZjFyRHhT
OWoxYU9oU1h6dFYycGVMb2RJTlhWMFhPTVFnCkQxb3liRG9TS096TFN4UVJFK25Y
K2x5SG53dmM4ZHF2dDZWdXdDVHN3Yk0KLS0tIE1xTlN1Unk4aURZWFNoNUhGMGc3
YzlyY0NwQnptejk2RWpRV3RMUDVRWm8KDsF8k67qcYafOPIiAYVeQ+SvzLobq0pg
h8OmBkNaahywzLTAjEcy8j84JRa1muAEJEX4fCLzE+7hsN+11Yc2gA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2021-09-05T19:36:03Z"
mac: ENC[AES256_GCM,data:9K8gQB0RJXuVFeEzNg8Kuo++50xvdk8NR8Y0yIJTcjdcAAI+V733yRBBCLt8AzIERifUqI2geZcdKJeOq5mQ5Bg5WA5bqBowBXOmT9Oxmg7M0uUIbfoIV7X9hG42rTcZ+gBd8d3YFTAY4waFlgY7sTuJvacDt+cHJkh0a1+CGPY=,iv:F3DHuCUDrLpAHktYy2x5qkYbQf1gs0t8tFLv61YrEiU=,tag:Xdi7t78Z44T2j041CaCR8A==,type:str]
pgp: []
encrypted_suffix: Templates
version: 3.7.1
3 changes: 3 additions & 0 deletions config/age-test-key/key-file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# created: 2021-08-20T15:53:30+01:00
# public key: age1pnmp2nq5qx9z4lpmachyn2ld07xjumn98hpeq77e4glddu96zvms9nn7c8
AGE-SECRET-KEY-1CQ3KTYJ25YDYA2XVYFGH8P5UJWCENLJ02ZRHMH9YV84WKQVGP3SS07GYNK
162 changes: 162 additions & 0 deletions controllers/sopssecret_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package controllers_test

import (
"os"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"

isindirv1alpha3 "github.com/isindir/sops-secrets-operator/api/v1alpha3"
controller "github.com/isindir/sops-secrets-operator/controllers"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/scheme"

"context"
"io/ioutil"
"path/filepath"
"time"
)

var _ = Describe("SopssecretController", func() {
TestSecretObject00 := &isindirv1alpha3.SopsSecret{}
BeforeEach(func() {
content, err := ioutil.ReadFile(filepath.Join("..", "config", "age-test-key", "00-test-secrets.yaml"))
Expect(err).Should(BeNil())

obj, _, err := scheme.Codecs.UniversalDeserializer().Decode(content, nil, nil)
TestSecretObject00 = obj.(*isindirv1alpha3.SopsSecret)
Expect(err).Should(BeNil())
})

// Define utility constants for object names and testing timeouts/durations and intervals.
const (
SopsSecretName = "test-sops-secret"
SopsSecretNamespace = "default"

timeout = time.Second * 10
duration = time.Second * 10
interval = time.Millisecond * 250
)

// This is to ensure test environment is configured correctly
Context("When Running controller reconciler", func() {
It("It should have SOPS env variables defined", func() {
// Key env variable must be set correctly
Expect(os.Getenv("SOPS_AGE_RECIPIENTS")).To(Equal("age1pnmp2nq5qx9z4lpmachyn2ld07xjumn98hpeq77e4glddu96zvms9nn7c8"))

// File containing private key must exist
ageKeyFileName := os.Getenv("SOPS_AGE_KEY_FILE")
_, err := os.Stat(ageKeyFileName)
Expect(err).To(BeNil())
}, float64(timeout))
})

Context("When Creating Malformed SopsSecret Object", func() {
It("Should Fail to Create SopsSecret", func() {
By("By creating a new SopsSecret")
ctx := context.Background()
sopsSecret := &isindirv1alpha3.SopsSecret{
TypeMeta: metav1.TypeMeta{
APIVersion: "github.com/isindir/sops-secrets-operator/api/v1alpha3",
Kind: "SopsSecret",
},
ObjectMeta: metav1.ObjectMeta{
Name: SopsSecretName,
Namespace: SopsSecretNamespace,
},
Spec: isindirv1alpha3.SopsSecretSpec{
Suspend: true,
SecretsTemplate: []isindirv1alpha3.SopsSecretTemplate{},
},
}
Expect(controller.K8sClient.Create(ctx, sopsSecret)).NotTo(Succeed())
}, float64(timeout))
})

Context("When Creating Correctly Defined SopsSecret Object", func() {
It("Should Succeed to Create SopsSecret", func() {
By("By creating a new SopsSecret version 00")
ctx := context.Background()

Expect(controller.K8sClient.Create(ctx, TestSecretObject00)).To(Succeed())
time.Sleep(10 * time.Second)

By("By checking that correct number of secrets was created")
listCommandOptions := &client.ListOptions{Namespace: "default"}
secretsList := &corev1.SecretList{}
Expect(controller.K8sClient.List(ctx, secretsList, listCommandOptions)).To(Succeed())
// 4 from SopsSecret object + 1 for Service Account
Expect(len(secretsList.Items)).To(Equal(4))

By("By checking content of token stringdata test secret")
testSecret := &corev1.Secret{}
tagrgetSecretNamespacedName := &types.NamespacedName{Namespace: "default", Name: "test-stringdata-token"}
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(string(testSecret.Data["token"])).To(Equal("Wb4ziZdELkdUf6m6KtNd7iRjjQRvSeJno5meH4NAGHFmpqJyEsekZ2WjX232s4Gj"))

By("By checking content of token data test secret")
tagrgetSecretNamespacedName = &types.NamespacedName{Namespace: "default", Name: "test-data-token"}
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(string(testSecret.Data["token"])).To(Equal("Wb4ziZdELkdUf6m6KtNd7iRjjQRvSeJno5meH4NAGHFmpqJyEsekZ2WjX232s4Gj"))

By("By checking docker secret type")
tagrgetSecretNamespacedName = &types.NamespacedName{Namespace: "default", Name: "test-type-docker-login"}
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(testSecret.Type).To(Equal(corev1.SecretTypeDockerConfigJson))

By("By checking jenkins test secret contains 1 label and 1 annotation")
tagrgetSecretNamespacedName = &types.NamespacedName{Namespace: "default", Name: "test-labels-annotations-jenkins-secret"}
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(string(testSecret.Data["username"])).To(Equal("myUsername"))
Expect(string(testSecret.Data["password"])).To(Equal("Pa58163word"))
Expect(testSecret.Labels["jenkins.io/credentials-type"]).To(Equal("usernamePassword"))
Expect(testSecret.Annotations["jenkins.io/credentials-description"]).To(Equal("credentials from Kubernetes"))

By("By updating a managed k8s secret value outside of SopsSecret object")
testSecret.Data["username"] = []byte("newUsername")
Expect(controller.K8sClient.Update(ctx, testSecret)).To(Succeed())
time.Sleep(10 * time.Second)
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(string(testSecret.Data["username"])).To(Equal("myUsername"))

By("By deleting data item from a managed k8s secret value outside of SopsSecret object")
delete(testSecret.Data, "username")
Expect(controller.K8sClient.Update(ctx, testSecret)).To(Succeed())
time.Sleep(10 * time.Second)
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(string(testSecret.Data["username"])).To(Equal("myUsername"))

By("By checking that status of the SopsSecret is Healthy")
sourceSopsSecret := &isindirv1alpha3.SopsSecret{}
sourceSopsSecretNamespacedName := &types.NamespacedName{Namespace: "default", Name: "test-sopssecret"}
Expect(controller.K8sClient.Get(ctx, *sourceSopsSecretNamespacedName, sourceSopsSecret)).To(Succeed())
Expect(sourceSopsSecret.Status.Message).To(Equal("Healthy"))

By("By removing secret template from SopsSecret must remove managed k8s secret")
// Delete template from SopsSecret and update
// Delete target secret (envtest will not perform garbage collection)
copy(sourceSopsSecret.Spec.SecretsTemplate[0:], sourceSopsSecret.Spec.SecretsTemplate[1:])
sourceSopsSecret.Spec.SecretsTemplate = sourceSopsSecret.Spec.SecretsTemplate[:len(sourceSopsSecret.Spec.SecretsTemplate)-1]
Expect(controller.K8sClient.Update(ctx, sourceSopsSecret)).To(Succeed())
testSecret = &corev1.Secret{}
tagrgetSecretNamespacedName = &types.NamespacedName{Namespace: "default", Name: "test-stringdata-token"}
Expect(controller.K8sClient.Get(ctx, *tagrgetSecretNamespacedName, testSecret)).To(Succeed())
Expect(controller.K8sClient.Delete(ctx, testSecret)).To(Succeed())
time.Sleep(10 * time.Second)
secretsList = &corev1.SecretList{}
Expect(controller.K8sClient.List(ctx, secretsList, listCommandOptions)).To(Succeed())
// 3 from SopsSecret object + 1 for Service Account
Expect(len(secretsList.Items)).To(Equal(3))
Expect(controller.K8sClient.Get(ctx, *sourceSopsSecretNamespacedName, sourceSopsSecret)).To(Succeed())
Expect(sourceSopsSecret.Status.Message).To(Equal("Healthy"))
}, float64(timeout))
})

// TODO: check by creating sops secret with one broken k8s secret definition will manifest in non-healthy sops object
// TODO: check pre-existing secret conflict with SopsSecret template
// TODO: check pre-existing k8s secret being taken over by SopsSecret using sops managed annotation
})
Loading

0 comments on commit 5e22bfd

Please sign in to comment.