diff --git a/service/submitqueue/BUILD.bazel b/service/submitqueue/BUILD.bazel index ffe271e43..d9b97c2e6 100644 --- a/service/submitqueue/BUILD.bazel +++ b/service/submitqueue/BUILD.bazel @@ -3,6 +3,7 @@ exports_files( "docker-compose.fake.yml", "docker-compose.git.yml", "docker-compose.provider.yml", + "docker-compose.tango.yml", "docker-compose.web.yml", "docker-compose.yml", ], diff --git a/service/tangofake/server/BUILD.bazel b/service/tangofake/server/BUILD.bazel index eb537c720..f1c267687 100644 --- a/service/tangofake/server/BUILD.bazel +++ b/service/tangofake/server/BUILD.bazel @@ -1,4 +1,4 @@ -load("@rules_go//go:def.bzl", "go_binary", "go_library") +load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library") go_library( name = "go_default_library", @@ -19,3 +19,24 @@ go_binary( embed = [":go_default_library"], visibility = ["//visibility:public"], ) + +# The linux build the e2e suite packs into the fake's image, so no pre-built +# .docker-bin artifact is required. +go_cross_binary( + name = "tangofake_linux", + platform = "@rules_go//go/toolchain:linux_amd64", + target = ":server", + visibility = ["//test:__subpackages__"], +) + +# Everything a Docker-based test needs to build the fake's image from a staged +# build context (see testutil.WithBuildContext). +filegroup( + name = "docker_test_context", + testonly = True, + srcs = [ + "Dockerfile", + ":tangofake_linux", + ], + visibility = ["//test:__subpackages__"], +) diff --git a/test/e2e/submitqueue/BUILD.bazel b/test/e2e/submitqueue/BUILD.bazel index 92c79e93c..1db706793 100644 --- a/test/e2e/submitqueue/BUILD.bazel +++ b/test/e2e/submitqueue/BUILD.bazel @@ -9,6 +9,7 @@ go_test( "harness_test.go", "queuepause_test.go", "suite_test.go", + "tango_suite_test.go", ], data = [ "//platform/extension/counter/mysql/schema", @@ -16,11 +17,14 @@ go_test( "//service/runway/server:docker_test_context", "//service/submitqueue:docker-compose.git.yml", "//service/submitqueue:docker-compose.provider.yml", + "//service/submitqueue:docker-compose.tango.yml", "//service/submitqueue:docker-compose.yml", "//service/submitqueue/demo/provider/git:config", "//service/submitqueue/demo/requests", "//service/submitqueue/gateway/server:docker_test_context", "//service/submitqueue/orchestrator/server:docker_test_context", + "//service/tangofake/server:docker_test_context", + "//submitqueue/extension/conflict/tango/mysql/schema", "//submitqueue/extension/storage:schema", "@git", "@git//:git_receive_pack", @@ -40,9 +44,10 @@ go_test( "SQ_GITHUB_TEST_REQUIRED", "SQ_GITHUB_TOKEN", ], - # One shard per top-level suite. Each starts its own Compose stack, so they - # run in parallel instead of adding up to the 300s default timeout. - shard_count = 3, + # One shard per top-level suite, so keep this equal to their number. Each + # starts its own Compose stack, so they run in parallel instead of adding up + # to the 300s default timeout. + shard_count = 4, tags = [ "e2e", "integration", @@ -60,6 +65,7 @@ go_test( "//platform/extension/consumergate:go_default_library", "//platform/extension/consumergate/file:go_default_library", "//platform/extension/messagequeue/mysql:go_default_library", + "//platform/git/exec:go_default_library", "//platform/git/exectest:go_default_library", "//platform/publish:go_default_library", "//submitqueue/core/messagequeue:go_default_library", diff --git a/test/e2e/submitqueue/tango_suite_test.go b/test/e2e/submitqueue/tango_suite_test.go new file mode 100644 index 000000000..37624d072 --- /dev/null +++ b/test/e2e/submitqueue/tango_suite_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This suite runs the local demo's ANALYZER=tango stack: the git provider with +// the Tango conflict analyzer answered by the fake Tango server, which reads +// TARGET files from the orchestrator's copy of the repository. It asserts that +// batches whose paths never overlap still depend on each other when their +// build targets do, and that batches with no shared target stay independent. +package e2e_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + changepb "github.com/uber/submitqueue/api/base/change/protopb" + mergestrategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb" + runwaymq "github.com/uber/submitqueue/api/runway/messagequeue" + gatewaypb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb" + entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" + "github.com/uber/submitqueue/platform/extension/consumergate" + consumergatefile "github.com/uber/submitqueue/platform/extension/consumergate/file" + gitexec "github.com/uber/submitqueue/platform/git/exec" + gitexectest "github.com/uber/submitqueue/platform/git/exectest" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/test/testutil" + "google.golang.org/grpc" +) + +// tangoQueue is the queue profiles.tango.yaml gives the Tango analyzer. +const tangoQueue = "demo-queue" + +// tangoTargets is the target graph the suite seeds: demo/00 and demo/01 are +// libraries, and every other folder depends on one of them. It is the same +// shape tool/gitsandbox commits for the demo. +var tangoTargets = map[string]string{ + "demo/00/TARGET": "", + "demo/01/TARGET": "", + "demo/03/TARGET": "//demo/01\n", + "demo/04/TARGET": "//demo/00\n", +} + +type TangoSuite struct { + suite.Suite + ctx context.Context + log *testutil.TestLogger + stack *testutil.ComposeStack + gatewayClient gatewaypb.SubmitQueueGatewayClient + db *sql.DB + gate *consumergatefile.Store + + git string + bare string + work string +} + +func TestTangoE2E(t *testing.T) { + suite.Run(t, new(TangoSuite)) +} + +func (s *TangoSuite) SetupSuite() { + t := s.T() + s.ctx = context.Background() + s.log = testutil.NewTestLogger(t) + + s.git = gitexectest.Git(t) + t.Setenv("SQ_CONTAINER_USER", dockerContainerUser(t)) + + gateDir := t.TempDir() + t.Setenv("SQ_CONSUMER_GATE_DIR", gateDir) + s.gate = consumergatefile.New(gateDir) + + sandbox := t.TempDir() + s.bare = filepath.Join(sandbox, "sandbox.git") + s.work = filepath.Join(t.TempDir(), "work") + t.Setenv("SQ_GIT_SANDBOX_DIR", sandbox) + t.Setenv("SQ_RUNWAY_CHECKOUT_DIR", t.TempDir()) + s.seedRepositoryWithTargets() + + staged := t.TempDir() + for _, name := range []string{"merge.yaml", "profiles.tango.yaml"} { + contents, err := os.ReadFile(testutil.Runfile("service/submitqueue/demo/provider/git/" + name)) + require.NoError(t, err, "reading example config %s", name) + require.NoError(t, os.WriteFile(filepath.Join(staged, name), contents, 0o644)) + } + t.Setenv("SQ_PROVIDER_CONFIG_DIR", staged) + + composeFile := testutil.Runfile("service/submitqueue/docker-compose.yml") + s.stack = testutil.NewComposeStack(t, s.log, s.ctx, composeFile, "e2e-submitqueue-tango", + testutil.WithOverlay( + testutil.Runfile("service/submitqueue/docker-compose.git.yml"), + testutil.Runfile("service/submitqueue/docker-compose.tango.yml"), + ), + testutil.WithBuildContext(map[string]string{ + ".docker-bin/gateway": "service/submitqueue/gateway/server/gateway_linux", + ".docker-bin/orchestrator": "service/submitqueue/orchestrator/server/orchestrator_linux", + ".docker-bin/runway": "service/runway/server/runway_linux", + ".docker-bin/tangofake": "service/tangofake/server/tangofake_linux", + "service/submitqueue/gateway/server/Dockerfile": "service/submitqueue/gateway/server/Dockerfile", + "service/submitqueue/gateway/server/queues.yaml": "service/submitqueue/gateway/server/queues.yaml", + "service/submitqueue/orchestrator/server/Dockerfile": "service/submitqueue/orchestrator/server/Dockerfile", + "service/runway/server/Dockerfile": "service/runway/server/Dockerfile", + "service/tangofake/server/Dockerfile": "service/tangofake/server/Dockerfile", + })) + require.NoError(t, s.stack.Up(), "failed to start compose stack") + + var err error + s.db, err = s.stack.ConnectMySQLService("mysql-app") + require.NoError(t, err) + queueDB, err := s.stack.ConnectMySQLService("mysql-queue") + require.NoError(t, err) + t.Cleanup(func() { _ = queueDB.Close() }) + + testutil.ApplySubmitQueueStorageSchema(t, s.log, s.db) + testutil.ApplySchema(t, s.log, s.db, testutil.SchemaDir("platform/extension/counter/mysql/schema")) + testutil.ApplySchema(t, s.log, s.db, testutil.SchemaDir("submitqueue/extension/conflict/tango/mysql/schema")) + testutil.ApplySchema(t, s.log, queueDB, testutil.SchemaDir("platform/extension/messagequeue/mysql/schema")) + + var conn *grpc.ClientConn + conn, err = s.stack.ConnectGRPC("gateway-service", 8080) + require.NoError(t, err) + s.gatewayClient = gatewaypb.NewSubmitQueueGatewayClient(conn) +} + +func (s *TangoSuite) TearDownSuite() { + if s.db != nil { + s.db.Close() + } +} + +func (s *TangoSuite) TestBatchesDependOnSharedTargets() { + // Hold the lead land so the batches below find it in flight; the gate must + // be closed before the lead is submitted. + key := consumergate.Key{ + ConsumerGroup: "runway-merge", + Partition: entityqueue.PartitionIdentity{Tenant: tangoQueue, PartitionKey: tangoQueue}, + } + s.Require().NoError(s.gate.Close(s.ctx, key, consumergate.Metadata{ + Reason: "e2e: keep the lead batch in flight while the others are analyzed", + CreatedBy: "e2e-suite", + CreatedAtMs: time.Now().UnixMilli(), + })) + defer func() { _ = s.gate.Open(s.ctx, key) }() + + // A change to demo/04 affects //demo/04 alone. + lead := s.landChange("feature/tango-lead", "demo/04/lead.txt") + leadBatch := s.awaitBatchID(lead) + s.awaitParkedLand(leadBatch) + + // A change to the library demo/00 affects //demo/00 and, one step away, + // //demo/04, so it conflicts with the lead although no path overlaps. + library := s.landChange("feature/tango-library", "demo/00/lib.txt") + libraryBatch := s.awaitBatchID(library) + s.Equal([]string{leadBatch}, s.dependenciesOnceAnalyzed(libraryBatch), + "a change to a library must depend on the in-flight change to a target built on it") + + // A change to demo/03 affects //demo/03 alone, which neither batch reaches. + unrelated := s.landChange("feature/tango-unrelated", "demo/03/other.txt") + unrelatedBatch := s.awaitBatchID(unrelated) + s.Empty(s.dependenciesOnceAnalyzed(unrelatedBatch), + "a change sharing no target with an in-flight batch must stay independent") + + s.Require().NoError(s.gate.Open(s.ctx, key)) + for _, sqid := range []string{lead, library, unrelated} { + s.requireLanded(sqid) + } +} + +// landChange pushes a branch adding one file and submits it to the queue, +// returning the request ID. +func (s *TangoSuite) landChange(branch, file string) string { + s.runGit(s.work, "fetch", "origin") + s.runGit(s.work, "checkout", "-B", branch, "origin/main") + s.Require().NoError(os.MkdirAll(filepath.Join(s.work, filepath.Dir(file)), 0o755)) + s.Require().NoError(os.WriteFile(filepath.Join(s.work, file), []byte(branch+"\n"), 0o644)) + s.runGit(s.work, "add", ".") + s.runGit(s.work, "commit", "-m", "add "+file) + s.runGit(s.work, "push", "-f", "origin", branch) + head := s.runGit(s.work, "rev-parse", "HEAD") + + resp, err := s.gatewayClient.Land(s.ctx, &gatewaypb.LandRequest{ + Queue: tangoQueue, + Change: &changepb.Change{Uris: []string{ + fmt.Sprintf("git://%s/sandbox/%s/%s", sandboxRemote, url.PathEscape("refs/heads/"+branch), head), + }}, + Strategy: mergestrategypb.Strategy_SQUASH_REBASE, + }) + s.Require().NoError(err, "Land failed for %s", branch) + s.Require().NotEmpty(resp.Sqid) + return resp.Sqid +} + +// awaitBatchID waits for the request to be carried by a batch. +func (s *TangoSuite) awaitBatchID(sqid string) string { + var batchID string + pollUntil(persistPollInterval, func() bool { + err := s.db.QueryRowContext(s.ctx, + "SELECT id FROM batch WHERE queue = ? AND JSON_CONTAINS(contains, JSON_QUOTE(?)) LIMIT 1", + tangoQueue, sqid).Scan(&batchID) + return err == nil + }) + return batchID +} + +// dependenciesOnceAnalyzed returns the batch's dependency set after the batch +// has left creating, which is when the set and the new state are written +// together. +func (s *TangoSuite) dependenciesOnceAnalyzed(batchID string) []string { + var raw string + pollUntil(persistPollInterval, func() bool { + var state string + if err := s.db.QueryRowContext(s.ctx, + "SELECT state, dependencies FROM batch WHERE queue = ? AND id = ?", tangoQueue, batchID, + ).Scan(&state, &raw); err != nil { + return false + } + return entity.BatchState(state) != entity.BatchStateCreating + }) + var dependencies []string + s.Require().NoError(json.Unmarshal([]byte(raw), &dependencies)) + return dependencies +} + +// awaitParkedLand waits until the gate has caught the batch's land delivery, +// which proves the batch finished analysis and is in flight. +func (s *TangoSuite) awaitParkedLand(batchID string) { + pollUntil(persistPollInterval, func() bool { + records, err := s.gate.ListParked(s.ctx, "runway-merge") + s.Require().NoError(err) + for _, r := range records { + if r.Topic == runwaymq.TopicKeyMerge.String() && r.MessageID == batchID { + return true + } + } + return false + }) +} + +func (s *TangoSuite) requireLanded(sqid string) { + var got entity.RequestStatus + pollUntil(persistPollInterval, func() bool { + resp, err := s.gatewayClient.GetRequestSummaryByID(s.ctx, &gatewaypb.GetRequestSummaryByIDRequest{Sqid: sqid, Queue: tangoQueue}) + if err != nil || resp.Request == nil { + return false + } + got = entity.RequestStatus(resp.Request.Status) + return isTerminalStatus(got) + }) + s.Equal(entity.RequestStatusLanded, got, "request %s reached the wrong terminal status", sqid) +} + +// seedRepositoryWithTargets creates the bare repository Runway merges into and +// a working clone, with the TARGET files committed on main. +func (s *TangoSuite) seedRepositoryWithTargets() { + s.runGit(filepath.Dir(s.bare), "init", "--bare", "-b", "main", s.bare) + s.runGit(filepath.Dir(s.work), "clone", s.bare, s.work) + for _, kv := range [][2]string{ + {"user.name", "E2E Author"}, + {"user.email", "author@example.com"}, + {"commit.gpgsign", "false"}, + } { + s.runGit(s.work, "config", kv[0], kv[1]) + } + for path, contents := range tangoTargets { + s.Require().NoError(os.MkdirAll(filepath.Join(s.work, filepath.Dir(path)), 0o755)) + s.Require().NoError(os.WriteFile(filepath.Join(s.work, path), []byte(contents), 0o644)) + } + s.runGit(s.work, "add", ".") + s.runGit(s.work, "commit", "-m", "seed targets") + s.runGit(s.work, "push", "origin", "main") +} + +func (s *TangoSuite) runGit(dir string, args ...string) string { + s.T().Helper() + out, err := gitexec.Output(s.ctx, s.git, dir, args...) + require.NoError(s.T(), err, "git %s in %s", strings.Join(args, " "), dir) + return strings.TrimSpace(out) +}