diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7771076..bc7d37f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,12 +76,31 @@ jobs: - name: Run integration tests run: go test -tags=integration -v -timeout=8m ./... + helm-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: "~1.26" + - name: Setup Helm + uses: azure/setup-helm@f0accbfd55e3332a28f721b8202b1016cecf90d5 # v5 + with: + version: "v3.18.3" + - name: Run Helm chart tests + run: go test ./helm/tests/... + - name: Check for unstaged files + run: ./scripts/check_unstaged.sh + required: runs-on: ubuntu-latest needs: - test - lint - integration-test + - helm-test # Allow this job to run even if the needed jobs fail, are skipped or # cancelled. if: always() diff --git a/helm/tests/chart_test.go b/helm/tests/chart_test.go new file mode 100644 index 0000000..d7254bf --- /dev/null +++ b/helm/tests/chart_test.go @@ -0,0 +1,198 @@ +package tests // nolint: testpackage + +import ( + "bytes" + "flag" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +// These tests run `helm template` with the values file specified in each test +// and compare the output to the contents of the corresponding golden file. +// All values and golden files are located in the `testdata` directory. +// To update golden files, run `go test . -update`. + +// updateGoldenFiles is a flag that can be set to update golden files. +var updateGoldenFiles = flag.Bool("update", false, "Update golden files") + +var namespaces = []string{ + "default", + "coder", +} + +var testCases = []testCase{ + { + name: "default_values", + expectedError: "", + }, + { + name: "all_values", + expectedError: "", + }, +} + +type testCase struct { + name string // Name of the test case. This is used to control which values and golden file are used. + namespace string // Namespace is the name of the namespace the resources should be generated within + expectedError string // Expected error from running `helm template`. +} + +func (tc testCase) valuesFilePath() string { + return filepath.Join("./testdata", tc.name+".yaml") +} + +func (tc testCase) goldenFilePath() string { + if tc.namespace == "default" { + return filepath.Join("./testdata", tc.name+".golden") + } + + return filepath.Join("./testdata", tc.name+"_"+tc.namespace+".golden") +} + +func inCI() bool { return os.Getenv("CI") != "" } + +func TestRenderChart(t *testing.T) { + t.Parallel() + if *updateGoldenFiles { + t.Skip("Golden files are being updated. Skipping test.") + } + if inCI() { + switch runtime.GOOS { + case "windows", "darwin": + t.Skip("Skipping tests on Windows and macOS in CI") + } + } + + // Ensure that Helm is available in $PATH + helmPath := lookupHelm(t) + err := updateHelmDependencies(t, helmPath, "..") + require.NoError(t, err, "failed to build Helm dependencies") + + for _, tc := range testCases { + for _, ns := range namespaces { + tc.namespace = ns + + t.Run(tc.namespace+"/"+tc.name, func(t *testing.T) { + t.Parallel() + + // Ensure that the values file exists. + valuesFilePath := tc.valuesFilePath() + if _, err := os.Stat(valuesFilePath); os.IsNotExist(err) { + t.Fatalf("values file %q does not exist", valuesFilePath) + } + + // Run helm template with the values file. + templateOutput, err := runHelmTemplate(t, helmPath, "..", valuesFilePath, tc.namespace) + if tc.expectedError != "" { + require.Error(t, err, "helm template should have failed") + require.Contains(t, templateOutput, tc.expectedError, "helm template output should contain expected error") + } else { + require.NoError(t, err, "helm template should not have failed") + require.NotEmpty(t, templateOutput, "helm template output should not be empty") + goldenFilePath := tc.goldenFilePath() + goldenBytes, err := os.ReadFile(goldenFilePath) + require.NoError(t, err, "failed to read golden file %q", goldenFilePath) + + // Remove carriage returns to make tests pass on Windows. + goldenBytes = bytes.ReplaceAll(goldenBytes, []byte("\r"), []byte("")) + expected := string(goldenBytes) + + require.NoError(t, err, "failed to load golden file %q") + require.Equal(t, expected, templateOutput) + } + }) + } + } +} + +func TestUpdateGoldenFiles(t *testing.T) { + t.Parallel() + if !*updateGoldenFiles { + t.Skip("Run with -update to update golden files") + } + + helmPath := lookupHelm(t) + err := updateHelmDependencies(t, helmPath, "..") + require.NoError(t, err, "failed to build Helm dependencies") + + for _, tc := range testCases { + if tc.expectedError != "" { + t.Logf("skipping test case %q with render error", tc.name) + continue + } + + for _, ns := range namespaces { + tc.namespace = ns + + valuesPath := tc.valuesFilePath() + templateOutput, err := runHelmTemplate(t, helmPath, "..", valuesPath, tc.namespace) + if err != nil { + t.Logf("error running `helm template -f %q`: %v", valuesPath, err) + t.Logf("output: %s", templateOutput) + } + require.NoError(t, err, "failed to run `helm template -f %q`", valuesPath) + + goldenFilePath := tc.goldenFilePath() + err = os.WriteFile(goldenFilePath, []byte(templateOutput), 0o644) // nolint:gosec + require.NoError(t, err, "failed to write golden file %q", goldenFilePath) + } + } + t.Log("Golden files updated. Please review the changes and commit them.") +} + +// updateHelmDependencies runs `helm dependency update .` on the given chartDir. +func updateHelmDependencies(t testing.TB, helmPath, chartDir string) error { + // Remove charts/ from chartDir if it exists. + err := os.RemoveAll(filepath.Join(chartDir, "charts")) + if err != nil { + return xerrors.Errorf("failed to remove charts/ directory: %w", err) + } + + // Regenerate the chart dependencies. + cmd := exec.Command(helmPath, "dependency", "update", "--skip-refresh", ".") + cmd.Dir = chartDir + t.Logf("exec command: %v", cmd.Args) + out, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("failed to run `helm dependency build`: %w\noutput: %s", err, out) + } + + return nil +} + +// runHelmTemplate runs helm template on the given chart with the given values and +// returns the raw output. +func runHelmTemplate(t testing.TB, helmPath, chartDir, valuesFilePath, namespace string) (string, error) { + // Ensure that valuesFilePath exists + if _, err := os.Stat(valuesFilePath); err != nil { + return "", xerrors.Errorf("values file %q does not exist: %w", valuesFilePath, err) + } + + cmd := exec.Command(helmPath, "template", chartDir, "-f", valuesFilePath, "--namespace", namespace) + t.Logf("exec command: %v", cmd.Args) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// lookupHelm ensures that Helm is available in $PATH and returns the path to the +// Helm executable. +func lookupHelm(t testing.TB) string { + helmPath, err := exec.LookPath("helm") + if err != nil { + t.Fatalf("helm not found in $PATH: %v", err) + return "" + } + t.Logf("Using helm at %q", helmPath) + return helmPath +} + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} diff --git a/helm/tests/testdata/all_values.golden b/helm/tests/testdata/all_values.golden new file mode 100644 index 0000000..0ec4af4 --- /dev/null +++ b/helm/tests/testdata/all_values.golden @@ -0,0 +1,169 @@ +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "coder-logstream-kube-custom-name" + annotations: + golden: test + labels: + testing: golden +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-logstream-kube-custom-role-name + namespace: coder +rules: + + - apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "watch", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets", "events"] + verbs: ["get", "watch", "list"] +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-logstream-kube-custom-role-name + namespace: coder-workloads +rules: + + - apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "watch", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets", "events"] + verbs: ["get", "watch", "list"] +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: coder-logstream-kube-rolebinding-custom-rb-name + namespace: coder +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-logstream-kube-custom-role-name +subjects: +- kind: ServiceAccount + name: "coder-logstream-kube-custom-name" + namespace: default +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: coder-logstream-kube-rolebinding-custom-rb-name + namespace: coder-workloads +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-logstream-kube-custom-role-name +subjects: +- kind: ServiceAccount + name: "coder-logstream-kube-custom-name" + namespace: default +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name +spec: + # This must remain at 1 otherwise duplicate logs can occur! + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + template: + metadata: + labels: + app.kubernetes.io/instance: release-name + label-one: turtle + label-two: cat + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9998" + prometheus.io/path: "/metrics" + spec: + serviceAccountName: "coder-logstream-kube-custom-name" + restartPolicy: Always + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/instance + operator: In + values: + - coder-logstream-kube + topologyKey: kubernetes.io/hostname + weight: 1 + tolerations: + - effect: NoSchedule + key: dedicated + operator: Equal + value: monitoring + - effect: NoExecute + key: node.kubernetes.io/not-ready + operator: Exists + tolerationSeconds: 30 + nodeSelector: + kubernetes.io/os: linux + topology.kubernetes.io/zone: us-east-1a + podSecurityContext: + fsGroup: 655 + containers: + - name: coder-logstream-kube + image: "ghcr.io/coder/coder-logstream-kube:latest" + imagePullPolicy: IfNotPresent + command: + - /coder-logstream-kube + args: + - --coder-url + - http://coder.coder.svc.cluster.local + ports: + - name: metrics + containerPort: 9998 + protocol: TCP + resources: + limits: + cpu: 500m + memory: 500Mi + requests: + cpu: 2000m + memory: 2000Mi + env: + - name: CODER_URL + value: http://coder.coder.svc.cluster.local + - name: CODER_LOGSTREAM_METRICS_ADDR + value: ":9998" + - name: CODER_NAMESPACES + value: coder,coder-workloads + - name: CODER_LABEL_SELECTOR + value: "com.coder.deployment=dev-coder.nicecorp.org" + securityContext: + allowPrivilegeEscalation: true + runAsGroup: 655 + runAsNonRoot: true + runAsUser: 655 + volumeMounts: + - mountPath: /etc/ssl/custom + name: custom-ca + readOnly: true + volumes: + - configMap: + name: custom-ca-cert + name: custom-ca diff --git a/helm/tests/testdata/all_values.yaml b/helm/tests/testdata/all_values.yaml new file mode 100644 index 0000000..63a5ff2 --- /dev/null +++ b/helm/tests/testdata/all_values.yaml @@ -0,0 +1,79 @@ +url: "http://coder.coder.svc.cluster.local" +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchExpressions: + - key: app.kubernetes.io/instance + operator: In + values: + - coder-logstream-kube +args: + - --coder-url + - "http://coder.coder.svc.cluster.local" +image: + repo: custom-internal-registry.nicecorp.org/coder-logstream-kube + tag: v1.2.3 + pullPolicy: Always + pullSecrets: + - name: super-secret-pull-secret + sslCertFile: /etc/ssl/certs/my-custom-cert.pem + sslCertDir: /etc/ssl/certs/my-custom-cert-directory/ +labelSelector: com.coder.deployment=dev-coder.nicecorp.org +labels: + label-one: turtle + label-two: cat +metrics: + enabled: true + port: 9998 +namespaces: + - coder + - coder-workloads +nodeSelector: + kubernetes.io/os: linux + topology.kubernetes.io/zone: us-east-1a +podSecurityContext: + fsGroup: 655 +rbac: + roleName: coder-logstream-kube-custom-role-name + roleBindingName: coder-logstream-kube-rolebinding-custom-rb-name +resources: + limits: + cpu: 500m + memory: 500Mi + requests: + cpu: 2000m + memory: 2000Mi +securityContext: + runAsNonRoot: true + runAsUser: 655 + runAsGroup: 655 + allowPrivilegeEscalation: true +serviceAccount: + annotations: + golden: test + labels: + testing: golden + name: coder-logstream-kube-custom-name +tolerations: + - key: "dedicated" + operator: "Equal" + value: "monitoring" + effect: "NoSchedule" + - key: "node.kubernetes.io/not-ready" + operator: "Exists" + effect: "NoExecute" + tolerationSeconds: 30 +image: + tag: latest +volumes: + - name: custom-ca + configMap: + name: custom-ca-cert +volumeMounts: + - name: custom-ca + mountPath: /etc/ssl/custom + readOnly: true diff --git a/helm/tests/testdata/all_values_coder.golden b/helm/tests/testdata/all_values_coder.golden new file mode 100644 index 0000000..1bb5080 --- /dev/null +++ b/helm/tests/testdata/all_values_coder.golden @@ -0,0 +1,169 @@ +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "coder-logstream-kube-custom-name" + annotations: + golden: test + labels: + testing: golden +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-logstream-kube-custom-role-name + namespace: coder +rules: + + - apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "watch", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets", "events"] + verbs: ["get", "watch", "list"] +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-logstream-kube-custom-role-name + namespace: coder-workloads +rules: + + - apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "watch", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets", "events"] + verbs: ["get", "watch", "list"] +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: coder-logstream-kube-rolebinding-custom-rb-name + namespace: coder +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-logstream-kube-custom-role-name +subjects: +- kind: ServiceAccount + name: "coder-logstream-kube-custom-name" + namespace: coder +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: coder-logstream-kube-rolebinding-custom-rb-name + namespace: coder-workloads +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-logstream-kube-custom-role-name +subjects: +- kind: ServiceAccount + name: "coder-logstream-kube-custom-name" + namespace: coder +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name +spec: + # This must remain at 1 otherwise duplicate logs can occur! + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + template: + metadata: + labels: + app.kubernetes.io/instance: release-name + label-one: turtle + label-two: cat + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9998" + prometheus.io/path: "/metrics" + spec: + serviceAccountName: "coder-logstream-kube-custom-name" + restartPolicy: Always + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/instance + operator: In + values: + - coder-logstream-kube + topologyKey: kubernetes.io/hostname + weight: 1 + tolerations: + - effect: NoSchedule + key: dedicated + operator: Equal + value: monitoring + - effect: NoExecute + key: node.kubernetes.io/not-ready + operator: Exists + tolerationSeconds: 30 + nodeSelector: + kubernetes.io/os: linux + topology.kubernetes.io/zone: us-east-1a + podSecurityContext: + fsGroup: 655 + containers: + - name: coder-logstream-kube + image: "ghcr.io/coder/coder-logstream-kube:latest" + imagePullPolicy: IfNotPresent + command: + - /coder-logstream-kube + args: + - --coder-url + - http://coder.coder.svc.cluster.local + ports: + - name: metrics + containerPort: 9998 + protocol: TCP + resources: + limits: + cpu: 500m + memory: 500Mi + requests: + cpu: 2000m + memory: 2000Mi + env: + - name: CODER_URL + value: http://coder.coder.svc.cluster.local + - name: CODER_LOGSTREAM_METRICS_ADDR + value: ":9998" + - name: CODER_NAMESPACES + value: coder,coder-workloads + - name: CODER_LABEL_SELECTOR + value: "com.coder.deployment=dev-coder.nicecorp.org" + securityContext: + allowPrivilegeEscalation: true + runAsGroup: 655 + runAsNonRoot: true + runAsUser: 655 + volumeMounts: + - mountPath: /etc/ssl/custom + name: custom-ca + readOnly: true + volumes: + - configMap: + name: custom-ca-cert + name: custom-ca diff --git a/helm/tests/testdata/default_values.golden b/helm/tests/testdata/default_values.golden new file mode 100644 index 0000000..d1383e2 --- /dev/null +++ b/helm/tests/testdata/default_values.golden @@ -0,0 +1,78 @@ +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "coder-logstream-kube" + annotations: + {} + labels: + {} +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: coder-logstream-kube-role +rules: + + - apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "watch", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets", "events"] + verbs: ["get", "watch", "list"] +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: coder-logstream-kube-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: coder-logstream-kube-role +subjects: +- kind: ServiceAccount + name: "coder-logstream-kube" + namespace: default +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name +spec: + # This must remain at 1 otherwise duplicate logs can occur! + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + template: + metadata: + labels: + app.kubernetes.io/instance: release-name + spec: + serviceAccountName: "coder-logstream-kube" + restartPolicy: Always + containers: + - name: coder-logstream-kube + image: "ghcr.io/coder/coder-logstream-kube:latest" + imagePullPolicy: IfNotPresent + command: + - /coder-logstream-kube + resources: + {} + env: + - name: CODER_URL + value: http://coder.coder.svc.cluster.local + - name: CODER_LOGSTREAM_METRICS_ADDR + value: "" + securityContext: + allowPrivilegeEscalation: false + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 diff --git a/helm/tests/testdata/default_values.yaml b/helm/tests/testdata/default_values.yaml new file mode 100644 index 0000000..bd7fad5 --- /dev/null +++ b/helm/tests/testdata/default_values.yaml @@ -0,0 +1,3 @@ +url: "http://coder.coder.svc.cluster.local" +image: + tag: "latest" diff --git a/helm/tests/testdata/default_values_coder.golden b/helm/tests/testdata/default_values_coder.golden new file mode 100644 index 0000000..e14923f --- /dev/null +++ b/helm/tests/testdata/default_values_coder.golden @@ -0,0 +1,78 @@ +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "coder-logstream-kube" + annotations: + {} + labels: + {} +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: coder-logstream-kube-role +rules: + + - apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "watch", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets", "events"] + verbs: ["get", "watch", "list"] +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: coder-logstream-kube-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: coder-logstream-kube-role +subjects: +- kind: ServiceAccount + name: "coder-logstream-kube" + namespace: coder +--- +# Source: coder-logstream-kube/templates/service.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name +spec: + # This must remain at 1 otherwise duplicate logs can occur! + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + template: + metadata: + labels: + app.kubernetes.io/instance: release-name + spec: + serviceAccountName: "coder-logstream-kube" + restartPolicy: Always + containers: + - name: coder-logstream-kube + image: "ghcr.io/coder/coder-logstream-kube:latest" + imagePullPolicy: IfNotPresent + command: + - /coder-logstream-kube + resources: + {} + env: + - name: CODER_URL + value: http://coder.coder.svc.cluster.local + - name: CODER_LOGSTREAM_METRICS_ADDR + value: "" + securityContext: + allowPrivilegeEscalation: false + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 diff --git a/scripts/check_unstaged.sh b/scripts/check_unstaged.sh new file mode 100755 index 0000000..7b95842 --- /dev/null +++ b/scripts/check_unstaged.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +files=$(git ls-files --other --modified --exclude-standard) +if [[ -n "$files" ]]; then + echo "The following files contain unstaged changes:" + echo "$files" + echo + git --no-pager diff + echo + echo "Error: unstaged changes, see above for details." + exit 1 +fi