package unity_catalog import ( "context" "fmt" "net/http" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/seaweedfs/seaweedfs/test/testutil" ) const ( ucImageDefault = "unitycatalog/unitycatalog:v0.4.0" ucContainerCfg = "/home/unitycatalog/etc/conf/server.properties" ucStartupGrace = 90 * time.Second ucAPIBase = "/api/2.1/unity-catalog" ucWarehouse = "lakehouse" ucWarehouseKey = "warehouse" // Role used by the master-role STS-vended variant of the test. The trust // policy is wide open so any caller can assume it; in production UC // would set this ARN as `aws.masterRoleArn`. ucVendedRoleArn = "arn:aws:iam::000000000000:role/UnityCatalogVendedRole" ucVendedRoleName = "UnityCatalogVendedRole" ) type testEnv struct { seaweedDir string weedBinary string dataDir string bindIP string s3Port int s3GrpcPort int masterPort int masterGrpcPort int filerPort int filerGrpcPort int volumePort int volumeGrpcPort int weedProcess *exec.Cmd weedCancel context.CancelFunc accessKey string secretKey string ucImage string ucContainerID string ucHostPort int } // ucServerOpts customizes the Unity Catalog server.properties and runtime // environment. Defaults match the upstream playground (static keys, no master // role). type ucServerOpts struct { // MasterRoleArn populates aws.masterRoleArn. Empty means UC falls back to // static aws.accessKey / aws.secretKey for storage operations. MasterRoleArn string // ExtraEnv adds environment variables to the UC container, useful for // AWS_ENDPOINT_URL_STS-style overrides. ExtraEnv map[string]string } func newTestEnv(t *testing.T) *testEnv { t.Helper() wd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } seaweedDir := wd for i := 0; i < 8; i++ { if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil { break } seaweedDir = filepath.Dir(seaweedDir) } weedBinary := filepath.Join(seaweedDir, "weed", "weed") if _, err := os.Stat(weedBinary); err != nil { weedBinary = "weed" if _, err := exec.LookPath(weedBinary); err != nil { t.Skip("weed binary not found, skipping integration test") } } dataDir, err := os.MkdirTemp("", "seaweed-uc-test-*") if err != nil { t.Fatalf("mkdtemp: %v", err) } bindIP := testutil.FindBindIP() ports := testutil.MustAllocatePorts(t, 9) masterPort, masterGrpcPort := ports[0], ports[1] volumePort, volumeGrpcPort := ports[2], ports[3] filerPort, filerGrpcPort := ports[4], ports[5] s3Port, s3GrpcPort := ports[6], ports[7] ucHostPort := ports[8] image := os.Getenv("UC_IMAGE") if image == "" { image = ucImageDefault } return &testEnv{ seaweedDir: seaweedDir, weedBinary: weedBinary, dataDir: dataDir, bindIP: bindIP, s3Port: s3Port, s3GrpcPort: s3GrpcPort, masterPort: masterPort, masterGrpcPort: masterGrpcPort, filerPort: filerPort, filerGrpcPort: filerGrpcPort, volumePort: volumePort, volumeGrpcPort: volumeGrpcPort, accessKey: "admin", secretKey: "admin", ucImage: image, ucHostPort: ucHostPort, } } // startSeaweedFS starts a `weed mini` instance. If iamJSON is empty, a minimal // admin-only IAM config is used. When iamJSON is non-empty, it is passed to // both -s3.config and -s3.iam.config so the STS handler is enabled (mirroring // the lakekeeper test). func (env *testEnv) startSeaweedFS(t *testing.T, iamJSON string) { t.Helper() enableSTS := iamJSON != "" if iamJSON == "" { iamJSON = fmt.Sprintf(`{ "identities": [ { "name": "admin", "credentials": [{"accessKey": %q, "secretKey": %q}], "actions": ["Admin", "Read", "List", "Tagging", "Write"] } ] }`, env.accessKey, env.secretKey) } iamConfigPath := filepath.Join(env.dataDir, "iam.json") if err := os.WriteFile(iamConfigPath, []byte(iamJSON), 0644); err != nil { t.Fatalf("write iam config: %v", err) } ctx, cancel := context.WithCancel(context.Background()) env.weedCancel = cancel weedArgs := []string{"-v", "4", "mini", "-master.port", fmt.Sprintf("%d", env.masterPort), "-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort), "-volume.port", fmt.Sprintf("%d", env.volumePort), "-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort), "-filer.port", fmt.Sprintf("%d", env.filerPort), "-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort), "-s3.port", fmt.Sprintf("%d", env.s3Port), "-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort), "-s3.config", iamConfigPath, "-ip", env.bindIP, "-ip.bind", "0.0.0.0", "-dir", env.dataDir, } if enableSTS { weedArgs = append(weedArgs, "-s3.iam.config", iamConfigPath, "-s3.iam.readOnly=false") } cmd := exec.CommandContext(ctx, env.weedBinary, weedArgs...) cmd.Dir = env.dataDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("start weed mini: %v", err) } env.weedProcess = cmd if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/status", env.s3Port), testutil.SeaweedMiniStartupTimeout) { t.Fatalf("S3 API at 127.0.0.1:%d did not become ready", env.s3Port) } } // startUnityCatalog launches the Unity Catalog OSS server in Docker against // the running SeaweedFS instance. It mirrors the upstream playground's // server.properties layout and bind-mounts only that single file (matching // docker-compose.yaml from the playground). func (env *testEnv) startUnityCatalog(t *testing.T, ctx context.Context, opts ucServerOpts) { t.Helper() s3EndpointForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port) props := strings.Join([]string{ "server.env=test", "server.authorization=disable", "server.cookie-timeout=PT1H", "server.managed-table.enabled=false", fmt.Sprintf("aws.masterRoleArn=%s", opts.MasterRoleArn), fmt.Sprintf("aws.accessKey=%s", env.accessKey), fmt.Sprintf("aws.secretKey=%s", env.secretKey), "aws.region=us-east-1", fmt.Sprintf("aws.endpoint=%s", s3EndpointForContainer), // UC keys perBucketS3Configs by NormalizedURL.from(bucketPath) and // looks up using the storageBase, which is "s3://" (scheme + // authority only). The playground's "s3://lakehouse/warehouse" never // matches because of that asymmetry; the bucket-only form works. fmt.Sprintf("s3.bucketPath.0=s3://%s", ucWarehouse), "s3.region.0=us-east-1", fmt.Sprintf("s3.awsRoleArn.0=%s", opts.MasterRoleArn), fmt.Sprintf("s3.accessKey.0=%s", env.accessKey), fmt.Sprintf("s3.secretKey.0=%s", env.secretKey), fmt.Sprintf("s3.endpoint.0=%s", s3EndpointForContainer), "", }, "\n") confDir := filepath.Join(env.dataDir, "uc-conf") if err := os.MkdirAll(confDir, 0755); err != nil { t.Fatalf("mkdir uc-conf: %v", err) } propsPath := filepath.Join(confDir, "server.properties") if err := os.WriteFile(propsPath, []byte(props), 0644); err != nil { t.Fatalf("write server.properties: %v", err) } containerName := fmt.Sprintf("seaweed-unity-catalog-%d", time.Now().UnixNano()) args := []string{ "run", "-d", "--rm", "--name", containerName, "--add-host", "host.docker.internal:host-gateway", "-p", fmt.Sprintf("%d:8080", env.ucHostPort), "-v", fmt.Sprintf("%s:%s:ro", propsPath, ucContainerCfg), "-e", "JAVA_OPTS=-Xmx1g", } for k, v := range opts.ExtraEnv { args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) } args = append(args, env.ucImage) out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() if err != nil { t.Fatalf("docker run unity-catalog: %v\n%s", err, out) } // docker run -d prefixes pull progress / warnings before the final container // ID line when the image isn't cached locally. Trim to the last whitespace- // separated token so the ID survives a fresh CI runner. fields := strings.Fields(string(out)) if len(fields) == 0 { t.Fatalf("docker run produced no output; combined: %q", string(out)) } env.ucContainerID = fields[len(fields)-1] t.Logf("unity-catalog container id: %s", env.ucContainerID) probe := fmt.Sprintf("http://127.0.0.1:%d%s/catalogs", env.ucHostPort, ucAPIBase) deadline := time.Now().Add(ucStartupGrace) var lastErr error for time.Now().Before(deadline) { req, _ := http.NewRequestWithContext(ctx, http.MethodGet, probe, nil) resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req) if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { return } lastErr = fmt.Errorf("status %d", resp.StatusCode) } else { lastErr = err } select { case <-ctx.Done(): t.Fatalf("ctx done while waiting for unity catalog: %v", ctx.Err()) case <-time.After(time.Second): } } logs, _ := exec.Command("docker", "logs", "--tail", "200", env.ucContainerID).CombinedOutput() t.Fatalf("unity catalog did not become ready: %v\nrecent logs:\n%s", lastErr, logs) } func (env *testEnv) cleanup(t *testing.T) { if env.ucContainerID != "" { if logs, err := exec.Command("docker", "logs", "--tail", "200", env.ucContainerID).CombinedOutput(); err == nil { t.Logf("unity-catalog tail logs:\n%s", logs) } _ = exec.Command("docker", "rm", "-f", env.ucContainerID).Run() } if env.weedCancel != nil { env.weedCancel() } if env.weedProcess != nil { _ = env.weedProcess.Wait() } if env.dataDir != "" { _ = os.RemoveAll(env.dataDir) } } // -- S3 client helpers --------------------------------------------------------- func (env *testEnv) newHostS3Client(t *testing.T, ctx context.Context) *s3.Client { t.Helper() return env.newHostS3ClientWithCreds(t, ctx, env.accessKey, env.secretKey, "") } func (env *testEnv) newHostS3ClientWithCreds(t *testing.T, ctx context.Context, ak, sk, token string) *s3.Client { t.Helper() endpoint := fmt.Sprintf("http://127.0.0.1:%d", env.s3Port) cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(ak, sk, token)), ) if err != nil { t.Fatalf("load aws config: %v", err) } return s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(endpoint) o.UsePathStyle = true }) } // stsEnabledIAMConfig returns an iam.json that defines an admin user, the // UnityCatalogVendedRole role with a permissive trust policy, and a FullAccess // policy attached to it. This is the SeaweedFS-side counterpart of the // `aws.masterRoleArn` configuration on the Unity Catalog server. func stsEnabledIAMConfig(accessKey, secretKey string) string { return fmt.Sprintf(`{ "identities": [ { "name": "admin", "credentials": [{"accessKey": %q, "secretKey": %q}], "actions": ["Admin", "Read", "List", "Tagging", "Write"] } ], "sts": { "tokenDuration": "12h", "maxSessionLength": "24h", "issuer": "seaweedfs-sts", "signingKey": "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" }, "roles": [ { "roleName": %q, "roleArn": %q, "trustPolicy": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole" } ] }, "attachedPolicies": ["FullAccess"] } ], "policies": [ { "name": "FullAccess", "document": { "Version": "2012-10-17", "Statement": [ {"Effect": "Allow", "Action": "*", "Resource": "*"} ] } } ] }`, accessKey, secretKey, ucVendedRoleName, ucVendedRoleArn) }