|
| 1 | +//go:build spanner_integration |
| 2 | + |
| 3 | +package testdb |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "crypto/rand" |
| 8 | + "encoding/hex" |
| 9 | + "fmt" |
| 10 | + "log/slog" |
| 11 | + "os" |
| 12 | + "strings" |
| 13 | + |
| 14 | + database_admin "cloud.google.com/go/spanner/admin/database/apiv1" |
| 15 | + "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" |
| 16 | + "google.golang.org/api/option" |
| 17 | +) |
| 18 | + |
| 19 | +// instanceEnv names a shared Spanner instance |
| 20 | +// (projects/<project>/instances/<instance>). When set, suites provision a |
| 21 | +// uniquely named database on that instance instead of starting the emulator. |
| 22 | +const instanceEnv = "ZITADEL_TEST_SPANNER_INSTANCE" |
| 23 | + |
| 24 | +func provision(ctx context.Context) (string, func(), error) { |
| 25 | + project, instance, err := parseInstancePath(strings.TrimSpace(os.Getenv(instanceEnv))) |
| 26 | + if err != nil { |
| 27 | + return "", func() {}, err |
| 28 | + } |
| 29 | + |
| 30 | + dbID := uniqueDatabaseID() |
| 31 | + if err := createDatabase(ctx, project, instance, dbID); err != nil { |
| 32 | + return "", func() {}, fmt.Errorf("unable to create Spanner test database %q: %w", dbID, err) |
| 33 | + } |
| 34 | + |
| 35 | + drop := func() { |
| 36 | + if err := dropDatabase(context.Background(), project, instance, dbID); err != nil { |
| 37 | + slog.Error("unable to drop Spanner test database", "database", dbID, "err", err) |
| 38 | + } |
| 39 | + } |
| 40 | + |
| 41 | + dsn := fmt.Sprintf("projects/%s/instances/%s/databases/%s", project, instance, dbID) |
| 42 | + slog.Info("provisioned Spanner test database", "dsn", dsn, "run_id", os.Getenv("GITHUB_RUN_ID")) |
| 43 | + return dsn, drop, nil |
| 44 | +} |
| 45 | + |
| 46 | +func createDatabase(ctx context.Context, project, instance, dbID string, opts ...option.ClientOption) error { |
| 47 | + client, err := database_admin.NewDatabaseAdminClient(ctx, opts...) |
| 48 | + if err != nil { |
| 49 | + return fmt.Errorf("database admin client: %w", err) |
| 50 | + } |
| 51 | + defer client.Close() |
| 52 | + |
| 53 | + op, err := client.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ |
| 54 | + Parent: fmt.Sprintf("projects/%s/instances/%s", project, instance), |
| 55 | + CreateStatement: "CREATE DATABASE `" + dbID + "`", |
| 56 | + }) |
| 57 | + if err != nil { |
| 58 | + return fmt.Errorf("create database: %w", err) |
| 59 | + } |
| 60 | + if _, err = op.Wait(ctx); err != nil { |
| 61 | + return fmt.Errorf("wait for database: %w", err) |
| 62 | + } |
| 63 | + return nil |
| 64 | +} |
| 65 | + |
| 66 | +func dropDatabase(ctx context.Context, project, instance, dbID string, opts ...option.ClientOption) error { |
| 67 | + client, err := database_admin.NewDatabaseAdminClient(ctx, opts...) |
| 68 | + if err != nil { |
| 69 | + return fmt.Errorf("database admin client: %w", err) |
| 70 | + } |
| 71 | + defer client.Close() |
| 72 | + |
| 73 | + err = client.DropDatabase(ctx, &databasepb.DropDatabaseRequest{ |
| 74 | + Database: fmt.Sprintf("projects/%s/instances/%s/databases/%s", project, instance, dbID), |
| 75 | + }) |
| 76 | + if err != nil { |
| 77 | + return fmt.Errorf("drop database: %w", err) |
| 78 | + } |
| 79 | + return nil |
| 80 | +} |
| 81 | + |
| 82 | +func parseInstancePath(path string) (project, instance string, err error) { |
| 83 | + parts := strings.Split(path, "/") |
| 84 | + if len(parts) != 4 || parts[0] != "projects" || parts[2] != "instances" || parts[1] == "" || parts[3] == "" { |
| 85 | + return "", "", fmt.Errorf("%s must be of the form projects/<project>/instances/<instance>, got %q", instanceEnv, path) |
| 86 | + } |
| 87 | + return parts[1], parts[3], nil |
| 88 | +} |
| 89 | + |
| 90 | +// uniqueDatabaseID builds a Spanner database ID (2–30 chars, [a-z0-9_-], no |
| 91 | +// trailing hyphen). Entropy comes first so a 30-char clamp never drops |
| 92 | +// uniqueness when GITHUB_RUN_ID is long; a short run-id suffix keeps orphans |
| 93 | +// traceable when present. |
| 94 | +func uniqueDatabaseID() string { |
| 95 | + id := "itest_" + randomToken() |
| 96 | + if runID := os.Getenv("GITHUB_RUN_ID"); runID != "" { |
| 97 | + budget := 30 - len(id) - 1 |
| 98 | + if budget > 0 { |
| 99 | + if len(runID) > budget { |
| 100 | + runID = runID[len(runID)-budget:] |
| 101 | + } |
| 102 | + id = id + "_" + runID |
| 103 | + } |
| 104 | + } |
| 105 | + return strings.TrimRight(id, "-") |
| 106 | +} |
| 107 | + |
| 108 | +func randomToken() string { |
| 109 | + b := make([]byte, 4) |
| 110 | + if _, err := rand.Read(b); err != nil { |
| 111 | + panic("crypto/rand unavailable: " + err.Error()) |
| 112 | + } |
| 113 | + return hex.EncodeToString(b) |
| 114 | +} |
0 commit comments