diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bd01a58..e925ca35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,8 +25,13 @@ jobs: git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - - id: 'add-cockroachdb-repo' - run: 'helm repo add cockroachdb https://charts.cockroachdb.com/' + - id: 'prepare' + run: | + helm repo add bitnami https://charts.bitnami.com/bitnami + helm repo add cockroachdb https://charts.cockroachdb.com/ + cat ./charts/zitadel/values.yaml >> charts/zitadel-umbrella/values.yaml + sed -n -e '/Common.*zitadel-umbrella/,$p' ./charts/zitadel/Chart.yaml >> charts/zitadel-umbrella/Chart.yaml + helm dependency build charts/zitadel-umbrella - id: 'release' uses: 'helm/chart-releaser-action@v1.6.0' diff --git a/.gitignore b/.gitignore index 485dee64..b6d59af2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea +/*.tgz \ No newline at end of file diff --git a/README.md b/README.md index 26b5ffff..4a6b7a22 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,11 @@ kubectl get pods --all-namespaces --watch # Or if you have the watch binary installed watch -n .1 "kubectl get pods --all-namespaces" ``` - +## Screenshots +![Dashboard](https://media.discordapp.net/attachments/1288861233541550101/1288867332093116426/image.png) +![Login](https://media.discordapp.net/attachments/1288861233541550101/1288867328850657343/image.png) +![Change Password](https://media.discordapp.net/attachments/1288861233541550101/1288867330755133532/image.png) +![2FA Setup](https://media.discordapp.net/attachments/1288861233541550101/1288867330037911582/image.png) ## Contributors diff --git a/charts/zitadel-umbrella/Chart.lock b/charts/zitadel-umbrella/Chart.lock new file mode 100644 index 00000000..689045cb --- /dev/null +++ b/charts/zitadel-umbrella/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.35 +- name: cockroachdb + repository: https://charts.cockroachdb.com/ + version: 14.0.3 +digest: sha256:b2453e7eccc9cc111e14816b3d308e8686e53f9b945794d4821d726c688f272c +generated: "2024-10-03T23:38:57.462436343+02:00" diff --git a/charts/zitadel-umbrella/Chart.yaml b/charts/zitadel-umbrella/Chart.yaml new file mode 100644 index 00000000..a9cf3ef4 --- /dev/null +++ b/charts/zitadel-umbrella/Chart.yaml @@ -0,0 +1,12 @@ +# This Chart.yaml is extended by the Charts.yaml for the zitadel chart. +name: zitadel-umbrella +description: A vendored Helm chart for ZITADEL with subcharts +dependencies: + - name: postgresql + version: 15.5.35 + repository: https://charts.bitnami.com/bitnami + condition: postgresql.enabled + - name: cockroachdb + version: 14.0.3 + repository: https://charts.cockroachdb.com/ + condition: cockroachdb.enabled diff --git a/charts/zitadel-umbrella/values.yaml b/charts/zitadel-umbrella/values.yaml new file mode 100644 index 00000000..d422464b --- /dev/null +++ b/charts/zitadel-umbrella/values.yaml @@ -0,0 +1,21 @@ +# This Chart.yaml is extended by the values.yaml for the zitadel chart. +postgresql: + # enable postgresql helm subchart: https://github.com/bitnami/charts/blob/main/bitnami/postgresql/ + enabled: true + auth: + username: zitadel + password: zitadel + postgresPassword: zitadel + volumePermissions: + enabled: true + tls: + enabled: false + certificatesSecret: postgres-cert + certFilename: "tls.crt" + certKeyFilename: "tls.key" + +cockroachdb: + # enable cockroachdb helm subchart: https://github.com/cockroachdb/helm-charts + enabled: false + tls: + enabled: false diff --git a/charts/zitadel/Chart.yaml b/charts/zitadel/Chart.yaml index c6a255a6..8a212e78 100644 --- a/charts/zitadel/Chart.yaml +++ b/charts/zitadel/Chart.yaml @@ -1,12 +1,64 @@ -apiVersion: v2 +# Specific values unique to the zitadel chart name: zitadel description: A Helm chart for ZITADEL +# Common values with the zitadel-umbrella chart... +apiVersion: v2 type: application appVersion: v2.61.0 version: 8.5.0 kubeVersion: ">= 1.21.0-0" icon: https://zitadel.com/zitadel-logo-dark.svg +home: https://zitadel.com +keywords: + - auth + - authentication + - sso + - single-sign-on + - single_sign_on + - idp + - identity + - identity-platform + - identity_platform + - oidc + - open-identity + - open_identity + - open-identity-connect + - open_identity_connect + - oauth +sources: + - https://zitadel.com/docs/self-hosting/deploy/overview + - https://github.com/zitadel/zitadel maintainers: - name: zitadel email: support@zitadel.com url: https://zitadel.com +annotations: + artifacthub.io/maintainers: | + - name: Zitadel Team + email: support@zitadel.com + url: https://zitadel.com + artifacthub.io/links: | + - name: GitHub + url: https://github.com/zitadel/zitadel-charts + - name: Examples + url: https://github.com/zitadel/zitadel-charts/tree/main/examples + - name: Discord + url: https://zitadel.com/chat + - name: Documentation + url: https://zitadel.com/docs/self-hosting/deploy/overview + artifacthub.io/images: | + - name: zitadel + image: ghcr.io/zitadel/zitadel + artifacthub.io/license: Apache License 2.0 + artifacthub.io/changes: | + - kind: changed + description: overhauled helm chart + artifacthub.io/screenshots: | + - title: Dashboard + url: https://media.discordapp.net/attachments/1288861233541550101/1288867332093116426/image.png + - title: Login + url: https://media.discordapp.net/attachments/1288861233541550101/1288867328850657343/image.png + - title: Change Password + url: https://media.discordapp.net/attachments/1288861233541550101/1288867330755133532/image.png + - title: 2fa Setup + url: https://media.discordapp.net/attachments/1288861233541550101/1288867330037911582/image.png diff --git a/charts/zitadel/acceptance_test/acceptance_test.go b/charts/zitadel/acceptance_test/acceptance_test.go new file mode 100644 index 00000000..1a1c9eed --- /dev/null +++ b/charts/zitadel/acceptance_test/acceptance_test.go @@ -0,0 +1,154 @@ +package acceptance_test + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/stretchr/testify/suite" +) + +func TestPostgresInsecure(t *testing.T) { + t.Parallel() + example := "1-postgres-insecure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + nil, + )) +} + +func TestPostgresSecure(t *testing.T) { + t.Parallel() + example := "2-postgres-secure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + func(cfg *ConfigurationTest) { + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "certs-job.yaml")) + k8s.WaitUntilJobSucceed(t, cfg.KubeOptions, "create-certs", 120, 3*time.Second) + }, + nil, + nil, + )) +} + +func TestCockroachInsecure(t *testing.T) { + t.Parallel() + example := "3-cockroach-insecure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Cockroach.WithValues(filepath.Join(workDir, "cockroach-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + nil, + )) +} + +func TestCockroachSecure(t *testing.T) { + t.Parallel() + example := "4-cockroach-secure" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Cockroach.WithValues(filepath.Join(workDir, "cockroach-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + func(cfg *ConfigurationTest) { + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "zitadel-cert-job.yaml")) + k8s.WaitUntilJobSucceed(t, cfg.KubeOptions, "create-zitadel-cert", 120, 3*time.Second) + }, + nil, + )) +} + +func TestReferencedSecrets(t *testing.T) { + t.Parallel() + example := "5-referenced-secrets" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + func(cfg *ConfigurationTest) { + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "zitadel-secrets.yaml")) + k8s.KubectlApply(t, cfg.KubeOptions, filepath.Join(workDir, "zitadel-masterkey.yaml")) + }, + nil, + )) +} + +func TestMachineUser(t *testing.T) { + t.Parallel() + example := "6-machine-user" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + saUsername := cfg.FirstInstance.Org.Machine.Machine.Username + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + testAuthenticatedAPI(saUsername, fmt.Sprintf("%s.json", saUsername))), + ) +} + +func TestSelfSigned(t *testing.T) { + t.Parallel() + example := "7-self-signed" + workDir, valuesFile, values := readConfig(t, example) + cfg := values.Zitadel.ConfigmapConfig + suite.Run(t, Configure( + t, + newNamespaceIdentifier(example), + Postgres.WithValues(filepath.Join(workDir, "postgres-values.yaml")), + []string{valuesFile}, + cfg.ExternalDomain, + cfg.ExternalPort, + cfg.ExternalSecure, + nil, + nil, + nil, + )) +} diff --git a/charts/zitadel/acceptance_test/accessibility_test.go b/charts/zitadel/acceptance_test/accessibility_test.go new file mode 100644 index 00000000..418c1462 --- /dev/null +++ b/charts/zitadel/acceptance_test/accessibility_test.go @@ -0,0 +1,138 @@ +package acceptance_test + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + mgmt_api "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/management" + + "github.com/gruntwork-io/terratest/modules/k8s" + corev1 "k8s.io/api/core/v1" +) + +type checkOptions interface { + execute(ctx context.Context) error +} + +type checkOptionsFunc func(ctx context.Context) error + +func (f checkOptionsFunc) execute(ctx context.Context) error { + return f(ctx) +} + +type httpCheckOptions struct { + getUrl string + test func(response *http.Response, body []byte) error +} + +func (c *httpCheckOptions) execute(ctx context.Context) (err error) { + checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) + defer checkCancel() + //nolint:bodyclose + resp, body, err := HttpGet(checkCtx, c.getUrl, nil) + if err != nil { + return fmt.Errorf("HttpGet failed with response %+v and body %+v: %w", resp, body, err) + } + if err = c.test(resp, body); err != nil { + return fmt.Errorf("checking response %+v with body %+v failed: %w", resp, body, err) + } + return nil +} + +func (s *ConfigurationTest) checkAccessibility(pods []corev1.Pod) { + ctx, cancel := context.WithTimeout(s.Ctx, time.Minute) + defer cancel() + apiBaseURL := s.APIBaseURL() + tunnels := []interface{ Close() }{CloseFunc(ServiceTunnel(s))} + defer func() { + for _, t := range tunnels { + t.Close() + } + }() + checks := append( + zitadelStatusChecks(s.Scheme, s.Domain, s.Port), + &httpCheckOptions{ + getUrl: apiBaseURL + "/ui/console/assets/environment.json", + test: func(resp *http.Response, body []byte) error { + if err := checkHttpStatus200(resp, body); err != nil { + return err + } + bodyStr := string(body) + for _, expect := range []string{ + fmt.Sprintf(`"api":"%s"`, apiBaseURL), + fmt.Sprintf(`"issuer":"%s"`, apiBaseURL), + } { + if !strings.Contains(bodyStr, expect) { + return fmt.Errorf("couldn't find %s in environment.json content %s", expect, bodyStr) + } + } + return nil + }, + }, + checkOptionsFunc(func(ctx context.Context) error { + randomInvalidKey := `{"type":"serviceaccount","keyId":"229185755715993707","key":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAm8bpVfzWJuZsEz1VfTrwSAdkbH+i/u2NS4dv60lwIjtXzrU7\n1xZkHw9jxqz+c+APTaTzp1KY49Dc/wcwXv032FuD1GK2ZSRnMaHm8QnNt8Xhi0e8\nBlu3QQmlqxWPCI67wDPUwXoSHM+r9gQXn2pOR0oonoLP+Gzef+RRj1zUFpZmHWPX\nxw4UWWHwl4xChw9iyO4HbZZGe6wBVYVWe2BnvviCVEeKapyjaCqokZES38S4+S2X\nit202xLlRDyXs3XFWmBzHGmEsxx3LZZor85Kbph/bGjDcV8rdQC1YKC++z8OhuLp\n79GltP7YWrfMN3Z8iRUJQY9APrKQYtljVkWrnQIDAQABAoIBAQCIRZrLyRHCF+LF\ndes6UPvv1t+n9oQtRLxNLV7f0m+Q0p7+yhZeE01kyn67R4yU65YXk0w+vIfZC1a4\nlp5fCl73Gx+ZBP2QPyczCPHRPIVE1Yt33zoByevmrjzKDGMC1nIyMmVVF6eOorFI\n1s2ffEycGqir+b1bEkoWUTJ0Gn3Cf1PE4vTgenHhCrYSvMsbmszQ5GDlfxNj27qf\nF2YrnLx11GplMYU0YEzGqSQHxw76rrmF7yiTvbB+olsjXWARAJxBriSlrF2BDYQk\n+HJ8MEwhWhncaZH1i0Xz/jarDBizpo2o1+K1ZqF6RBUknT72EPnMxI9JsvS4FH44\nZfbrujBhAoGBAMQnx6tO79GpnBIAr7iELyUu5F4mCdU6D0rOAiCjXPpCUAdCDuwX\nzROonIGXPPmhzXXtxebeTz4cf+P8p6tUnrqpl/f0Oi1DMOzv0jL/SAUDC9uUrg6k\nurXZT2dgeONwd1pADyNXSpbZfwRE5IoecFg6cgFi4kune0mdG3mr8QjpAoGBAMtN\nerrMc+4bc3GsmWG4FSXn3xlWMeVGIo2/owP2P5MuMu0ibjofZkl28y0xo8dJgWmv\nLiFSEOhUy+TXZK7K1a2+fD+AXHHaHkBjNbTmCaAbf7rZnuUL4iZVpQyIoTCVuAwo\nC6bsE4TcwGddk4yZj/WZ7v1be+uNgeYwQr2UshyVAoGAN8pYsBCzhR6IlVY8pG50\nOk8sBNss0MjCsLQHRuEwAL37pRTUybG7UmwSl4k8foPWvEP0lcWFJFVWyrGBvulC\nfDTgVFXSdi02LS3Iy1hwU3yaUsnm96NCt5YnT2/Q8l96kuDFbXfWbzFNPxmZJu+h\nZHa7FknZs0rfdgCJYAHXfIECgYEAw3kSqSrNyMICJOkkbO2W/+RLAUx8GwttS8dX\nkQaip/wCoTi6rQ3lxnslY23YIFRPpvL1srn6YbiudrCXMOz7uNtvEYt01082SQha\n6j1IQfZOwLRfb7EWV29/i2aPPWynEqEqWuuf9N5f7MLvjH9WCHpibJ4aryhXHqGG\nekvPWWUCgYA5qDsPk5ykRWEALbunzB/RkpxR6LTLSwriU/OzRswOiKo8UPqH4JZI\nOsFAgudG5H+UOEGMuaSvIq0PLbGex16PjKqUsRwgIoPdH8183f9fxZSJDmr7ELIy\nZJEvE3eJnYwMOpSEZS0VR5Sw0CmKV2Hhd+u6rRB8YjXMP0nAVg8eOA==\n-----END RSA PRIVATE KEY-----\n","userId":"229185755715600491"}` + conn, err := OpenGRPCConnection(s, []byte(randomInvalidKey)) + if errors.As(err, &x509.UnknownAuthorityError{}) { + // The gRPC client doesn't support skipping the server cert validation + return nil + } + if err != nil { + return fmt.Errorf("couldn't create gRPC management client: %w", err) + } + _, err = conn.Healthz(ctx, &mgmt_api.HealthzRequest{}) + // TODO: Why is the key checked on the healthz RPC? + if strings.Contains(err.Error(), "Errors.AuthNKey.NotFound") || + strings.Contains(err.Error(), "Errors.User.NotFound") || + strings.Contains(err.Error(), "assertion invalid") { + err = nil + } + return err + })) + for i := range pods { + pod := pods[i] + podTunnel := k8s.NewTunnel(s.KubeOptions, k8s.ResourceTypePod, pod.Name, 0, 8080) + podTunnel.ForwardPort(s.T()) + tunnels = append(tunnels, podTunnel) + localPort, err := strconv.ParseUint(strings.Split(podTunnel.Endpoint(), ":")[1], 10, 16) + if err != nil { + s.T().Fatal(err) + } + checks = append(checks, zitadelStatusChecks(s.Scheme, s.Domain, uint16(localPort))...) + } + wg := sync.WaitGroup{} + for _, check := range checks { + wg.Add(1) + go Await(ctx, s.T(), &wg, 60, check.execute) + } + wait(ctx, s.T(), &wg, "accessibility") +} + +func zitadelStatusChecks(scheme, domain string, port uint16) []checkOptions { + return []checkOptions{ + &httpCheckOptions{ + getUrl: fmt.Sprintf("%s://%s:%d/debug/validate", scheme, domain, port), + test: checkHttpStatus200, + }, + &httpCheckOptions{ + getUrl: fmt.Sprintf("%s://%s:%d/debug/healthz", scheme, domain, port), + test: checkHttpStatus200, + }, + &httpCheckOptions{ + getUrl: fmt.Sprintf("%s://%s:%d/debug/ready", scheme, domain, port), + test: checkHttpStatus200, + }} +} + +func checkHttpStatus200(resp *http.Response, _ []byte) error { + if resp.StatusCode != 200 { + return fmt.Errorf("expected status code 200 but got %d", resp.StatusCode) + } + return nil +} diff --git a/charts/zitadel/acceptance_test/after_test.go b/charts/zitadel/acceptance_test/after_test.go new file mode 100644 index 00000000..25b63b82 --- /dev/null +++ b/charts/zitadel/acceptance_test/after_test.go @@ -0,0 +1,8 @@ +package acceptance_test + +func (s *ConfigurationTest) AfterTest(_, _ string) { + if s.afterZITADELFunc == nil || s.T().Failed() { + return + } + s.afterZITADELFunc(s) +} diff --git a/charts/zitadel/acceptance_test/authenticate_test.go b/charts/zitadel/acceptance_test/authenticate_test.go new file mode 100644 index 00000000..f4e59655 --- /dev/null +++ b/charts/zitadel/acceptance_test/authenticate_test.go @@ -0,0 +1,102 @@ +package acceptance_test + +import ( + "context" + "encoding/json" + "fmt" + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/zitadel/oidc/pkg/oidc" + mgmt_api "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/management" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func testAuthenticatedAPI(secretName, secretKey string) func(test *ConfigurationTest) { + return func(cfg *ConfigurationTest) { + t := cfg.T() + apiBaseURL := cfg.APIBaseURL() + secret := k8s.GetSecret(t, cfg.KubeOptions, secretName) + key := secret.Data[secretKey] + if key == nil { + t.Fatalf("key %s in secret %s is nil", secretKey, secretName) + } + jwta, err := oidc.NewJWTProfileAssertionFromFileData(key, []string{apiBaseURL}) + if err != nil { + t.Fatal(err) + } + jwt, err := oidc.GenerateJWTProfileToken(jwta) + if err != nil { + t.Fatal(err) + } + closeTunnel := ServiceTunnel(cfg) + defer closeTunnel() + var token string + Await(cfg.Ctx, t, nil, 60, func(ctx context.Context) error { + var tokenErr error + token, tokenErr = getToken(ctx, t, jwt, apiBaseURL) + return tokenErr + }) + Await(cfg.Ctx, t, nil, 60, func(ctx context.Context) error { + if httpErr := callAuthenticatedHTTPEndpoint(ctx, token, apiBaseURL); httpErr != nil { + return httpErr + } + return callAuthenticatedGRPCEndpoint(cfg, key) + }) + } +} + +func getToken(ctx context.Context, t *testing.T, jwt, apiBaseURL string) (string, error) { + form := url.Values{} + form.Add("grant_type", string(oidc.GrantTypeBearer)) + form.Add("scope", fmt.Sprintf("%s %s %s urn:zitadel:iam:org:project:id:zitadel:aud", oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail)) + form.Add("assertion", jwt) + //nolint:bodyclose + resp, tokenBody, err := HttpPost(ctx, fmt.Sprintf("%s/oauth/v2/token", apiBaseURL), func(req *http.Request) { + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + }, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("expected token response 200, but got %d", resp.StatusCode) + } + token := struct { + AccessToken string `json:"access_token"` + }{} + if err = json.Unmarshal(tokenBody, &token); err != nil { + t.Fatal(err) + } + return token.AccessToken, nil +} + +func callAuthenticatedHTTPEndpoint(ctx context.Context, token, apiBaseURL string) error { + checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) + defer checkCancel() + //nolint:bodyclose + resp, _, err := HttpGet(checkCtx, fmt.Sprintf("%s/management/v1/languages", apiBaseURL), func(req *http.Request) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + }) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("expected status 200 at an authenticated endpoint, but got %d", resp.StatusCode) + } + return nil +} + +func callAuthenticatedGRPCEndpoint(cfg *ConfigurationTest, key []byte) error { + t := cfg.T() + conn, err := OpenGRPCConnection(cfg, key) + if err != nil { + return fmt.Errorf("couldn't open gRPC connection: %v", err) + } + _, err = conn.GetSupportedLanguages(cfg.Ctx, &mgmt_api.GetSupportedLanguagesRequest{}) + if err != nil { + t.Fatalf("couldn't call authenticated gRPC endpoint: %v", err) + } + return nil +} diff --git a/charts/zitadel/acceptance_test/availability_test.go b/charts/zitadel/acceptance_test/availability_test.go new file mode 100644 index 00000000..0284cb03 --- /dev/null +++ b/charts/zitadel/acceptance_test/availability_test.go @@ -0,0 +1,24 @@ +package acceptance_test + +import ( + "context" + "sync" + "time" + + "github.com/gruntwork-io/terratest/modules/k8s" + corev1 "k8s.io/api/core/v1" +) + +func (s *ConfigurationTest) awaitReadiness(pods []corev1.Pod) { + ctx, cancel := context.WithTimeout(s.Ctx, 5*time.Minute) + defer cancel() + wg := sync.WaitGroup{} + for _, p := range pods { + wg.Add(1) + go func(pod corev1.Pod) { + k8s.WaitUntilPodAvailable(s.T(), s.KubeOptions, pod.Name, 300, time.Second) + wg.Done() + }(p) + } + wait(ctx, s.T(), &wg, "readiness") +} diff --git a/charts/zitadel/acceptance_test/await_test.go b/charts/zitadel/acceptance_test/await_test.go new file mode 100644 index 00000000..0a62192a --- /dev/null +++ b/charts/zitadel/acceptance_test/await_test.go @@ -0,0 +1,24 @@ +package acceptance_test + +import ( + "context" + "sync" + "testing" + "time" +) + +func Await(ctx context.Context, t *testing.T, wg *sync.WaitGroup, tries int, cb func(ctx context.Context) error) { + err := cb(ctx) + if err == nil { + if wg != nil { + wg.Done() + } + return + } + if tries == 0 { + t.Fatal(err) + } + t.Logf("got error %v. trying again in a second", err) + time.Sleep(time.Second) + Await(ctx, t, wg, tries-1, cb) +} diff --git a/charts/zitadel/acceptance_test/before_test.go b/charts/zitadel/acceptance_test/before_test.go new file mode 100644 index 00000000..b02da0e6 --- /dev/null +++ b/charts/zitadel/acceptance_test/before_test.go @@ -0,0 +1,22 @@ +package acceptance_test + +import "github.com/gruntwork-io/terratest/modules/helm" + +func (s *ConfigurationTest) BeforeTest(_, _ string) { + if s.beforeFunc != nil { + s.beforeFunc(s) + } + options := &helm.Options{ + KubectlOptions: s.KubeOptions, + Version: s.dbChart.version, + SetValues: s.dbChart.testValues, + ExtraArgs: map[string][]string{"install": {"--wait"}}, + } + if s.dbChart.valuesFile != "" { + options.ValuesFiles = []string{s.dbChart.valuesFile} + } + helm.Install(s.T(), options, s.dbChart.Name+"/"+s.dbChart.Name, s.dbRelease) + if s.afterDBFunc != nil { + s.afterDBFunc(s) + } +} diff --git a/charts/zitadel/acceptance_test/configure_test.go b/charts/zitadel/acceptance_test/configure_test.go new file mode 100644 index 00000000..49a35abd --- /dev/null +++ b/charts/zitadel/acceptance_test/configure_test.go @@ -0,0 +1,98 @@ +package acceptance_test + +import ( + "context" + "fmt" + "testing" + + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/gruntwork-io/terratest/modules/logger" + "github.com/stretchr/testify/suite" + "k8s.io/client-go/kubernetes" +) + +type hookFunc func(*ConfigurationTest) + +type ConfigurationTest struct { + suite.Suite + Ctx context.Context + log *logger.Logger + KubeOptions *k8s.KubectlOptions + KubeClient *kubernetes.Clientset + Scheme, Domain string + Port uint16 + zitadelValues []string + dbChart *databaseChart + zitadelRelease, dbRelease string + beforeFunc, afterDBFunc, afterZITADELFunc hookFunc +} + +func (c *ConfigurationTest) APIBaseURL() string { + return fmt.Sprintf(`%s://%s:%d`, c.Scheme, c.Domain, c.Port) +} + +type databaseChart struct { + valuesFile, RepoUrl, Name, version string + testValues map[string]string +} + +var ( + Cockroach = databaseChart{ + RepoUrl: "https://charts.cockroachdb.com/", + Name: "cockroachdb", + version: "13.0.1", + testValues: map[string]string{ + "statefulset.replicas": "1", + "conf.single-node": "true", + }, + } + Postgres = databaseChart{ + RepoUrl: "https://charts.bitnami.com/bitnami", + Name: "postgresql", + version: "12.10.0", + } +) + +func (d databaseChart) WithValues(valuesFile string) *databaseChart { + d.valuesFile = valuesFile + return &d +} + +func Configure( + t *testing.T, + namespace string, + dbChart *databaseChart, + zitadelValues []string, + externalDomain string, + externalPort uint16, + externalSecure bool, + before, afterDB, afterZITADEL hookFunc, +) *ConfigurationTest { + kubeOptions := k8s.NewKubectlOptions("", "", namespace) + clientset, err := k8s.GetKubernetesClientFromOptionsE(t, kubeOptions) + if err != nil { + t.Fatal(err) + } + externalScheme := "http" + if externalSecure { + externalScheme = "https" + } + cfg := &ConfigurationTest{ + Ctx: context.Background(), + log: logger.New(logger.Terratest), + KubeOptions: kubeOptions, + KubeClient: clientset, + zitadelValues: zitadelValues, + zitadelRelease: "zitadel-test", + dbChart: dbChart, + dbRelease: "db", + beforeFunc: before, + afterDBFunc: afterDB, + afterZITADELFunc: afterZITADEL, + Domain: externalDomain, + Port: externalPort, + Scheme: externalScheme, + } + cfg.SetT(t) + return cfg +} diff --git a/charts/zitadel/acceptance_test/grpc_test.go b/charts/zitadel/acceptance_test/grpc_test.go new file mode 100644 index 00000000..99a28bef --- /dev/null +++ b/charts/zitadel/acceptance_test/grpc_test.go @@ -0,0 +1,19 @@ +package acceptance_test + +import ( + "fmt" + "github.com/zitadel/zitadel-go/v2/pkg/client/management" + "github.com/zitadel/zitadel-go/v2/pkg/client/middleware" + "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel" +) + +func OpenGRPCConnection(cfg *ConfigurationTest, key []byte) (*management.Client, error) { + conn, err := management.NewClient( + cfg.APIBaseURL(), + fmt.Sprintf("%s:%d", cfg.Domain, cfg.Port), + []string{zitadel.ScopeZitadelAPI()}, + zitadel.WithJWTProfileTokenSource(middleware.JWTProfileFromFileData(key)), + zitadel.WithInsecure(), + ) + return conn, err +} diff --git a/charts/zitadel/acceptance_test/main_test.go b/charts/zitadel/acceptance_test/main_test.go new file mode 100644 index 00000000..7f864549 --- /dev/null +++ b/charts/zitadel/acceptance_test/main_test.go @@ -0,0 +1,51 @@ +package acceptance_test + +import ( + "github.com/gruntwork-io/terratest/modules/helm" + terratesting "github.com/gruntwork-io/terratest/modules/testing" + "github.com/stretchr/testify/require" + "log" + "os" + "path/filepath" + "testing" +) + +var ChartPath string + +func TestMain(m *testing.M) { + t := &mockT{*log.New(os.Stderr, "", 0)} + var err error + ChartPath, err = filepath.Abs("..") + require.NoError(t, err) + helm.AddRepo(t, &helm.Options{}, Postgres.Name, Postgres.RepoUrl) + helm.AddRepo(t, &helm.Options{}, Cockroach.Name, Cockroach.RepoUrl) + _, err = helm.RunHelmCommandAndGetOutputE(t, &helm.Options{}, "dependencies", "build", ChartPath) + require.NoError(t, err) + m.Run() +} + +var _ terratesting.TestingT = &mockT{} + +type mockT struct { + log.Logger +} + +func (m *mockT) Fail() { + m.Logger.Fatal("Fail() called") +} + +func (m *mockT) FailNow() { + m.Logger.Fatal("FailNow() called") +} + +func (m *mockT) Error(args ...interface{}) { + m.Logger.Fatalf("FailNow(%v) called", args) +} + +func (m *mockT) Errorf(format string, args ...interface{}) { + m.Logger.Fatalf("FailNow(%s, %v) called", format, args) +} + +func (m *mockT) Name() string { + return "TestMain" +} diff --git a/charts/zitadel/acceptance_test/read_config_test.go b/charts/zitadel/acceptance_test/read_config_test.go new file mode 100644 index 00000000..c15d9838 --- /dev/null +++ b/charts/zitadel/acceptance_test/read_config_test.go @@ -0,0 +1,81 @@ +package acceptance_test + +import ( + "fmt" + "github.com/gruntwork-io/terratest/modules/random" + "gopkg.in/yaml.v3" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +type Values struct { + Zitadel struct { + MasterkeySecretName string `yaml:"masterkeySecretName"` + ConfigSecretName string `yaml:"configSecretName"` + ConfigmapConfig struct { + ExternalDomain string `yaml:"ExternalDomain"` + ExternalPort uint16 `yaml:"ExternalPort"` + ExternalSecure bool `yaml:"ExternalSecure"` + FirstInstance struct { + Org struct { + Machine struct { + Machine struct { + Username string `yaml:"Username"` + } `yaml:"Machine"` + } `yaml:"Machine"` + } `yaml:"Org"` + } `yaml:"FirstInstance"` + } `yaml:"configmapConfig"` + } `yaml:"zitadel"` +} + +func readValues(t *testing.T, valuesFilePath string) (values Values) { + // set default values like in the defaults.yaml + values.Zitadel.ConfigmapConfig.ExternalDomain = "localhost" + values.Zitadel.ConfigmapConfig.ExternalPort = 8080 + values.Zitadel.ConfigmapConfig.ExternalSecure = true + valuesBytes, err := os.ReadFile(valuesFilePath) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(valuesBytes, &values); err != nil { + t.Fatal(err) + } + return values +} + +func newNamespaceIdentifier(testcase string) string { + // if triggered by a github action the environment variable is set + // we use it to better identify the test + commitSHA, exist := os.LookupEnv("GITHUB_SHA") + namespace := fmt.Sprintf("zitadel-test-%s-%s", testcase, strings.ToLower(random.UniqueId())) + if exist { + namespace += "-" + commitSHA + } + // max namespace length is 63 characters + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names + return truncateString(namespace, 63) +} + +func truncateString(str string, num int) string { + shortenStr := str + if len(str) > num { + shortenStr = str[0:num] + } + return shortenStr +} + +func workingDirectory(exampleDir string) (workingDir, valuesFile string) { + _, filename, _, _ := runtime.Caller(0) + workingDir = filepath.Join(filename, "..", "..", "..", "..", "examples", exampleDir) + valuesFile = filepath.Join(workingDir, "zitadel-values.yaml") + return workingDir, valuesFile +} + +func readConfig(t *testing.T, exampleDir string) (string, string, Values) { + workingDir, valuesFile := workingDirectory(exampleDir) + return workingDir, valuesFile, readValues(t, valuesFile) +} diff --git a/charts/zitadel/acceptance_test/request_test.go b/charts/zitadel/acceptance_test/request_test.go new file mode 100644 index 00000000..632a1771 --- /dev/null +++ b/charts/zitadel/acceptance_test/request_test.go @@ -0,0 +1,35 @@ +package acceptance_test + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" +) + +func HttpGet(ctx context.Context, url string, beforeSend func(req *http.Request)) (*http.Response, []byte, error) { + return httpCall(ctx, http.MethodGet, url, beforeSend, nil) +} + +func HttpPost(ctx context.Context, url string, beforeSend func(req *http.Request), body io.Reader) (*http.Response, []byte, error) { + return httpCall(ctx, http.MethodPost, url, beforeSend, body) +} + +func httpCall(ctx context.Context, method string, url string, beforeSend func(req *http.Request), requestBody io.Reader) (*http.Response, []byte, error) { + req, err := http.NewRequestWithContext(ctx, method, url, requestBody) + if err != nil { + return nil, nil, fmt.Errorf("creating request for url %s failed: %s", url, err.Error()) + } + if beforeSend != nil { + beforeSend(req) + } + httpClient := http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + resp, err := httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("sending request %+v failed: %s", *req, err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + return resp, responseBody, err +} diff --git a/charts/zitadel/acceptance_test/run_test.go b/charts/zitadel/acceptance_test/run_test.go new file mode 100644 index 00000000..08ace6a8 --- /dev/null +++ b/charts/zitadel/acceptance_test/run_test.go @@ -0,0 +1,48 @@ +package acceptance_test + +import ( + "testing" + "time" + + "github.com/gruntwork-io/terratest/modules/helm" + "github.com/gruntwork-io/terratest/modules/k8s" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (s *ConfigurationTest) TestZITADELInstallation() { + helm.Install(s.T(), &helm.Options{ + KubectlOptions: s.KubeOptions, + ValuesFiles: s.zitadelValues, + SetValues: map[string]string{ + "replicaCount": "1", + "pdb.enabled": "true", + }, + }, ChartPath, s.zitadelRelease) + k8s.WaitUntilJobSucceed(s.T(), s.KubeOptions, "zitadel-test-init", 900, time.Second) + k8s.WaitUntilJobSucceed(s.T(), s.KubeOptions, "zitadel-test-setup", 900, time.Second) + pods := listPods(s.T(), 5, s.KubeOptions) + s.awaitReadiness(pods) + zitadelPods := make([]corev1.Pod, 0) + for i := range pods { + pod := pods[i] + if name, ok := pod.GetObjectMeta().GetLabels()["app.kubernetes.io/name"]; ok && name == "zitadel" { + zitadelPods = append(zitadelPods, pod) + } + } + s.log.Logf(s.T(), "ZITADEL pods are ready") + s.checkAccessibility(zitadelPods) +} + +// listPods retries until all three start pods are returned from the kubeapi +func listPods(t *testing.T, try int, kubeOptions *k8s.KubectlOptions) []corev1.Pod { + if try == 0 { + t.Fatal("no trials left") + } + pods := k8s.ListPods(t, kubeOptions, metav1.ListOptions{LabelSelector: `app.kubernetes.io/instance=zitadel-test, app.kubernetes.io/component=start`}) + if len(pods) == 1 { + return pods + } + time.Sleep(time.Second) + return listPods(t, try-1, kubeOptions) +} diff --git a/charts/zitadel/acceptance_test/service_tunnel_test.go b/charts/zitadel/acceptance_test/service_tunnel_test.go new file mode 100644 index 00000000..42bfd96b --- /dev/null +++ b/charts/zitadel/acceptance_test/service_tunnel_test.go @@ -0,0 +1,39 @@ +package acceptance_test + +import ( + "context" + "fmt" + "github.com/gruntwork-io/terratest/modules/k8s" + "net" +) + +type CloseFunc func() + +func (c CloseFunc) Close() { + c() +} + +// ServiceTunnel must be closed using the returned close function +func ServiceTunnel(cfg *ConfigurationTest) func() { + serviceTunnel := k8s.NewTunnel(cfg.KubeOptions, k8s.ResourceTypeService, cfg.zitadelRelease, int(cfg.Port), 8080) + awaitServicePortForward(cfg, serviceTunnel) + return serviceTunnel.Close +} + +func awaitServicePortForward(cfg *ConfigurationTest, tunnel *k8s.Tunnel) { + t := cfg.T() + addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) + if err != nil { + t.Fatal(err) + } + Await(cfg.Ctx, t, nil, 600, func(ctx context.Context) error { + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return err + } + if err := l.Close(); err != nil { + panic(err) + } + return tunnel.ForwardPortE(cfg.T()) + }) +} diff --git a/charts/zitadel/acceptance_test/setup_test.go b/charts/zitadel/acceptance_test/setup_test.go new file mode 100644 index 00000000..9a31e7c5 --- /dev/null +++ b/charts/zitadel/acceptance_test/setup_test.go @@ -0,0 +1,49 @@ +package acceptance_test + +import ( + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/jinzhu/copier" + "k8s.io/apimachinery/pkg/api/errors" + "strings" +) + +func (s *ConfigurationTest) SetupTest() { + clusterKubectl := new(k8s.KubectlOptions) + t := s.T() + if err := copier.Copy(clusterKubectl, s.KubeOptions); err != nil { + t.Fatal(err) + } + clusterKubectl.Namespace = "" + if err := k8s.KubectlDeleteFromStringE(t, clusterKubectl, ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: crdb +`); err != nil && !isNotFoundFromKubectl(err) { + t.Fatal(err) + } + if err := k8s.KubectlDeleteFromStringE(t, clusterKubectl, ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: crdb +`); err != nil && !isNotFoundFromKubectl(err) { + t.Fatal(err) + } + _, err := k8s.GetNamespaceE(t, s.KubeOptions, s.KubeOptions.Namespace) + notFound := errors.IsNotFound(err) + if err != nil && !notFound { + t.Fatal(err) + } + if notFound { + k8s.CreateNamespace(t, s.KubeOptions, s.KubeOptions.Namespace) + } + +} + +func isNotFoundFromKubectl(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "not found") +} diff --git a/charts/zitadel/acceptance_test/teardown_test.go b/charts/zitadel/acceptance_test/teardown_test.go new file mode 100644 index 00000000..e1ee34d9 --- /dev/null +++ b/charts/zitadel/acceptance_test/teardown_test.go @@ -0,0 +1,11 @@ +package acceptance_test + +import "github.com/gruntwork-io/terratest/modules/k8s" + +func (s *ConfigurationTest) TearDownTest() { + if !s.T().Failed() { + k8s.DeleteNamespace(s.T(), s.KubeOptions, s.KubeOptions.Namespace) + } else { + s.log.Logf(s.T(), "Test failed on namespace %s. Omitting cleanup.", s.KubeOptions.Namespace) + } +} diff --git a/charts/zitadel/acceptance_test/wait_test.go b/charts/zitadel/acceptance_test/wait_test.go new file mode 100644 index 00000000..9426965e --- /dev/null +++ b/charts/zitadel/acceptance_test/wait_test.go @@ -0,0 +1,26 @@ +package acceptance_test + +import ( + "context" + "sync" + "testing" +) + +func wait(ctx context.Context, t *testing.T, wg *sync.WaitGroup, waitFor string) { + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + for { + select { + case <-done: + return + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + t.Fatalf("awaiting %s failed: %s", waitFor, err) + } + return + } + } +} diff --git a/charts/zitadel/templates/certsjob.yaml b/charts/zitadel/templates/certsjob.yaml new file mode 100644 index 00000000..6de0022e --- /dev/null +++ b/charts/zitadel/templates/certsjob.yaml @@ -0,0 +1,220 @@ +{{- if .Values.certJob.enabled -}} +{{- if and (or .Values.postgresql.enabled .Values.cockroachdb.enabled) (.Values.certJob.manual) -}} +{{ fail "cannot have both a DB enabled and manual option."}} +{{- end -}} +{{- if and .Values.postgresql.enabled .Values.cockroachdb.enabled -}} +{{ fail "you can only enable one database."}} +{{- end -}} + +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{include "zitadel.fullname" . }}-create-certs" + labels: + {{- include "zitadel.labels" . | nindent 4 }} + app.kubernetes.io/component: create-certs + {{- with .Values.certJob.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + backoffLimit: {{.Values.certJob.backoffLimit}} + activeDeadlineSeconds: {{.Values.certJob.activeDeadlineSeconds}} + template: + metadata: + labels: + {{- include "zitadel.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: init + {{- with .Values.certJob.podAdditionalLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.certJob.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + restartPolicy: OnFailure + serviceAccountName: {{ .Values.certJob.serviceAccountName }} + + {{- if or (.Values.postgresql.enabled) (eq .Values.certJob.manual "postgresql") }} + + initContainers: + - image: alpine/openssl + imagePullPolicy: IfNotPresent + name: create-certs + volumeMounts: + - mountPath: /secret + name: secret + command: + - /bin/ash + - -c + - | + function createKey() { + USER=$1 + openssl genrsa -out ${USER}.key 2048 + echo "created ${USER}.key" + } + function createSigningRequest() { + USER=$1 + openssl req -new -key ${USER}.key -extensions 'v3_req' -out ${USER}.csr -config <(generateServerConfig) + echo "created ${USER}.csr" + } + function generateServerConfig() { + cat<> ${USER}-cert.json + } + cd /secret + # Create a CA key and cert for signing other certs + createKey ca + openssl req -x509 -new -nodes -key ca.key -days 365 -out ca.crt -subj "/CN=My Custom CA" + createKey postgres + createSigningRequest postgres + signCertificate postgres.csr postgres.crt ca.crt ca.key + createCertSecret postgres + createKey zitadel + createSigningRequest zitadel + signCertificate zitadel.csr zitadel.crt ca.crt ca.key + createCertSecret zitadel + + containers: + - image: alpine/curl + name: apply-certs + imagePullPolicy: IfNotPresent + volumeMounts: + - mountPath: /secret + name: secret + command: + - /bin/ash + - -c + - | + export APISERVER=https://kubernetes.default.svc SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount + export NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt + function uploadSecret { + USER=$1 + curl \ + --cacert ${CACERT} \ + --header "Authorization: Bearer ${TOKEN}" \ + --header "Content-Type: application/json" \ + -X POST ${APISERVER}/api/v1/namespaces/${NAMESPACE}/secrets \ + --data "$(tr -d '\n' < /secret/${USER}-cert.json)" \ + > /dev/null || echo "error uploading ${USER} secret: $?" + } + uploadSecret postgres + uploadSecret zitadel + + volumes: + - name: {{ .Value.certJob.volumeName }} + emptyDir: {} + {{- end}} + + {{- if or (.Values.cockroachdb.enabled) (eq .Values.certJob.manual "cockroachdb") }} + initContainers: + - image: busybox + imagePullPolicy: IfNotPresent + name: copy-certs + command: + - /bin/sh + - -c + - cp -f /certs/* /cockroach-certs/; chmod 0400 /cockroach-certs/*.key + + volumeMounts: + - mountPath: /cockroach-certs/ + name: {{.Values.certJob.volumeName}} + - mountPath: /certs/ + name: {{.Values.certJob.secretName}} + + containers: + - image: cockroachdb/cockroach:v23.1.8 + imagePullPolicy: IfNotPresent + name: create-zitadel-cert + volumeMounts: + - mountPath: /cockroach/cockroach-certs/ + name: {{.Values.certJob.volumeName}} + command: + - /bin/bash + - -ecx + - | + cockroach cert create-client \ + --certs-dir /cockroach/cockroach-certs \ + --ca-key /cockroach/cockroach-certs/ca.key \ + --lifetime 8760h \ + zitadel + export SECRET=$(cat <