diff --git a/agent/internal/agent/handlers.go b/agent/internal/agent/handlers.go index 1ffbf8a8..2c5dd279 100644 --- a/agent/internal/agent/handlers.go +++ b/agent/internal/agent/handlers.go @@ -199,6 +199,7 @@ func (a *Agent) ProcessBuild(item agenthttp.WorkQueueItem) error { CloneURL: buildDetails.CloneURL, CommitSha: buildDetails.Build.CommitSha, Branch: buildDetails.Build.Branch, + GitRef: buildDetails.Build.GitRef, ImageRepository: buildDetails.ImageRepository, ImageURI: buildDetails.ImageURI, ServiceID: buildDetails.Build.ServiceID, diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index 42d34970..acf91613 100644 --- a/agent/internal/build/build.go +++ b/agent/internal/build/build.go @@ -27,6 +27,7 @@ type Config struct { CloneURL string CommitSha string Branch string + GitRef string ImageRepository string ImageURI string ResolvedCommitSha string @@ -63,6 +64,8 @@ type dockerfileConfig struct { var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`) var windowsAbsoluteRootPattern = regexp.MustCompile(`^[A-Za-z]:[\\/]`) var imageDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +var credentialURLPattern = regexp.MustCompile(`(?i)https?://[^\s/@]+(?::[^\s/@]*)?@`) +var pullRequestMergeRefPattern = regexp.MustCompile(`^refs/pull/[1-9][0-9]*/merge$`) func NewBuilder(dataDir string, logSender LogSender) *Builder { return &Builder{ @@ -153,6 +156,9 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er safeURL = "https://***@" + safeURL[idx+1:] } b.sendLog(config, fmt.Sprintf("Cloning %s", safeURL)) + if pullRequestMergeRefPattern.MatchString(config.GitRef) { + return b.clonePullRequestRef(ctx, config, buildDir) + } branch := config.Branch if branch == "" { @@ -209,6 +215,52 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er return nil } +func (b *Builder) clonePullRequestRef(ctx context.Context, config *Config, buildDir string) error { + if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched { + return fmt.Errorf("invalid exact commit SHA") + } + cmd := exec.CommandContext(ctx, "git", "init", buildDir) + output, err := b.runCommand(cmd, config) + if err != nil { + return fmt.Errorf("git init failed: %s: %w", output, err) + } + cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "remote", "add", "origin", config.CloneURL) + output, err = b.runCommand(cmd, config) + if err != nil { + return fmt.Errorf("git remote setup failed: %s: %w", output, err) + } + cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", "1", "--no-tags", "origin", config.GitRef) + output, err = b.runCommand(cmd, config) + if err != nil { + return fmt.Errorf("git fetch pull request ref failed: %s: %w", output, err) + } + cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD") + fetchedCommit, err := b.runCommand(cmd, config) + if err != nil { + return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err) + } + if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) { + return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha) + } + b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8))) + cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "--detach", config.CommitSha) + output, err = b.runCommand(cmd, config) + if err != nil { + return fmt.Errorf("git checkout failed: %s: %w", output, err) + } + b.sendLog(config, "Clone completed") + resolvedCommitSha, err := b.resolveCommitSha(ctx, config, buildDir) + if err != nil { + return err + } + if !strings.EqualFold(resolvedCommitSha, config.CommitSha) { + return fmt.Errorf("checked out commit %s, expected %s", resolvedCommitSha, config.CommitSha) + } + config.ResolvedCommitSha = resolvedCommitSha + b.sendLog(config, fmt.Sprintf("Resolved commit %s", truncateStr(resolvedCommitSha, 8))) + return nil +} + func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir string) (string, error) { cmd := exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "HEAD") output, err := b.runCommand(cmd, config) @@ -462,7 +514,7 @@ func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfile func (b *Builder) runCommand(cmd *exec.Cmd, config *Config) (string, error) { output, err := cmd.CombinedOutput() - outputStr := string(output) + outputStr := credentialURLPattern.ReplaceAllString(string(output), "https://***@") if len(outputStr) > 0 { lines := strings.Split(strings.TrimSpace(outputStr), "\n") diff --git a/agent/internal/build/build_test.go b/agent/internal/build/build_test.go index 8f5b9bb6..d089e9b2 100644 --- a/agent/internal/build/build_test.go +++ b/agent/internal/build/build_test.go @@ -81,6 +81,39 @@ func TestCleanupStaleBuildDirsRemovesOnlyOldDirectories(t *testing.T) { assertExists(t, filePath) } +func TestCloneFetchesExactPullRequestMergeRef(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "work") + remoteDir := filepath.Join(t.TempDir(), "remote.git") + runGit(t, "init", "--initial-branch", "main", workDir) + runGit(t, "-C", workDir, "config", "user.name", "Test User") + runGit(t, "-C", workDir, "config", "user.email", "test@example.com") + if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("preview"), 0600); err != nil { + t.Fatal(err) + } + runGit(t, "-C", workDir, "add", "app.txt") + runGit(t, "-C", workDir, "commit", "-m", "preview merge") + selectedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD") + runGit(t, "clone", "--bare", workDir, remoteDir) + runGit(t, "--git-dir", remoteDir, "update-ref", "refs/pull/42/merge", selectedSHA) + + buildDir := filepath.Join(t.TempDir(), "build") + config := &Config{ + BuildID: "build-1", + CloneURL: "file://" + remoteDir, + CommitSha: selectedSHA, + Branch: "main", + GitRef: "refs/pull/42/merge", + } + builder := NewBuilder(t.TempDir(), nil) + + if err := builder.clone(context.Background(), config, buildDir); err != nil { + t.Fatal(err) + } + if config.ResolvedCommitSha != selectedSHA { + t.Fatalf("resolved commit = %s, want %s", config.ResolvedCommitSha, selectedSHA) + } +} + func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) { workDir := filepath.Join(t.TempDir(), "work") remoteDir := filepath.Join(t.TempDir(), "remote.git") @@ -109,9 +142,7 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) { CommitSha: selectedSHA, Branch: "main", } - builder := NewBuilder(t.TempDir(), nil) - - if err := builder.clone(context.Background(), config, buildDir); err != nil { + if err := NewBuilder(t.TempDir(), nil).clone(context.Background(), config, buildDir); err != nil { t.Fatal(err) } if config.ResolvedCommitSha != selectedSHA { @@ -119,6 +150,43 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) { } } +func TestCloneRejectsMovedRef(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "work") + remoteDir := filepath.Join(t.TempDir(), "remote.git") + runGit(t, "init", "--initial-branch", "main", workDir) + runGit(t, "-C", workDir, "config", "user.name", "Test User") + runGit(t, "-C", workDir, "config", "user.email", "test@example.com") + if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("first"), 0600); err != nil { + t.Fatal(err) + } + runGit(t, "-C", workDir, "add", "app.txt") + runGit(t, "-C", workDir, "commit", "-m", "first") + expectedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD") + if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("second"), 0600); err != nil { + t.Fatal(err) + } + runGit(t, "-C", workDir, "commit", "-am", "second") + movedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD") + runGit(t, "clone", "--bare", workDir, remoteDir) + runGit(t, "--git-dir", remoteDir, "update-ref", "refs/pull/42/merge", movedSHA) + + config := &Config{ + BuildID: "build-1", + CloneURL: "file://" + remoteDir, + CommitSha: expectedSHA, + Branch: "main", + GitRef: "refs/pull/42/merge", + } + err := NewBuilder(t.TempDir(), nil).clone( + context.Background(), + config, + filepath.Join(t.TempDir(), "build"), + ) + if err == nil || !strings.Contains(err.Error(), "fetched ref resolved to") { + t.Fatalf("clone error = %v, want moved ref failure", err) + } +} + func TestResolveBuildContext(t *testing.T) { buildDir := t.TempDir() nestedDir := filepath.Join(buildDir, "services", "api") diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go index ecf17577..e363f1cd 100644 --- a/agent/internal/http/client.go +++ b/agent/internal/http/client.go @@ -398,6 +398,7 @@ type BuildDetails struct { CommitSha string `json:"commitSha"` CommitMessage string `json:"commitMessage"` Branch string `json:"branch"` + GitRef string `json:"gitRef"` ServiceID string `json:"serviceId"` ProjectID string `json:"projectId"` } `json:"build"` diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 14333107..923e725f 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -135,6 +135,26 @@ For each rollout, the control plane freezes the required target set immediately With no configured health check, `healthy` means only that the container is running. Routing convergence prevents completion before network configuration is live, but application-level readiness remains the responsibility of a user-configured health check. +## Pull Request Preview Isolation + +An enabled GitHub service represents each eligible pull request as an ordinary, +visible stateless service in the project's `previews` environment. An existing +environment with that name is reused; otherwise the control plane creates it. +The environment remains after its last preview closes and may also contain +unrelated user-created services. + +Each copy has an independent service ID, revision, build, rollout, deployment +set, registry path, and generated route while reusing the normal runtime +pipeline. It is copied from the base service only when first created, so later +user edits to the visible preview service survive pull request updates. + +The control plane resolves the exact synthetic merge ref and queues an ordinary +build. The latest revision on the copy prevents superseded build and rollout +callbacks from deploying or reporting success. GitHub reports the transient +environment as ready only after health and routing convergence complete. +Closing, merging, or drafting the pull request deletes the copied service after +runtime and registry cleanup. + ## Networking ### IP Address Scheme diff --git a/docs/deployments/github.mdx b/docs/deployments/github.mdx index 9f7ba3cc..75c692aa 100644 --- a/docs/deployments/github.mdx +++ b/docs/deployments/github.mdx @@ -18,6 +18,14 @@ Techulus Cloud integrates with GitHub through a [GitHub App](https://docs.github 3. Install the GitHub App on your GitHub account or organization. +The GitHub App needs these repository permissions: + +- **Contents:** Read +- **Pull requests:** Read +- **Deployments:** Read and write + +Subscribe the app to both the **Push** and **Pull request** webhook events. + ## Connecting a Repository Once the GitHub App is installed, connect a repository to a service: @@ -40,6 +48,47 @@ The flow: GitHub deployment statuses are updated on the commit so you can track progress from pull requests. +## Pull Request Preview Deployments + +Preview deployments are opt in from a GitHub-backed service's **Configuration** +page. Each eligible pull request gets one visible service in the project's +ordinary `previews` environment. An existing environment named `previews` is +reused and is left in place when previews close. If you configure an +**Automatic Subdomain Domain** and its wildcard DNS record, each preview also +gets a stable generated HTTPS URL beneath that domain. Without this setting, +the preview is still created without a public URL. + +A pull request is eligible only when it: + +- comes from the same repository as the base branch (forks are skipped), +- targets the service's configured deployment branch, +- is open and ready for review (drafts are skipped), and +- belongs to a stateless service. + +The preview builds GitHub's synthetic merge result at +`refs/pull//merge`. This tests the change as it would merge into the +configured branch. If GitHub cannot produce that ref because of merge +conflicts, the preview service is removed rather than building the raw pull +request head. Reconciliation recreates it after GitHub can produce the merge +ref again. + +When first created, preview services inherit the base service's current source +configuration, replicas, autoscaling, placement, health check, start command, +resource limits, ports, serverless settings, and complete secret set. They do +not copy volumes, backups, deployment schedules, cron jobs, production custom +domains, or public TCP/UDP routes. Serverless mode is disabled when no generated +public domain is available. Preview services use the normal service pages and +may be edited like other services. Their source repository remains tied to the +pull request, and volumes remain unavailable. Later pull request updates +preserve other edits. + +New commits replace the preview revision without changing its URL. Converting +the pull request to a draft, closing it, or merging it removes the runtime and +route and marks the GitHub deployment inactive. A daily reconciliation job +rechecks previews in case a webhook was missed. Generated hosts use the normal +HTTP-01 certificate path, so installations with high pull-request volume should +monitor their certificate authority's issuance limits. + ## Build Process Agents build images using one of two methods: diff --git a/docs/installation.mdx b/docs/installation.mdx index bbcfb618..5959d27f 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -297,6 +297,11 @@ migrations from every replica. | `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64-encoded) | | `GITHUB_WEBHOOK_SECRET` | Webhook secret | +Configure the app with Contents read, Pull requests read, and Deployments write +repository permissions. Subscribe it to Push and Pull request events. Pull +request previews use the Automatic Subdomain Domain setting and its wildcard DNS +record when configured. Without it, previews are created without public URLs. + ## Generating Secrets ```bash diff --git a/web/actions/builds.ts b/web/actions/builds.ts index b52dd868..07c940b7 100644 --- a/web/actions/builds.ts +++ b/web/actions/builds.ts @@ -84,7 +84,7 @@ export async function retryBuild(buildId: string) { } const [service] = await db - .select({ id: services.id }) + .select({ id: services.id, previewOfService: services.previewOfService }) .from(services) .where(and(eq(services.id, build.serviceId), isNull(services.deletedAt))); @@ -96,16 +96,21 @@ export async function retryBuild(buildId: string) { throw new Error(`Cannot retry build in ${build.status} status`); } + const actor = { + type: "user" as const, + userId: session.user.id, + name: session.user.name, + }; + if (service.previewOfService) { + await triggerBuildInternal(build.serviceId, "manual", actor); + return { success: true }; + } await requeueBuildRevisionInternal({ serviceId: build.serviceId, serviceRevisionId: build.serviceRevisionId, commitMessage: build.commitMessage ?? "Retry build", author: build.author ?? undefined, - actor: { - type: "user", - userId: session.user.id, - name: session.user.name, - }, + actor, }); return { success: true }; @@ -144,6 +149,11 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) { if (result.service.sourceType !== "github") { throw new Error("Service is not connected to GitHub"); } + if (result.service.previewOfService) { + throw new Error( + "Preview services build their pull request merge ref; use Build to rebuild it", + ); + } const branch = result.githubRepo.deployBranch || result.githubRepo.defaultBranch || "main"; diff --git a/web/actions/previews.ts b/web/actions/previews.ts new file mode 100644 index 00000000..2436cd78 --- /dev/null +++ b/web/actions/previews.ts @@ -0,0 +1,78 @@ +"use server"; + +import { randomUUID } from "node:crypto"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { getService } from "@/db/queries"; +import { githubRepos, services } from "@/db/schema"; +import { requireDeveloperRole } from "@/lib/auth"; +import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; +import { ensurePreviewEnvironment } from "@/lib/preview-deployments"; + +export async function setPreviewDeploymentsEnabled( + serviceId: string, + enabled: boolean, +) { + await requireDeveloperRole(); + const service = await getService(serviceId); + if (!service) throw new Error("Service not found"); + if (service.previewOfService) { + throw new Error("Preview services cannot create nested previews"); + } + if (service.sourceType !== "github") { + throw new Error("Preview deployments require a GitHub App service"); + } + if (service.stateful) { + throw new Error( + "Preview deployments are available only for stateless services", + ); + } + if (enabled) { + await ensurePreviewEnvironment(service.projectId); + } + + await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`); + const current = await tx + .select({ + previewOfService: services.previewOfService, + sourceType: services.sourceType, + stateful: services.stateful, + }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!current) throw new Error("Service not found"); + if (current.previewOfService) { + throw new Error("Preview services cannot create nested previews"); + } + if (current.sourceType !== "github") { + throw new Error("Preview deployments require a GitHub App service"); + } + if (current.stateful) { + throw new Error( + "Preview deployments are available only for stateless services", + ); + } + const repo = await tx + .select({ id: githubRepos.id }) + .from(githubRepos) + .where(eq(githubRepos.serviceId, serviceId)) + .then((rows) => rows[0]); + if (!repo) { + throw new Error("Preview deployments require a GitHub App service"); + } + await tx + .update(services) + .set({ previewDeploymentsEnabled: enabled }) + .where(eq(services.id, serviceId)); + }); + await inngest.send( + inngestEvents.previewServiceReconcileRequested.create( + { baseServiceId: serviceId }, + { id: `preview-setting:${serviceId}:${enabled}:${randomUUID()}` }, + ), + ); + return { success: true }; +} diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 63319b52..7c9bb68f 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -54,6 +54,10 @@ import { cleanupRegistryArtifactsForService, prepareRegistryArtifactCleanup, } from "@/lib/registry-retention"; +import { + deletePreviewService, + deletePreviewsForBaseService, +} from "@/lib/preview-lifecycle"; import { containerPathSchema, githubRepoUrlSchema, @@ -143,7 +147,10 @@ export async function deleteProject( } } - for (const service of projectServices) { + for (const service of projectServices.sort( + (a, b) => + Number(Boolean(b.previewOfService)) - Number(Boolean(a.previewOfService)), + )) { await hardDeleteService(service.id); } await db.transaction(async (tx) => { @@ -253,11 +260,14 @@ export async function deleteEnvironment(environmentId: string) { } const envServices = await db - .select({ id: services.id }) + .select({ id: services.id, previewOfService: services.previewOfService }) .from(services) .where(eq(services.environmentId, environmentId)); - for (const service of envServices) { + for (const service of envServices.sort( + (a, b) => + Number(Boolean(b.previewOfService)) - Number(Boolean(a.previewOfService)), + )) { await hardDeleteService(service.id); } await db.transaction(async (tx) => { @@ -406,6 +416,23 @@ export async function createService(input: CreateServiceInput) { } async function hardDeleteService(serviceId: string) { + const preview = await db + .select({ + previewOfService: services.previewOfService, + previewGitRef: services.previewGitRef, + }) + .from(services) + .where(eq(services.id, serviceId)) + .then((rows) => rows[0]); + if (preview?.previewOfService && preview.previewGitRef) { + const deleted = await deletePreviewService( + preview.previewOfService, + preview.previewGitRef, + ); + if (!deleted) throw new Error("Preview service not found"); + return { success: true }; + } + const service = await db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`); const freshService = await tx @@ -451,6 +478,7 @@ async function hardDeleteService(serviceId: string) { ); } const claimedService = service.service; + await deletePreviewsForBaseService(serviceId, "base service deleted"); const allDeployments = await db .select() @@ -888,10 +916,37 @@ export async function updateServiceGithubRepo( updateData.image = `${registryHost}/${service.projectId}/${serviceId}:latest`; } + let reconcilePreviews = false; await db.transaction(async (tx) => { await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, ); + const current = await tx + .select({ + githubRepoUrl: services.githubRepoUrl, + previewDeploymentsEnabled: services.previewDeploymentsEnabled, + previewOfService: services.previewOfService, + }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!current) throw new Error("Service not found"); + if (current.previewOfService && normalizedUrl !== current.githubRepoUrl) { + throw new Error( + "A preview service must remain linked to its pull request", + ); + } + if ( + current.previewDeploymentsEnabled && + !current.previewOfService && + normalizedUrl !== current.githubRepoUrl + ) { + throw new Error( + "Disable preview deployments before changing the GitHub repository", + ); + } + reconcilePreviews = + current.previewDeploymentsEnabled && !current.previewOfService; await tx .update(services) .set(updateData) @@ -901,6 +956,14 @@ export async function updateServiceGithubRepo( .set({ deployBranch: normalizedBranch }) .where(eq(githubRepos.serviceId, serviceId)); }); + if (reconcilePreviews) { + await inngest.send( + inngestEvents.previewServiceReconcileRequested.create( + { baseServiceId: serviceId }, + { id: `preview-source:${serviceId}:${randomUUID()}` }, + ), + ); + } return { success: true }; } catch (error) { @@ -1715,6 +1778,12 @@ export async function addServiceVolume( .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) .then((rows) => rows[0]); if (!service) throw new Error("Service not found"); + if (service.previewOfService) { + throw new Error("Preview services cannot have volumes"); + } + if (service.previewDeploymentsEnabled) { + throw new Error("Disable preview deployments before adding a volume"); + } if (service.placementMode === "automatic") { throw new Error("Switch to manual placement before adding a volume"); } diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx index 98938adb..94324f88 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx @@ -10,6 +10,7 @@ export default function BuildsPage() { diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index a97fb142..f66bf2bc 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -96,7 +96,13 @@ export default function ConfigurationPage() { - + diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts index 3f82d6a0..977106bd 100644 --- a/web/app/api/inngest/route.ts +++ b/web/app/api/inngest/route.ts @@ -16,6 +16,9 @@ import { oldBackupsCleanup, onDeploymentFailed, onRestoreFailed, + previewReconciliation, + previewServiceReconcileWorkflow, + previewSyncWorkflow, registryArtifactRetention, restoreTriggerWorkflow, restoreWorkflow, @@ -52,6 +55,9 @@ export const { GET, POST, PUT } = serve({ backupWorkflow, restoreWorkflow, onRestoreFailed, + previewSyncWorkflow, + previewReconciliation, + previewServiceReconcileWorkflow, buildWorkflow, buildTriggerWorkflow, restoreTriggerWorkflow, diff --git a/web/app/api/v1/agent/builds/[id]/route.ts b/web/app/api/v1/agent/builds/[id]/route.ts index b0ddfc97..f54080fd 100644 --- a/web/app/api/v1/agent/builds/[id]/route.ts +++ b/web/app/api/v1/agent/builds/[id]/route.ts @@ -88,7 +88,11 @@ export async function POST( const [service, revision, buildTimeoutMinutes] = await Promise.all([ db - .select({ id: services.id, projectId: services.projectId }) + .select({ + id: services.id, + projectId: services.projectId, + previewGitRef: services.previewGitRef, + }) .from(services) .where(eq(services.id, build.serviceId)) .then((rows) => rows[0]), @@ -145,6 +149,7 @@ export async function POST( commitSha: specification.source.commitSha, commitMessage: build.commitMessage, branch: specification.source.branch, + gitRef: service.previewGitRef, serviceId: build.serviceId, projectId: service.projectId, }, diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts index 4fb68b4b..d6e48b21 100644 --- a/web/app/api/v1/agent/builds/[id]/status/route.ts +++ b/web/app/api/v1/agent/builds/[id]/status/route.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; import { @@ -14,6 +14,7 @@ import { updateGitHubDeploymentStatus } from "@/lib/github"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import { notify } from "@/lib/notifications"; +import { updatePreviewGitHubStatus } from "@/lib/preview-deployments"; import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { enqueueWork } from "@/lib/work-queue"; @@ -118,6 +119,7 @@ export async function POST( specification: serviceRevisions.specification, projectSlug: projects.slug, environmentName: environments.name, + previewOfService: services.previewOfService, }) .from(serviceRevisions) .innerJoin(services, eq(serviceRevisions.serviceId, services.id)) @@ -259,48 +261,67 @@ export async function POST( ) { try { const baseUrl = process.env.APP_URL || "https://cloud.techulus.com"; - const logUrl = `${baseUrl}/builds/${buildId}/logs`; - const environmentUrl = `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}`; - const repository = revisionRepositoryFullName( - specification.source.repository, - ); - const installationId = specification.source.authentication.installationId; - if (["cloning", "building", "pushing"].includes(update.status)) { - await updateGitHubDeploymentStatus( - installationId, - repository, - build.githubDeploymentId, - "in_progress", - { - description: `Build ${update.status}...`, - logUrl, - environmentUrl, - }, - ); - } else if (update.status === "completed") { - await updateGitHubDeploymentStatus( - installationId, - repository, - build.githubDeploymentId, - "success", - { - description: "Build completed successfully", - logUrl, - environmentUrl, - }, - ); + const logUrl = revision.previewOfService + ? `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}/builds/${buildId}` + : `${baseUrl}/builds/${buildId}/logs`; + if (revision.previewOfService) { + await updatePreviewGitHubStatus({ + serviceId: build.serviceId, + serviceRevisionId: build.serviceRevisionId, + expectedDeploymentId: build.githubDeploymentId, + state: update.status === "failed" ? "failure" : "in_progress", + description: + update.status === "completed" + ? "Preview image built; preparing deployment" + : update.status === "failed" + ? update.error || "Preview build failed" + : `Preview build ${update.status}...`, + logUrl, + }); } else { - await updateGitHubDeploymentStatus( - installationId, - repository, - build.githubDeploymentId, - "failure", - { - description: update.error || "Build failed", - logUrl, - environmentUrl, - }, + const environmentUrl = `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}`; + const repository = revisionRepositoryFullName( + specification.source.repository, ); + const installationId = + specification.source.authentication.installationId; + if (["cloning", "building", "pushing"].includes(update.status)) { + await updateGitHubDeploymentStatus( + installationId, + repository, + build.githubDeploymentId, + "in_progress", + { + description: `Build ${update.status}...`, + logUrl, + environmentUrl, + }, + ); + } else if (update.status === "completed") { + await updateGitHubDeploymentStatus( + installationId, + repository, + build.githubDeploymentId, + "success", + { + description: "Build completed successfully", + logUrl, + environmentUrl, + }, + ); + } else { + await updateGitHubDeploymentStatus( + installationId, + repository, + build.githubDeploymentId, + "failure", + { + description: update.error || "Build failed", + logUrl, + environmentUrl, + }, + ); + } } } catch (error) { console.error( @@ -371,14 +392,32 @@ export async function POST( sql`select pg_advisory_xact_lock(hashtext(${build.serviceId}))`, ); const activeService = await tx - .select({ id: services.id }) + .select({ + id: services.id, + previewOfService: services.previewOfService, + }) .from(services) .where( and(eq(services.id, build.serviceId), isNull(services.deletedAt)), ) .limit(1) .then((rows) => rows[0]); - if (!activeService) return; + if (!activeService) { + return; + } + if (activeService.previewOfService) { + const latestRevision = await tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, build.serviceId)) + .orderBy( + desc(serviceRevisions.createdAt), + desc(serviceRevisions.id), + ) + .limit(1) + .then((rows) => rows[0]); + if (latestRevision?.id !== build.serviceRevisionId) return; + } await enqueueWork( auth.serverId, "create_manifest", diff --git a/web/app/api/webhooks/github/route.ts b/web/app/api/webhooks/github/route.ts index ca380357..8e4efedb 100644 --- a/web/app/api/webhooks/github/route.ts +++ b/web/app/api/webhooks/github/route.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; @@ -14,6 +15,10 @@ import { updateGitHubDeploymentStatus, verifyWebhookSignature, } from "@/lib/github"; +import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; +import { deletePreviewsForGitHubInstallation } from "@/lib/preview-lifecycle"; +import { pullRequestMergeRef } from "@/lib/service-revision-spec"; import { triggerResolvedBuildInternal } from "@/lib/trigger-build"; type InstallationPayload = { @@ -52,6 +57,18 @@ type PushPayload = { sender: { id: number; login: string }; }; +type PullRequestPayload = { + action: string; + number: number; + pull_request: { + draft: boolean; + merged: boolean; + base: { ref: string; repo: { id: number; full_name: string } }; + head: { repo: { id: number; full_name: string } | null }; + }; + repository: { id: number; full_name: string }; +}; + type PushResult = { serviceId: string; status: "queued" | "skipped" | "failed"; @@ -84,6 +101,11 @@ async function handleInstallationEvent(payload: InstallationPayload) { } if (action === "deleted") { + await deletePreviewsForGitHubInstallation( + installation.id, + "GitHub installation deleted", + { removeRepositoryLinks: true }, + ); await db .delete(githubInstallations) .where(eq(githubInstallations.installationId, installation.id)); @@ -91,6 +113,13 @@ async function handleInstallationEvent(payload: InstallationPayload) { return NextResponse.json({ ok: true, message: "Installation deleted" }); } + if (action === "suspend") { + await deletePreviewsForGitHubInstallation( + installation.id, + "GitHub installation suspended", + ); + } + return NextResponse.json({ ok: true }); } @@ -255,6 +284,132 @@ async function handlePushEvent(payload: PushPayload) { ); } +const pullRequestSyncActions = new Set([ + "opened", + "reopened", + "synchronize", + "ready_for_review", + "edited", +]); +const pullRequestCloseActions = new Set(["closed", "converted_to_draft"]); + +async function handlePullRequestEvent( + payload: PullRequestPayload, + deliveryId: string, +) { + if ( + !pullRequestSyncActions.has(payload.action) && + !pullRequestCloseActions.has(payload.action) + ) { + return NextResponse.json({ ok: true, skipped: true }); + } + if ( + !Number.isSafeInteger(payload.number) || + payload.number <= 0 || + payload.repository.id !== payload.pull_request.base.repo.id + ) { + return NextResponse.json( + { error: "Invalid pull request payload" }, + { status: 400 }, + ); + } + + const linkedServices = await db + .select({ githubRepo: githubRepos, service: services }) + .from(githubRepos) + .innerJoin(services, eq(githubRepos.serviceId, services.id)) + .where(eq(githubRepos.repoId, payload.repository.id)); + const sameRepository = + payload.pull_request.head.repo?.id === payload.pull_request.base.repo.id; + const shouldSync = + pullRequestSyncActions.has(payload.action) && + !payload.pull_request.draft && + sameRepository; + const previewGitRef = pullRequestMergeRef(payload.number); + const events: Array< + | ReturnType + | ReturnType + > = []; + const syncedBaseServiceIds = new Set(); + const linkedBaseServices = linkedServices.filter( + ({ service }) => !service.previewOfService && !service.deletedAt, + ); + + if (shouldSync) { + for (const { githubRepo, service } of linkedServices) { + if ( + service.previewOfService || + service.deletedAt || + service.sourceType !== "github" || + service.stateful || + !service.previewDeploymentsEnabled || + (githubRepo.deployBranch ?? githubRepo.defaultBranch) !== + payload.pull_request.base.ref + ) { + continue; + } + syncedBaseServiceIds.add(service.id); + events.push( + inngestEvents.previewSyncRequested.create( + { + baseServiceId: service.id, + previewGitRef, + }, + { + id: `github-pr-sync:${deliveryId}:${service.id}:${payload.number}`, + }, + ), + ); + } + } + + for (const { service } of linkedBaseServices) { + if (syncedBaseServiceIds.has(service.id)) { + continue; + } + const reason = + payload.action === "closed" + ? payload.pull_request.merged + ? "pull_request_merged" + : "pull_request_closed" + : payload.action === "converted_to_draft" + ? "converted_to_draft" + : !sameRepository + ? "fork_pull_request" + : "pull_request_ineligible"; + events.push( + inngestEvents.previewCloseRequested.create( + { + baseServiceId: service.id, + previewGitRef, + reason, + verifyWithGitHub: true, + }, + { + id: `github-pr-close:${deliveryId}:${service.id}:${payload.number}`, + }, + ), + ); + } + + if (events.length > 0) { + try { + await inngest.send(events); + } catch (error) { + console.error("Failed to dispatch preview deployment events:", error); + return NextResponse.json( + { ok: false, error: "Failed to queue preview deployment work" }, + { status: 500 }, + ); + } + } + return NextResponse.json({ + ok: true, + queued: events.length, + skippedFork: !sameRepository, + }); +} + export async function POST(request: NextRequest) { const body = await request.text(); const signature = request.headers.get("x-hub-signature-256"); @@ -274,6 +429,12 @@ export async function POST(request: NextRequest) { return handleInstallationEvent(payload as InstallationPayload); case "push": return handlePushEvent(payload as PushPayload); + case "pull_request": { + const deliveryId = + request.headers.get("x-github-delivery") ?? + createHash("sha256").update(body).digest("hex"); + return handlePullRequestEvent(payload as PullRequestPayload, deliveryId); + } case "ping": return NextResponse.json({ ok: true, message: "pong" }); default: diff --git a/web/components/builds/builds-viewer.tsx b/web/components/builds/builds-viewer.tsx index dcf773d6..ee3902ab 100644 --- a/web/components/builds/builds-viewer.tsx +++ b/web/components/builds/builds-viewer.tsx @@ -148,11 +148,13 @@ function BuildStatusBadge({ export function BuildsViewer({ serviceId, hasGithubAppRepo, + isPreview, projectSlug, envName, }: { serviceId: string; hasGithubAppRepo: boolean; + isPreview: boolean; projectSlug: string; envName: string; }) { @@ -188,7 +190,7 @@ export function BuildsViewer({ ); const handleTriggerBuild = async () => { - if (hasGithubAppRepo) { + if (hasGithubAppRepo && !isPreview) { setSelectedSha(null); setIsCommitDialogOpen(true); return; diff --git a/web/components/service/details/pull-request-previews-setting.tsx b/web/components/service/details/pull-request-previews-setting.tsx new file mode 100644 index 00000000..5b322ab1 --- /dev/null +++ b/web/components/service/details/pull-request-previews-setting.tsx @@ -0,0 +1,109 @@ +"use client"; + +import Link from "next/link"; +import { useTransition } from "react"; +import { toast } from "sonner"; +import { setPreviewDeploymentsEnabled } from "@/actions/previews"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import type { ServiceWithDetails as Service } from "@/db/types"; +import { pullRequestNumberFromMergeRef } from "@/lib/service-revision-spec"; + +function previewPullRequestNumber(previewGitRef: string | null) { + if (!previewGitRef) return null; + try { + return pullRequestNumberFromMergeRef(previewGitRef); + } catch { + return null; + } +} + +export function PullRequestPreviewsSetting({ + service, + projectSlug, + autoSubdomainDomain, + onUpdate, +}: { + service: Service; + projectSlug: string; + autoSubdomainDomain: string | null; + onUpdate?: () => void; +}) { + const [isPending, startTransition] = useTransition(); + const pullRequestNumber = previewPullRequestNumber(service.previewGitRef); + + if (service.previewOfService && pullRequestNumber) { + return ( +
+

+ Preview source:{" "} + + PR #{pullRequestNumber} + +

+ + {service.previewGitRef} + +
+ ); + } + if (!service.hasGithubAppRepo) return null; + + const updateEnabled = (enabled: boolean) => { + startTransition(async () => { + try { + await setPreviewDeploymentsEnabled(service.id, enabled); + toast.success( + enabled ? "Pull request previews enabled" : "Preview teardown queued", + ); + onUpdate?.(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Update failed"); + } + }); + }; + + return ( +
+
+ +

+ Deploy same-repository pull requests that are ready for review as + visible services in{" "} + {service.previewDeploymentsEnabled ? ( + + the previews environment + + ) : ( + "a previews environment" + )} + . Secrets are copied when each service is created. +

+ {service.stateful ? ( +

+ Preview deployments require a stateless service. +

+ ) : !autoSubdomainDomain ? ( +

+ Configure Automatic Subdomain Domain to give previews public URLs. +

+ ) : null} +
+ +
+ ); +} diff --git a/web/components/service/details/source-section.tsx b/web/components/service/details/source-section.tsx index 1da29e01..0331aec9 100644 --- a/web/components/service/details/source-section.tsx +++ b/web/components/service/details/source-section.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { ServiceWithDetails as Service } from "@/db/types"; import { imageNeedsProductionPinning } from "@/lib/docker-image"; +import { PullRequestPreviewsSetting } from "./pull-request-previews-setting"; function parseImageInfo(image: string): { registry: string; @@ -44,10 +45,16 @@ function parseImageInfo(image: string): { export const SourceSection = memo(function SourceSection({ service, + projectSlug, + autoSubdomainDomain, onUpdate, + onPreviewUpdate, }: { service: Service; + projectSlug: string; + autoSubdomainDomain: string | null; onUpdate?: () => void; + onPreviewUpdate?: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [editMode, setEditMode] = useState<"github" | "image">("image"); @@ -221,6 +228,12 @@ export const SourceSection = memo(function SourceSection({ )} + diff --git a/web/db/schema.ts b/web/db/schema.ts index 5a80770d..1766a0e9 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm"; import { bigint, boolean, + check, foreignKey, index, integer, @@ -586,6 +587,11 @@ export const services = pgTable( ), migrationBackupId: text("migration_backup_id"), migrationError: text("migration_error"), + previewDeploymentsEnabled: boolean("preview_deployments_enabled") + .notNull() + .default(false), + previewOfService: text("preview_of_service"), + previewGitRef: text("preview_git_ref"), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), @@ -599,6 +605,30 @@ export const services = pgTable( index("services_last_autoscale_attempt_idx").on( table.lastAutoscaleAttemptAt, ), + foreignKey({ + name: "services_preview_of_service_fk", + columns: [table.previewOfService], + foreignColumns: [table.id], + }).onDelete("restrict"), + check( + "services_preview_identity_check", + sql`(${table.previewOfService} is null) = (${table.previewGitRef} is null)`, + ), + check( + "services_preview_git_ref_check", + sql`${table.previewGitRef} is null or ${table.previewGitRef} ~ '^refs/pull/[1-9][0-9]*/merge$'`, + ), + check( + "services_preview_policy_check", + sql`( + (${table.previewOfService} is null and (${table.previewDeploymentsEnabled} = false or ${table.stateful} = false)) + or + (${table.previewOfService} is not null and ${table.previewDeploymentsEnabled} = false and ${table.stateful} = false) + )`, + ), + uniqueIndex("services_preview_base_ref_unique_idx") + .on(table.previewOfService, table.previewGitRef) + .where(sql`${table.previewOfService} is not null`), ], ); diff --git a/web/lib/github.ts b/web/lib/github.ts index 7ee0145f..70d92909 100644 --- a/web/lib/github.ts +++ b/web/lib/github.ts @@ -1,5 +1,16 @@ import { createHmac, createPrivateKey, timingSafeEqual } from "node:crypto"; import { SignJWT } from "jose"; +import { pullRequestMergeRef } from "@/lib/service-revision-spec"; + +export class GitHubApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "GitHubApiError"; + } +} function getAppId(): string { const appId = process.env.GITHUB_APP_ID; @@ -79,7 +90,10 @@ export async function getInstallationToken( if (!response.ok) { const error = await response.text(); - throw new Error(`Failed to get installation token: ${error}`); + throw new GitHubApiError( + `Failed to get installation token: ${error}`, + response.status, + ); } const data = await response.json(); @@ -135,6 +149,24 @@ export type GitHubCommit = { date: string; }; +export type GitHubPullRequest = { + number: number; + state: "open" | "closed"; + draft: boolean; + merged: boolean; + title: string; + updatedAt: string; + user: { id: number; login: string }; + base: { + ref: string; + repository: { id: number; fullName: string }; + }; + head: { + sha: string; + repository: { id: number; fullName: string } | null; + }; +}; + export function isFullCommitSha(value: string): boolean { return /^[0-9a-f]{40}$/i.test(value); } @@ -235,6 +267,144 @@ export async function resolveGitHubCommit( return mapGitHubCommit(commit); } +type GitHubPullRequestResponse = { + number: number; + state: string; + draft?: boolean | null; + merged?: boolean | null; + title: string; + updated_at: string; + user: { id: number; login: string } | null; + base: { ref: string; repo: { id: number; full_name: string } }; + head: { sha: string; repo: { id: number; full_name: string } | null }; +}; + +function mapGitHubPullRequest( + pullRequest: GitHubPullRequestResponse, +): GitHubPullRequest { + if ( + !pullRequest.user || + (pullRequest.state !== "open" && pullRequest.state !== "closed") + ) { + throw new Error("GitHub returned an invalid pull request"); + } + return { + number: pullRequest.number, + state: pullRequest.state, + draft: pullRequest.draft === true, + merged: pullRequest.merged === true, + title: pullRequest.title, + updatedAt: pullRequest.updated_at, + user: { id: pullRequest.user.id, login: pullRequest.user.login }, + base: { + ref: pullRequest.base.ref, + repository: { + id: pullRequest.base.repo.id, + fullName: pullRequest.base.repo.full_name, + }, + }, + head: { + sha: pullRequest.head.sha, + repository: pullRequest.head.repo + ? { + id: pullRequest.head.repo.id, + fullName: pullRequest.head.repo.full_name, + } + : null, + }, + }; +} + +async function githubPullRequestRequest( + installationId: number, + repoFullName: string, + suffix: string, +): Promise { + validateRepoFullName(repoFullName); + if (!Number.isSafeInteger(installationId) || installationId <= 0) { + throw new Error("Invalid GitHub installation ID"); + } + const token = await getInstallationToken(installationId); + const response = await fetch( + `https://api.github.com/repos/${repoFullName}/pulls${suffix}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!response.ok) { + const detail = await response.text(); + throw new GitHubApiError( + `GitHub pull request failed (${response.status}): ${detail || response.statusText}`, + response.status, + ); + } + return response.json() as Promise; +} + +export async function getGitHubPullRequest( + installationId: number, + repoFullName: string, + pullRequestNumber: number, +): Promise { + if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) { + throw new Error("Invalid pull request number"); + } + const pullRequest = await githubPullRequestRequest( + installationId, + repoFullName, + `/${pullRequestNumber}`, + ); + return mapGitHubPullRequest(pullRequest); +} + +export async function listOpenGitHubPullRequests( + installationId: number, + repoFullName: string, + baseBranch: string, +): Promise { + if (!baseBranch.trim()) throw new Error("GitHub branch is not configured"); + const pullRequests: GitHubPullRequestResponse[] = []; + for (let page = 1; ; page++) { + const batch = await githubPullRequestRequest( + installationId, + repoFullName, + `?state=open&base=${encodeURIComponent(baseBranch)}&per_page=100&page=${page}`, + ); + pullRequests.push(...batch); + if (batch.length < 100) break; + } + return pullRequests.map(mapGitHubPullRequest); +} + +export async function resolveGitHubPullRequestMergeRef( + installationId: number, + repoFullName: string, + pullRequestNumber: number, +): Promise<{ gitRef: string; sha: string }> { + const gitRef = pullRequestMergeRef(pullRequestNumber); + try { + const commits = await githubCommitRequest( + installationId, + repoFullName, + `?sha=${encodeURIComponent(gitRef)}&per_page=1`, + ); + const sha = commits[0]?.sha; + if (!sha || !isFullCommitSha(sha)) { + throw new Error("GitHub returned no merge commit"); + } + return { gitRef, sha: sha.toLowerCase() }; + } catch (error) { + throw new Error( + `Merge ref ${gitRef} is unavailable; resolve merge conflicts and retry`, + { cause: error }, + ); + } +} + export async function listGitHubCommits( installationId: number, repoFullName: string, @@ -254,7 +424,66 @@ type DeploymentState = | "in_progress" | "success" | "failure" - | "error"; + | "error" + | "inactive"; + +export async function findGitHubDeployment( + installationId: number, + repoFullName: string, + commitSha: string, + environment: string, + expectedPayload: Record, +): Promise { + validateRepoFullName(repoFullName); + const token = await getInstallationToken(installationId); + const parameters = new URLSearchParams({ + sha: commitSha, + environment, + per_page: "100", + }); + const response = await fetch( + `https://api.github.com/repos/${repoFullName}/deployments?${parameters}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to list deployments: ${error}`); + } + const deployments = (await response.json()) as Array<{ + id: number; + payload: unknown; + }>; + for (const deployment of deployments) { + let payload = deployment.payload; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + continue; + } + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + continue; + } + const payloadRecord = payload as Record; + if ( + Number.isSafeInteger(deployment.id) && + Object.entries(expectedPayload).every( + ([key, value]) => + Object.hasOwn(payloadRecord, key) && payloadRecord[key] === value, + ) + ) { + return deployment.id; + } + } + return null; +} export async function createGitHubDeployment( installationId: number, @@ -262,6 +491,11 @@ export async function createGitHubDeployment( ref: string, environment: string, description: string, + options: { + transientEnvironment?: boolean; + productionEnvironment?: boolean; + payload?: Record; + } = {}, ): Promise { validateRepoFullName(repoFullName); const token = await getInstallationToken(installationId); @@ -280,8 +514,11 @@ export async function createGitHubDeployment( ref, environment, description, + payload: options.payload ?? {}, auto_merge: false, required_contexts: [], + transient_environment: options.transientEnvironment, + production_environment: options.productionEnvironment, }), }, ); diff --git a/web/lib/inngest/events/build.ts b/web/lib/inngest/events/build.ts index 1f8214fa..9960c997 100644 --- a/web/lib/inngest/events/build.ts +++ b/web/lib/inngest/events/build.ts @@ -6,10 +6,11 @@ export type BuildEvents = { serviceId: string; serviceRevisionId: string; buildRequestId: string; - trigger: "manual" | "scheduled" | "push"; + trigger: "manual" | "scheduled" | "push" | "preview"; commitSha: string; commitMessage: string; branch: string; + gitRef?: string; author?: string; actor?: ServiceRevisionActor | null; githubDeploymentId?: number; diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts index 4e284e71..48091f06 100644 --- a/web/lib/inngest/events/index.ts +++ b/web/lib/inngest/events/index.ts @@ -4,6 +4,7 @@ export type { BackupEvents } from "./backup"; export type { BuildEvents } from "./build"; export type { MigrationEvents } from "./migration"; export type { NotificationEvent, NotificationEvents } from "./notification"; +export type { PreviewEvents } from "./preview"; export type { ResourceEvents } from "./resource"; export type { RestoreEvents } from "./restore"; export type { RolloutEvents } from "./rollout"; @@ -14,6 +15,7 @@ import type { BackupEvents } from "./backup"; import type { BuildEvents } from "./build"; import type { MigrationEvents } from "./migration"; import type { NotificationEvents } from "./notification"; +import type { PreviewEvents } from "./preview"; import type { ResourceEvents } from "./resource"; import type { RestoreEvents } from "./restore"; import type { RolloutEvents } from "./rollout"; @@ -28,6 +30,7 @@ export type Events = RolloutEvents & ServiceDeletionEvents & ResourceEvents & NotificationEvents & + PreviewEvents & ServiceCronEvents; type EventName = keyof Events & string; @@ -63,6 +66,11 @@ export const inngestEvents = { buildCompleted: defineEvent("build/completed"), manifestCompleted: defineEvent("manifest/completed"), manifestFailed: defineEvent("manifest/failed"), + previewSyncRequested: defineEvent("preview/sync-requested"), + previewCloseRequested: defineEvent("preview/close-requested"), + previewServiceReconcileRequested: defineEvent( + "preview/service-reconcile-requested", + ), notificationRequested: defineEvent("notification/requested"), serviceCronExecute: defineEvent("service-cron/execute"), }; diff --git a/web/lib/inngest/events/preview.ts b/web/lib/inngest/events/preview.ts new file mode 100644 index 00000000..f4541be8 --- /dev/null +++ b/web/lib/inngest/events/preview.ts @@ -0,0 +1,22 @@ +export type PreviewEvents = { + "preview/sync-requested": { + data: { + baseServiceId: string; + previewGitRef: string; + force?: boolean; + }; + }; + "preview/close-requested": { + data: { + baseServiceId: string; + previewGitRef: string; + reason: string; + verifyWithGitHub?: boolean; + }; + }; + "preview/service-reconcile-requested": { + data: { + baseServiceId: string; + }; + }; +}; diff --git a/web/lib/inngest/functions/build-trigger-workflow.ts b/web/lib/inngest/functions/build-trigger-workflow.ts index acc3383f..4a80e12b 100644 --- a/web/lib/inngest/functions/build-trigger-workflow.ts +++ b/web/lib/inngest/functions/build-trigger-workflow.ts @@ -1,12 +1,13 @@ import { createHash } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { db } from "@/db"; -import { builds, serviceRevisions } from "@/db/schema"; +import { builds, serviceRevisions, services } from "@/db/schema"; import { getTargetPlatformsForRevision, selectBuildServerForRevision, } from "@/lib/build-assignment"; import { isFullCommitSha } from "@/lib/github"; +import { createPreviewGitHubDeployment } from "@/lib/preview-deployments"; import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { enqueueWork } from "@/lib/work-queue"; import { inngest } from "../client"; @@ -30,9 +31,11 @@ export const buildTriggerWorkflow = inngest.createFunction( serviceId, serviceRevisionId, buildRequestId, + trigger, commitSha, commitMessage, branch, + gitRef, author, githubDeploymentId, actor = null, @@ -41,10 +44,14 @@ export const buildTriggerWorkflow = inngest.createFunction( throw new Error("Build fan-out requires a full 40-character commit SHA"); } const exactCommitSha = commitSha.toLowerCase(); - const specification = await step.run("get-build-revision", async () => { + const revision = await step.run("get-build-revision", async () => { const revision = await db - .select({ specification: serviceRevisions.specification }) + .select({ + specification: serviceRevisions.specification, + previewGitRef: services.previewGitRef, + }) .from(serviceRevisions) + .innerJoin(services, eq(services.id, serviceRevisions.serviceId)) .where( and( eq(serviceRevisions.id, serviceRevisionId), @@ -57,58 +64,85 @@ export const buildTriggerWorkflow = inngest.createFunction( if ( parsed.source.type !== "github" || parsed.source.commitSha !== exactCommitSha || - parsed.source.branch !== branch + parsed.source.branch !== branch || + (revision.previewGitRef ?? undefined) !== gitRef ) { throw new Error("Build trigger does not match its service revision"); } - return parsed; + return { + specification: parsed, + isPreview: revision.previewGitRef != null, + }; }); + const { specification, isPreview } = revision; - const { buildIds, buildGroupId } = await step.run( - "create-builds", - async () => { - const targetPlatforms = - await getTargetPlatformsForRevision(specification); - if (targetPlatforms.length === 0) { - throw new Error("No target platforms configured for this build"); - } - if (new Set(targetPlatforms).size !== targetPlatforms.length) { - throw new Error( - "Duplicate target platforms configured for this build", - ); - } + const buildCreation = await step.run("create-builds", async () => { + const targetPlatforms = + await getTargetPlatformsForRevision(specification); + if (targetPlatforms.length === 0) { + throw new Error("No target platforms configured for this build"); + } + if (new Set(targetPlatforms).size !== targetPlatforms.length) { + throw new Error("Duplicate target platforms configured for this build"); + } - const assignments = await Promise.all( - targetPlatforms.map(async (platform) => ({ - id: buildIdForRequest(buildRequestId, platform), - platform, - serverId: await selectBuildServerForRevision( - specification, - platform, - ), - })), + const assignments = await Promise.all( + targetPlatforms.map(async (platform) => ({ + id: buildIdForRequest(buildRequestId, platform), + platform, + serverId: await selectBuildServerForRevision(specification, platform), + })), + ); + const buildRows = assignments.map(({ id, platform }) => ({ + id, + serviceId, + serviceRevisionId, + commitSha: exactCommitSha, + commitMessage, + branch, + author, + targetPlatform: platform, + buildGroupId: buildRequestId, + status: "pending" as const, + githubDeploymentId, + })); + const persisted = await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`, ); - const buildRows = assignments.map(({ id, platform }) => ({ - id, - serviceId, - serviceRevisionId, - commitSha: exactCommitSha, - commitMessage, - branch, - author, - targetPlatform: platform, - buildGroupId: buildRequestId, - status: "pending" as const, - githubDeploymentId, - })); - const inserted = await db + if (isPreview) { + const [activeService, latestRevision] = await Promise.all([ + tx + .select({ id: services.id }) + .from(services) + .where( + and(eq(services.id, serviceId), isNull(services.deletedAt)), + ) + .then((rows) => rows[0]), + tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, serviceId)) + .orderBy( + desc(serviceRevisions.createdAt), + desc(serviceRevisions.id), + ) + .limit(1) + .then((rows) => rows[0]), + ]); + if (!activeService || latestRevision?.id !== serviceRevisionId) { + return false; + } + } + + const inserted = await tx .insert(builds) .values(buildRows) .onConflictDoNothing({ target: builds.id }) .returning({ id: builds.id }); if (inserted.length !== buildRows.length) { - const existingRows = await db + const existingRows = await tx .select({ id: builds.id, serviceId: builds.serviceId, @@ -143,22 +177,116 @@ export const buildTriggerWorkflow = inngest.createFunction( } } } + return true; + }); - for (const assignment of assignments) { - await enqueueWork( - assignment.serverId, - "build", - { buildId: assignment.id }, - { id: `build-work-${assignment.id}` }, - ); + return { + stale: !persisted, + buildIds: assignments.map((assignment) => assignment.id), + buildGroupId: buildRequestId, + assignments, + }; + }); + if (buildCreation.stale) { + return { + status: "cancelled", + reason: "superseded_preview_revision", + buildGroupId: buildRequestId, + }; + } + const { buildIds, buildGroupId, assignments } = buildCreation; + if (trigger === "preview" && !githubDeploymentId) { + await step.run("create-preview-github-deployment", () => + createPreviewGitHubDeployment({ + serviceId, + serviceRevisionId, + commitSha: exactCommitSha, + }), + ); + } + const enqueueResult = await step.run("enqueue-builds", () => + db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + if (isPreview) { + const [activeService, latestRevision] = await Promise.all([ + tx + .select({ id: services.id }) + .from(services) + .where( + and(eq(services.id, serviceId), isNull(services.deletedAt)), + ) + .then((rows) => rows[0]), + tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, serviceId)) + .orderBy( + desc(serviceRevisions.createdAt), + desc(serviceRevisions.id), + ) + .limit(1) + .then((rows) => rows[0]), + ]); + if (!activeService || latestRevision?.id !== serviceRevisionId) { + await tx + .update(builds) + .set({ status: "cancelled", completedAt: new Date() }) + .where( + and(inArray(builds.id, buildIds), eq(builds.status, "pending")), + ); + return "stale" as const; + } + } + const currentBuilds = await tx + .select({ id: builds.id, status: builds.status }) + .from(builds) + .where(inArray(builds.id, buildIds)) + .for("update"); + const activeStatuses = new Set([ + "pending", + "claimed", + "cloning", + "building", + "pushing", + ]); + if ( + currentBuilds.length !== buildIds.length || + currentBuilds.some((build) => !activeStatuses.has(build.status)) + ) { + await tx + .update(builds) + .set({ status: "cancelled", completedAt: new Date() }) + .where( + and(inArray(builds.id, buildIds), eq(builds.status, "pending")), + ); + return "cancelled" as const; } - return { - buildIds: assignments.map((assignment) => assignment.id), - buildGroupId: buildRequestId, - }; - }, + await Promise.all( + assignments.map((assignment) => + enqueueWork( + assignment.serverId, + "build", + { buildId: assignment.id }, + { id: `build-work-${assignment.id}`, tx }, + ), + ), + ); + return "enqueued" as const; + }), ); + if (enqueueResult !== "enqueued") { + return { + status: "cancelled", + reason: + enqueueResult === "stale" + ? "superseded_preview_revision" + : "build_cancelled_before_enqueue", + buildGroupId: buildRequestId, + }; + } await step.run("send-build-started", async () => { await inngest.send( diff --git a/web/lib/inngest/functions/build-workflow.ts b/web/lib/inngest/functions/build-workflow.ts index 723b9762..16926dd6 100644 --- a/web/lib/inngest/functions/build-workflow.ts +++ b/web/lib/inngest/functions/build-workflow.ts @@ -2,6 +2,7 @@ import { and, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { builds, workQueue } from "@/db/schema"; import { deployServiceRevisionInternal } from "@/lib/deploy-service"; +import { updatePreviewGitHubStatus } from "@/lib/preview-deployments"; import { inngest } from "../client"; import { inngestEvents } from "../events"; @@ -167,6 +168,23 @@ function validateCompletedGroup( } } +async function markPreviewBuildFailed( + serviceId: string, + serviceRevisionId: string, + description: string, +) { + try { + await updatePreviewGitHubStatus({ + serviceId, + serviceRevisionId, + state: "failure", + description, + }); + } catch (error) { + console.error("[build-workflow] failed to update preview status:", error); + } +} + export const buildWorkflow = inngest.createFunction( { id: "build-workflow", @@ -175,6 +193,19 @@ export const buildWorkflow = inngest.createFunction( cancelOn: [ { event: inngestEvents.buildCancelled, match: "data.buildGroupId" }, ], + onFailure: async ({ event }) => { + const data = event.data.event.data as { + serviceId?: string; + serviceRevisionId?: string; + }; + if (data.serviceId && data.serviceRevisionId) { + await markPreviewBuildFailed( + data.serviceId, + data.serviceRevisionId, + "Preview build workflow failed", + ); + } + }, }, async ({ event, step }) => { const { serviceId, serviceRevisionId, buildGroupId } = event.data; @@ -183,9 +214,23 @@ export const buildWorkflow = inngest.createFunction( let groupBuilds = await step.run("get-group-builds", readGroup); if (groupBuilds.length === 0) { + await step.run("report-missing-build-group", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview build group is missing", + ), + ); return { status: "failed", reason: "build_group_missing", buildGroupId }; } if (groupFailure(groupBuilds)) { + await step.run("report-initial-build-failure", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview build failed", + ), + ); return { status: "failed", reason: "build_failed", buildGroupId }; } @@ -206,9 +251,23 @@ export const buildWorkflow = inngest.createFunction( } if (groupBuilds.length === 0) { + await step.run("report-missing-build-group-after-wait", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview build group is missing", + ), + ); return { status: "failed", reason: "build_group_missing", buildGroupId }; } if (groupFailure(groupBuilds)) { + await step.run("report-build-failure", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview build failed", + ), + ); return { status: "failed", reason: "build_failed", buildGroupId }; } if (groupBuilds.some((build) => build.status !== "completed")) { @@ -232,6 +291,13 @@ export const buildWorkflow = inngest.createFunction( }); groupBuilds = await step.run("refresh-group-after-timeout", readGroup); if (groupBuilds.some((build) => build.status !== "completed")) { + await step.run("report-build-timeout", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview build timed out", + ), + ); return { status: "failed", reason: "timeout", buildGroupId }; } } @@ -251,9 +317,23 @@ export const buildWorkflow = inngest.createFunction( ); } if (!manifest) { + await step.run("report-manifest-timeout", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview image manifest timed out", + ), + ); return { status: "completed_no_manifest", buildGroupId }; } if (manifest.status === "failed") { + await step.run("report-manifest-failure", () => + markPreviewBuildFailed( + serviceId, + serviceRevisionId, + "Preview image manifest failed", + ), + ); return { status: "failed", reason: "manifest_failed", buildGroupId }; } diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts index 1489b637..97efe4e9 100644 --- a/web/lib/inngest/functions/crons.ts +++ b/web/lib/inngest/functions/crons.ts @@ -1,7 +1,7 @@ -import { and, asc, eq, isNull, lte } from "drizzle-orm"; +import { and, asc, eq, isNotNull, isNull, lte } from "drizzle-orm"; import { cron } from "inngest"; import { db } from "@/db"; -import { serviceCrons, services } from "@/db/schema"; +import { githubRepos, serviceCrons, services } from "@/db/schema"; import { cleanupExpiredChallenges, renewExpiringCertificates, @@ -22,6 +22,7 @@ import { runAutoscalingController, } from "@/lib/scheduler"; import { inngest } from "../client"; +import { inngestEvents } from "../events"; import { cronEventId, latestDueOccurrence, @@ -215,6 +216,57 @@ export const serviceCommandRetention = inngest.createFunction( step.run("cleanup-service-commands", cleanupOldServiceCommands), ); +export const previewReconciliation = inngest.createFunction( + { + id: "cron-preview-reconciliation", + triggers: [cron("0 1 * * *")], + singleton: { mode: "skip" }, + }, + async ({ step }) => { + const serviceCount = await step.run( + "queue-preview-reconciliation", + async () => { + const [enabled, children] = await Promise.all([ + db + .select({ id: services.id }) + .from(services) + .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id)) + .where( + and( + eq(services.previewDeploymentsEnabled, true), + eq(services.sourceType, "github"), + isNull(services.previewOfService), + isNull(services.deletedAt), + ), + ), + db + .select({ id: services.previewOfService }) + .from(services) + .where(isNotNull(services.previewOfService)) + .groupBy(services.previewOfService), + ]); + const serviceIds = new Set([ + ...enabled.map(({ id }) => id), + ...children.flatMap(({ id }) => (id ? [id] : [])), + ]); + const day = new Date().toISOString().slice(0, 10); + if (serviceIds.size > 0) { + await inngest.send( + [...serviceIds].map((baseServiceId) => + inngestEvents.previewServiceReconcileRequested.create( + { baseServiceId }, + { id: `preview-service-daily:${baseServiceId}:${day}` }, + ), + ), + ); + } + return serviceIds.size; + }, + ); + return { serviceCount }; + }, +); + export const serviceCronDispatcher = inngest.createFunction( { id: "cron-service-cron-dispatcher", diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts index 88a7b203..dfd95ce4 100644 --- a/web/lib/inngest/functions/index.ts +++ b/web/lib/inngest/functions/index.ts @@ -8,6 +8,7 @@ export { challengeCleanup, controlPlaneUpdateCheck, notificationRetention, + previewReconciliation, serviceCommandRetention, serviceCronDispatcher, oldBackupsCleanup, @@ -21,6 +22,10 @@ export { serviceCronWorkflow } from "./service-cron-workflow"; export { migrationWorkflow } from "./migration-workflow"; export { notificationDelivery } from "./notification-delivery"; export { onDeploymentFailed } from "./on-deployment-failed"; +export { + previewServiceReconcileWorkflow, + previewSyncWorkflow, +} from "./preview-workflow"; export { restoreTriggerWorkflow } from "./restore-trigger-workflow"; export { onRestoreFailed, restoreWorkflow } from "./restore-workflow"; export { rolloutWorkflow } from "./rollout-workflow"; diff --git a/web/lib/inngest/functions/preview-workflow.ts b/web/lib/inngest/functions/preview-workflow.ts new file mode 100644 index 00000000..fc800c30 --- /dev/null +++ b/web/lib/inngest/functions/preview-workflow.ts @@ -0,0 +1,421 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { githubRepos, serviceRevisions, services } from "@/db/schema"; +import { + GitHubApiError, + getGitHubPullRequest, + listOpenGitHubPullRequests, + resolveGitHubPullRequestMergeRef, +} from "@/lib/github"; +import { + createPreviewClone, + inactivatePreviewGitHubDeployments, +} from "@/lib/preview-deployments"; +import { + cancelPreviewRevisionWork, + deletePreviewService, +} from "@/lib/preview-lifecycle"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; +import { + pullRequestMergeRef, + pullRequestNumberFromMergeRef, +} from "@/lib/service-revision-spec"; +import { triggerResolvedBuildInternal } from "@/lib/trigger-build"; +import { inngest } from "../client"; +import { inngestEvents } from "../events"; + +async function loadBaseContext(baseServiceId: string) { + return db + .select({ service: services, githubRepo: githubRepos }) + .from(services) + .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id)) + .where( + and( + eq(services.id, baseServiceId), + isNull(services.previewOfService), + isNull(services.deletedAt), + ), + ) + .then((rows) => rows[0]); +} + +async function closePreview( + baseServiceId: string, + previewGitRef: string, + reason: string, +) { + const deleted = await deletePreviewService( + baseServiceId, + previewGitRef, + reason, + ); + return deleted + ? { status: "deleted" as const, serviceId: deleted.service.id } + : { status: "not_found" as const }; +} + +function isEligiblePullRequest( + context: { + service: { + previewDeploymentsEnabled: boolean; + stateful: boolean; + sourceType: "image" | "github"; + }; + githubRepo: { + repoId: number; + deployBranch: string | null; + defaultBranch: string; + }; + }, + pullRequest: Awaited>, +) { + return ( + context.service.previewDeploymentsEnabled && + !context.service.stateful && + context.service.sourceType === "github" && + pullRequest.state === "open" && + !pullRequest.draft && + pullRequest.base.repository.id === context.githubRepo.repoId && + pullRequest.head.repository?.id === context.githubRepo.repoId && + pullRequest.base.ref === + (context.githubRepo.deployBranch ?? context.githubRepo.defaultBranch) + ); +} + +async function loadPreviewContext( + baseServiceId: string, + previewGitRef: string, +) { + return db + .select({ service: services, githubRepo: githubRepos }) + .from(services) + .innerJoin( + githubRepos, + eq(githubRepos.serviceId, services.previewOfService), + ) + .where( + and( + eq(services.previewOfService, baseServiceId), + eq(services.previewGitRef, previewGitRef), + isNull(services.deletedAt), + ), + ) + .then((rows) => rows[0]); +} + +async function closePreviewFromEvent(input: { + baseServiceId: string; + previewGitRef: string; + reason: string; + verifyWithGitHub?: boolean; +}) { + const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef); + if (input.verifyWithGitHub) { + const [baseContext, previewContext] = await Promise.all([ + loadBaseContext(input.baseServiceId), + loadPreviewContext(input.baseServiceId, input.previewGitRef), + ]); + if (!previewContext) return { status: "not_found" as const }; + if (baseContext) { + const pullRequest = await getGitHubPullRequest( + previewContext.githubRepo.installationId, + previewContext.githubRepo.repoFullName, + pullRequestNumber, + ).catch((error: unknown) => { + if (error instanceof GitHubApiError && error.status === 404) + return null; + throw error; + }); + if (pullRequest && isEligiblePullRequest(baseContext, pullRequest)) { + await enqueuePreviewSync( + input.baseServiceId, + input.previewGitRef, + `stale-close:${pullRequest.updatedAt}`, + ); + return { status: "stale" as const }; + } + } + } + return closePreview(input.baseServiceId, input.previewGitRef, input.reason); +} + +async function loadLatestPreviewRevision(serviceId: string) { + const service = await db + .select({ previewOfService: services.previewOfService }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!service?.previewOfService) return null; + const revision = await db + .select({ + id: serviceRevisions.id, + specification: serviceRevisions.specification, + }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, serviceId)) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(1) + .then((rows) => rows[0]); + if (!revision) return null; + const specification = parseServiceRevisionSpec(revision.specification); + return { + id: revision.id, + commitSha: + specification.source.type === "github" + ? specification.source.commitSha + : null, + }; +} + +export const previewSyncWorkflow = inngest.createFunction( + { + id: "preview-sync-workflow", + triggers: [ + inngestEvents.previewSyncRequested, + inngestEvents.previewCloseRequested, + ], + concurrency: [ + { + limit: 1, + key: 'event.data.baseServiceId + ":" + event.data.previewGitRef', + }, + ], + }, + async ({ event, step }) => { + if (event.name === inngestEvents.previewCloseRequested.name) { + return step.run("delete-preview", () => + closePreviewFromEvent(event.data), + ); + } + const { baseServiceId, previewGitRef } = event.data; + const force = "force" in event.data && event.data.force === true; + const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef); + const context = await step.run("load-base-service", () => + loadBaseContext(baseServiceId), + ); + if (!context) { + await step.run("close-orphaned-preview", () => + closePreview(baseServiceId, previewGitRef, "base service unavailable"), + ); + return { status: "closed", reason: "base_service_unavailable" }; + } + + const pullRequest = await step.run("load-pull-request", () => + getGitHubPullRequest( + context.githubRepo.installationId, + context.githubRepo.repoFullName, + pullRequestNumber, + ), + ); + if (!isEligiblePullRequest(context, pullRequest)) { + await step.run("close-ineligible-preview", () => + closePreview(baseServiceId, previewGitRef, "pull request ineligible"), + ); + return { status: "closed", reason: "pull_request_ineligible" }; + } + + const clone = await step.run("create-preview-service", () => + createPreviewClone({ + baseServiceId, + previewGitRef, + }), + ); + const previous = await step.run("load-latest-preview-revision", () => + loadLatestPreviewRevision(clone.serviceId), + ); + let mergeRef: { gitRef: string; sha: string }; + try { + mergeRef = await step.run("resolve-merge-ref", () => + resolveGitHubPullRequestMergeRef( + context.githubRepo.installationId, + context.githubRepo.repoFullName, + pullRequestNumber, + ), + ); + } catch { + await step.run("delete-unmergeable-preview", () => + deletePreviewService( + baseServiceId, + previewGitRef, + "merge ref is unavailable", + ), + ); + return { status: "failed", reason: "merge_ref_unavailable" }; + } + + if (!force && previous?.commitSha === mergeRef.sha) { + return { status: "unchanged", serviceId: clone.serviceId }; + } + + const queued = await step.run("queue-preview-build", () => + triggerResolvedBuildInternal(clone.serviceId, { + trigger: "preview", + commitSha: mergeRef.sha, + commitMessage: `Preview PR #${pullRequestNumber}: ${pullRequest.title}`, + author: pullRequest.user.login, + actor: { + type: "github", + githubUserId: pullRequest.user.id, + login: pullRequest.user.login, + }, + gitRef: mergeRef.gitRef, + idempotencyKey: force + ? `preview:${clone.serviceId}:${mergeRef.sha}:${event.id}` + : `preview:${clone.serviceId}:${mergeRef.sha}`, + }), + ); + if (previous) { + await step.run("cancel-superseded-preview", () => + cancelPreviewRevisionWork(clone.serviceId, previous.id), + ); + } + await step.run("inactivate-superseded-deployments", () => + inactivatePreviewGitHubDeployments({ + serviceId: clone.serviceId, + excludeServiceRevisionId: queued.serviceRevisionId, + description: "Superseded by a newer preview revision", + }), + ); + return queued; + }, +); + +export const previewServiceReconcileWorkflow = inngest.createFunction( + { + id: "preview-service-reconcile-workflow", + triggers: [inngestEvents.previewServiceReconcileRequested], + concurrency: [{ limit: 1, key: "event.data.baseServiceId" }], + }, + async ({ event, step }) => { + const context = await step.run("load-base-service", () => + loadBaseContext(event.data.baseServiceId), + ); + if (!context || !context.service.previewDeploymentsEnabled) { + const clones = await step.run("load-previews-to-close", () => + db + .select({ previewGitRef: services.previewGitRef }) + .from(services) + .where(eq(services.previewOfService, event.data.baseServiceId)), + ); + await Promise.all( + clones.flatMap((clone) => + clone.previewGitRef + ? [ + step.run(`close-disabled-${clone.previewGitRef}`, () => + closePreview( + event.data.baseServiceId, + clone.previewGitRef!, + "preview deployments disabled", + ), + ), + ] + : [], + ), + ); + return { status: "disabled", closed: clones.length }; + } + const pullRequests = await step.run("list-open-pull-requests", () => + listOpenGitHubPullRequests( + context.githubRepo.installationId, + context.githubRepo.repoFullName, + context.githubRepo.deployBranch ?? context.githubRepo.defaultBranch, + ), + ); + const eligible = pullRequests.filter((pullRequest) => + isEligiblePullRequest(context, pullRequest), + ); + const eligibleRefs = new Set( + eligible.map((pullRequest) => pullRequestMergeRef(pullRequest.number)), + ); + const existing = await step.run("list-existing-previews", () => + db + .select({ + previewGitRef: services.previewGitRef, + deletedAt: services.deletedAt, + }) + .from(services) + .where(eq(services.previewOfService, event.data.baseServiceId)), + ); + const deleting = existing.filter( + (clone) => clone.previewGitRef && clone.deletedAt != null, + ); + await Promise.all( + deleting.map((clone) => + step.run(`finish-delete-${clone.previewGitRef}`, () => + closePreview( + event.data.baseServiceId, + clone.previewGitRef!, + "retrying preview deletion", + ), + ), + ), + ); + const stale = existing.filter( + (clone) => + clone.previewGitRef && + clone.deletedAt == null && + !eligibleRefs.has(clone.previewGitRef), + ); + await Promise.all( + stale.map((clone) => + step.run(`queue-close-${clone.previewGitRef}`, () => + enqueuePreviewClose( + event.data.baseServiceId, + clone.previewGitRef!, + "pull request no longer eligible", + `reconcile:${event.id}`, + ), + ), + ), + ); + await Promise.all( + eligible.map((pullRequest) => + step.run(`queue-pr-${pullRequest.number}`, () => + enqueuePreviewSync( + event.data.baseServiceId, + pullRequestMergeRef(pullRequest.number), + `reconcile:${pullRequest.updatedAt}`, + ), + ), + ), + ); + return { + status: "queued", + count: eligible.length, + closed: deleting.length + stale.length, + }; + }, +); + +async function enqueuePreviewClose( + baseServiceId: string, + previewGitRef: string, + reason: string, + idSuffix: string, +) { + const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef); + await inngest.send( + inngestEvents.previewCloseRequested.create( + { baseServiceId, previewGitRef, reason, verifyWithGitHub: true }, + { + id: `preview-close:${baseServiceId}:${pullRequestNumber}:${idSuffix}`, + }, + ), + ); +} + +async function enqueuePreviewSync( + baseServiceId: string, + previewGitRef: string, + idSuffix: string, +) { + const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef); + await inngest.send( + inngestEvents.previewSyncRequested.create( + { baseServiceId, previewGitRef }, + { + id: `preview-reconcile:${baseServiceId}:${pullRequestNumber}:${idSuffix}`, + }, + ), + ); +} diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts index e056f122..926b4a26 100644 --- a/web/lib/inngest/functions/rollout-helpers.ts +++ b/web/lib/inngest/functions/rollout-helpers.ts @@ -1,11 +1,12 @@ import { randomUUID } from "node:crypto"; -import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import { db } from "@/db"; import { deploymentPorts, deployments, rollouts, servers, + serviceRevisions, services, } from "@/db/schema"; import { getCertificate, issueCertificate } from "@/lib/acme-manager"; @@ -61,6 +62,7 @@ export function distributeReplicas( export type DeploymentContext = { revisionId: string; + isPreview: boolean; specification: ServiceRevisionSpec; placements: Placement[]; serverMap: Map< @@ -71,6 +73,27 @@ export type DeploymentContext = { isRollingUpdate: boolean; }; +async function isCurrentPreviewRevision( + tx: RolloutTransaction, + serviceId: string, + revisionId: string, +) { + const latest = await tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .innerJoin(services, eq(services.id, serviceRevisions.serviceId)) + .where( + and( + eq(serviceRevisions.serviceId, serviceId), + isNull(services.deletedAt), + ), + ) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(1) + .then((rows) => rows[0]); + return latest?.id === revisionId; +} + async function getUsedPorts( tx: RolloutTransaction, serverId: string, @@ -364,7 +387,8 @@ export async function createDeploymentRecords( serviceId: string, context: DeploymentContext, ): Promise<{ deploymentIds: string[] }> { - const { revisionId, specification, placements, serverMap } = context; + const { revisionId, isPreview, specification, placements, serverMap } = + context; const requestedReplicasByServer = new Map( placements.map((placement) => [placement.serverId, placement.replicas]), @@ -385,12 +409,19 @@ export async function createDeploymentRecords( for (let i = 0; i < placement.replicas; i++) { const deploymentId = randomUUID(); + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- each allocation must observe and lock the ports claimed by the previous replica const currentDeploymentIds = await withAllocationRetry( () => db.transaction(async (tx) => { await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, ); + if ( + isPreview && + !(await isCurrentPreviewRevision(tx, serviceId, revisionId)) + ) { + throw new Error("Preview revision is no longer current"); + } const [rollout] = await tx .select({ status: rollouts.status }) .from(rollouts) @@ -493,14 +524,22 @@ export async function createDeploymentRecords( export async function completeRollout( rolloutId: string, serviceId: string, - context: Omit, + context: Omit, ): Promise<{ completed: boolean; stoppedCount: number }> { - const { placements, specification, isRollingUpdate } = context; + const { placements, revisionId, isPreview, specification, isRollingUpdate } = + context; const lockedServerId = specification.stateful ? placements[0]?.serverId : undefined; return db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`); + if ( + isPreview && + !(await isCurrentPreviewRevision(tx, serviceId, revisionId)) + ) { + return { completed: false, stoppedCount: 0 }; + } const rollout = await tx .select({ status: rollouts.status }) .from(rollouts) diff --git a/web/lib/inngest/functions/rollout-utils.ts b/web/lib/inngest/functions/rollout-utils.ts index 3fb6efe2..065a52d1 100644 --- a/web/lib/inngest/functions/rollout-utils.ts +++ b/web/lib/inngest/functions/rollout-utils.ts @@ -3,6 +3,7 @@ import { db } from "@/db"; import { deployments, rollouts } from "@/db/schema"; import { markDeploymentFailedRemoved } from "@/lib/deployment-status"; import { notify } from "@/lib/notifications"; +import { updatePreviewGitHubStatus } from "@/lib/preview-deployments"; import { enqueueReconcileForAllOnlineServers, enqueueWork, @@ -14,14 +15,17 @@ export async function handleRolloutFailure( reason: string, isRollingUpdate: boolean, ): Promise { - const { applied, rolloutDeployments } = await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const [rollout] = await tx - .select({ status: rollouts.status }) + .select({ + status: rollouts.status, + serviceRevisionId: rollouts.serviceRevisionId, + }) .from(rollouts) .where(eq(rollouts.id, rolloutId)) .for("update"); if (rollout?.status !== "in_progress") { - return { applied: false, rolloutDeployments: [] }; + return { applied: false as const, rolloutDeployments: [] }; } const rolloutDeployments = await tx @@ -38,7 +42,7 @@ export async function handleRolloutFailure( .where(eq(rollouts.id, rolloutId)); if (rolloutDeployments.length === 0) { - return { applied: true, rolloutDeployments }; + return { applied: true as const, rolloutDeployments, rollout }; } if (isRollingUpdate) { @@ -79,9 +83,26 @@ export async function handleRolloutFailure( } } - return { applied: true, rolloutDeployments }; + return { applied: true as const, rolloutDeployments, rollout }; }); - if (!applied) return; + if (!result.applied) return; + const { rolloutDeployments } = result; + const serviceRevisionId = result.rollout.serviceRevisionId; + if (serviceRevisionId) { + try { + await updatePreviewGitHubStatus({ + serviceId, + serviceRevisionId, + state: "failure", + description: `Preview rollout failed: ${reason}`, + }); + } catch (error) { + console.error( + "[rollout:failure] failed to update preview status:", + error, + ); + } + } if (rolloutDeployments.length === 0) { notify({ diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts index 3b723990..0c54f5c4 100644 --- a/web/lib/inngest/functions/rollout-workflow.ts +++ b/web/lib/inngest/functions/rollout-workflow.ts @@ -1,9 +1,30 @@ -import { and, eq, gte, inArray, isNull, lt, ne, or, sql } from "drizzle-orm"; +import { + and, + desc, + eq, + gte, + inArray, + isNull, + lt, + ne, + or, + sql, +} from "drizzle-orm"; import { db } from "@/db"; import { getService } from "@/db/queries"; -import { deployments, rollouts, servers } from "@/db/schema"; +import { + deployments, + rollouts, + servers, + serviceRevisions, + services, +} from "@/db/schema"; import { isObservedReady, observedReadyPhases } from "@/lib/deployment-status"; import { buildRoutingTargets } from "@/lib/routing-sync"; +import { + canDeployServiceRevision, + updatePreviewGitHubStatus, +} from "@/lib/preview-deployments"; import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; import { getRolloutServiceRevision } from "@/lib/service-revisions"; import { ingestRolloutLog } from "@/lib/victoria-logs"; @@ -202,11 +223,12 @@ export const rolloutWorkflow = inngest.createFunction( async ({ event, step }) => { const { rolloutId, serviceId } = event.data; - await step.run("validate-service", async () => { + const isPreview = await step.run("validate-service", async () => { const svc = await getService(serviceId); if (!svc) { throw new Error("Service not found"); } + return Boolean(svc.previewOfService); }); let acquiredTurn = false; @@ -265,6 +287,24 @@ export const rolloutWorkflow = inngest.createFunction( }; }); const specification = revision.specification; + const currentRevision = + !isPreview || + (await step.run("validate-current-preview-revision", () => + canDeployServiceRevision(serviceId, revision.id), + )); + if (!currentRevision) { + await step.run("mark-superseded-preview-rollout", () => + db + .update(rollouts) + .set({ + status: "failed", + currentStage: "superseded", + completedAt: new Date(), + }) + .where(eq(rollouts.id, rolloutId)), + ); + return { status: "cancelled", rolloutId }; + } await step.run("log-rollout-started", async () => { await ingestRolloutLog( @@ -420,6 +460,12 @@ export const rolloutWorkflow = inngest.createFunction( } const { deploymentIds } = await step.run("create-deployments", async () => { + if ( + isPreview && + !(await canDeployServiceRevision(serviceId, revision.id)) + ) { + throw new Error("Preview revision was superseded before deployment"); + } await db .update(rollouts) .set({ currentStage: "deploying" }) @@ -432,6 +478,7 @@ export const rolloutWorkflow = inngest.createFunction( const result = await createDeploymentRecords(rolloutId, serviceId, { revisionId: revision.id, + isPreview, specification, placements, serverMap, @@ -591,6 +638,27 @@ export const rolloutWorkflow = inngest.createFunction( await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, ); + if (isPreview) { + const latestRevision = await tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .innerJoin(services, eq(services.id, serviceRevisions.serviceId)) + .where( + and( + eq(serviceRevisions.serviceId, serviceId), + isNull(services.deletedAt), + ), + ) + .orderBy( + desc(serviceRevisions.createdAt), + desc(serviceRevisions.id), + ) + .limit(1) + .then((rows) => rows[0]); + if (latestRevision?.id !== revision.id) { + throw new Error("Preview revision was superseded before routing"); + } + } const [rollout] = await tx .select({ status: rollouts.status }) .from(rollouts) @@ -692,7 +760,15 @@ export const rolloutWorkflow = inngest.createFunction( } const rolloutCompleted = await step.run("complete-rollout", async () => { + if ( + isPreview && + !(await canDeployServiceRevision(serviceId, revision.id)) + ) { + return false; + } const result = await completeRollout(rolloutId, serviceId, { + revisionId: revision.id, + isPreview, specification, placements, totalReplicas, @@ -718,6 +794,16 @@ export const rolloutWorkflow = inngest.createFunction( if (!rolloutCompleted) { return { status: "cancelled", rolloutId }; } + if (isPreview) { + await step.run("report-preview-ready", () => + updatePreviewGitHubStatus({ + serviceId, + serviceRevisionId: revision.id, + state: "success", + description: "Preview is ready", + }), + ); + } return { status: "completed", rolloutId }; }, diff --git a/web/lib/preview-deployments.ts b/web/lib/preview-deployments.ts new file mode 100644 index 00000000..15581257 --- /dev/null +++ b/web/lib/preview-deployments.ts @@ -0,0 +1,633 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { getSetting } from "@/db/queries"; +import { + builds, + environments, + githubRepos, + secrets, + servicePorts, + serviceReplicas, + serviceRevisions, + services, +} from "@/db/schema"; +import { + createGitHubDeployment, + findGitHubDeployment, + updateGitHubDeploymentStatus, +} from "@/lib/github"; +import { resolveRegistryImageHost } from "@/lib/registry-reference"; +import { + getDefaultServiceHostname, + pullRequestMergeRef, + pullRequestNumberFromMergeRef, +} from "@/lib/service-revision-spec"; +import { SETTING_KEYS } from "@/lib/settings-keys"; + +const DNS_LABEL_MAX_LENGTH = 63; +export const PREVIEWS_ENVIRONMENT_NAME = "previews"; + +type PreviewTransaction = Parameters[0]>[0]; + +type PreviewPort = { + port: number; + isPublic: boolean; + domain: string | null; + protocol: "http" | "tcp" | "udp"; + externalPort: number | null; + tlsPassthrough: boolean; +}; + +function dnsLabelPart(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-"); +} + +export function previewHostname(input: { + serviceName: string; + serviceId: string; + pullRequestNumber: number; + domain: string; + portIndex?: number; +}) { + if ( + !Number.isInteger(input.pullRequestNumber) || + input.pullRequestNumber < 1 + ) { + throw new Error("Invalid pull request number"); + } + + const service = dnsLabelPart(input.serviceName) || "service"; + const serviceId = dnsLabelPart(input.serviceId) + .replaceAll("-", "") + .slice(0, 8); + if (!serviceId) throw new Error("Invalid service id"); + + const portSuffix = input.portIndex ? `-p${input.portIndex + 1}` : ""; + const stableSuffix = `-pr-${input.pullRequestNumber}-${serviceId}${portSuffix}`; + const availableServiceLength = DNS_LABEL_MAX_LENGTH - stableSuffix.length; + if (availableServiceLength < 1) { + throw new Error("Preview hostname suffix exceeds DNS label limit"); + } + + const label = `${service.slice(0, availableServiceLength).replace(/-+$/, "") || "s"}${stableSuffix}`; + const domain = input.domain.trim().toLowerCase().replace(/\.$/, ""); + if (!domain) throw new Error("Automatic Subdomain Domain is not configured"); + + return `${label}.${domain}`; +} + +export async function getPreviewDomain() { + const domain = await getSetting(SETTING_KEYS.AUTO_SUBDOMAIN_DOMAIN); + return domain?.trim().toLowerCase().replace(/\.$/, "") || null; +} + +async function ensurePreviewEnvironmentInTransaction( + tx: PreviewTransaction, + projectId: string, +) { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`preview-environment:${projectId}`}))`, + ); + const existing = await tx + .select() + .from(environments) + .where( + and( + eq(environments.projectId, projectId), + eq(environments.name, PREVIEWS_ENVIRONMENT_NAME), + ), + ) + .then((rows) => rows[0]); + if (existing) return existing; + + const created = await tx + .insert(environments) + .values({ + id: randomUUID(), + projectId, + name: PREVIEWS_ENVIRONMENT_NAME, + }) + .onConflictDoNothing({ + target: [environments.projectId, environments.name], + }) + .returning() + .then((rows) => rows[0]); + if (created) return created; + + const concurrent = await tx + .select() + .from(environments) + .where( + and( + eq(environments.projectId, projectId), + eq(environments.name, PREVIEWS_ENVIRONMENT_NAME), + ), + ) + .then((rows) => rows[0]); + if (!concurrent) throw new Error("Failed to create previews environment"); + return concurrent; +} + +export async function ensurePreviewEnvironment(projectId: string) { + return db.transaction((tx) => + ensurePreviewEnvironmentInTransaction(tx, projectId), + ); +} + +export function previewPortConfiguration(input: { + ports: PreviewPort[]; + serviceName: string; + serviceId: string; + pullRequestNumber: number; + domain: string | null; +}) { + let publicHttpIndex = 0; + return input.ports.map((port) => { + if (port.isPublic && port.protocol === "http" && input.domain) { + const index = publicHttpIndex++; + return { + ...port, + domain: previewHostname({ + serviceName: input.serviceName, + serviceId: input.serviceId, + pullRequestNumber: input.pullRequestNumber, + domain: input.domain, + portIndex: index, + }), + externalPort: null, + tlsPassthrough: false, + }; + } + return { + ...port, + isPublic: false, + domain: null, + externalPort: null, + tlsPassthrough: false, + }; + }); +} + +export async function createPreviewClone(input: { + baseServiceId: string; + previewGitRef: string; +}) { + const domain = await getPreviewDomain(); + const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef); + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`, + ); + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), hashtext(${input.previewGitRef}))`, + ); + + const base = await tx + .select() + .from(services) + .where( + and( + eq(services.id, input.baseServiceId), + isNull(services.previewOfService), + isNull(services.deletedAt), + ), + ) + .then((rows) => rows[0]); + if (!base || !base.previewDeploymentsEnabled) { + throw new Error("Preview deployments are not enabled for this service"); + } + if (base.stateful) { + throw new Error("Preview deployments require a stateless service"); + } + if (base.sourceType !== "github") { + throw new Error("Preview deployments require a GitHub App service"); + } + + const existing = await tx + .select() + .from(services) + .where( + and( + eq(services.previewOfService, base.id), + eq(services.previewGitRef, input.previewGitRef), + isNull(services.deletedAt), + ), + ) + .then((rows) => rows[0]); + if (existing) { + const primaryDomain = await tx + .select({ domain: servicePorts.domain }) + .from(servicePorts) + .where( + and( + eq(servicePorts.serviceId, existing.id), + eq(servicePorts.protocol, "http"), + eq(servicePorts.isPublic, true), + ), + ) + .orderBy(servicePorts.port, servicePorts.id) + .then((rows) => rows.find((row) => row.domain)?.domain ?? null); + return { + serviceId: existing.id, + created: false, + primaryUrl: primaryDomain ? `https://${primaryDomain}` : null, + }; + } + + const [repo, ports, sourceSecrets, placements] = await Promise.all([ + tx + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, base.id)) + .then((rows) => rows[0]), + tx + .select() + .from(servicePorts) + .where(eq(servicePorts.serviceId, base.id)) + .orderBy(servicePorts.port, servicePorts.protocol, servicePorts.id), + tx + .select() + .from(secrets) + .where(eq(secrets.serviceId, base.id)) + .orderBy(secrets.key, secrets.id), + tx + .select() + .from(serviceReplicas) + .where(eq(serviceReplicas.serviceId, base.id)) + .orderBy(serviceReplicas.serverId), + ]); + if (!repo) { + throw new Error("Preview deployments require a GitHub App service"); + } + + const previewEnvironment = await ensurePreviewEnvironmentInTransaction( + tx, + base.projectId, + ); + const previewServiceId = randomUUID(); + const configuredPorts = previewPortConfiguration({ + ports, + serviceName: base.name, + serviceId: base.id, + pullRequestNumber, + domain, + }); + const primaryDomain = configuredPorts.find( + (port) => port.isPublic && port.protocol === "http", + )?.domain; + + const serviceValues = { + projectId: base.projectId, + environmentId: previewEnvironment.id, + name: `${base.name} (PR #${pullRequestNumber})`, + hostname: + primaryDomain?.split(".")[0] ?? + getDefaultServiceHostname( + `${base.name}-pr-${pullRequestNumber}`, + previewServiceId, + ), + image: `${resolveRegistryImageHost()}/${base.projectId}/${previewServiceId}:latest`, + sourceType: "github" as const, + githubRepoUrl: base.githubRepoUrl, + githubBranch: base.githubBranch, + githubRootDir: base.githubRootDir, + replicas: base.replicas, + autoscalingEnabled: base.autoscalingEnabled, + autoscalingMinReplicas: base.autoscalingMinReplicas, + autoscalingMaxReplicas: base.autoscalingMaxReplicas, + placementMode: base.placementMode, + stateful: false, + lockedServerId: base.lockedServerId, + healthCheckCmd: base.healthCheckCmd, + healthCheckInterval: base.healthCheckInterval, + healthCheckTimeout: base.healthCheckTimeout, + healthCheckRetries: base.healthCheckRetries, + healthCheckStartPeriod: base.healthCheckStartPeriod, + startCommand: base.startCommand, + resourceCpuLimit: base.resourceCpuLimit, + resourceMemoryLimitMb: base.resourceMemoryLimitMb, + serverlessEnabled: base.serverlessEnabled && primaryDomain !== undefined, + serverlessSleepAfterSeconds: base.serverlessSleepAfterSeconds, + serverlessWakeTimeoutSeconds: base.serverlessWakeTimeoutSeconds, + deploymentSchedule: null, + backupEnabled: false, + backupSchedule: null, + previewDeploymentsEnabled: false, + previewOfService: base.id, + previewGitRef: pullRequestMergeRef(pullRequestNumber), + }; + + await tx.insert(services).values({ + id: previewServiceId, + ...serviceValues, + }); + await tx.insert(servicePorts).values( + configuredPorts.map((port) => ({ + ...port, + id: randomUUID(), + serviceId: previewServiceId, + })), + ); + if (base.placementMode === "manual" && placements.length > 0) { + await tx.insert(serviceReplicas).values( + placements.map((placement) => ({ + id: randomUUID(), + serviceId: previewServiceId, + serverId: placement.serverId, + count: placement.count, + })), + ); + } + if (sourceSecrets.length > 0) { + await tx.insert(secrets).values( + sourceSecrets.map((secret) => ({ + id: randomUUID(), + serviceId: previewServiceId, + key: secret.key, + encryptedValue: secret.encryptedValue, + updatedAt: secret.updatedAt, + })), + ); + } + await tx.insert(githubRepos).values({ + id: randomUUID(), + installationId: repo.installationId, + repoId: repo.repoId, + repoFullName: repo.repoFullName, + defaultBranch: repo.defaultBranch, + serviceId: previewServiceId, + deployBranch: repo.deployBranch, + autoDeploy: false, + }); + + return { + serviceId: previewServiceId, + created: true, + primaryUrl: primaryDomain ? `https://${primaryDomain}` : null, + }; + }); +} + +export async function canDeployServiceRevision( + serviceId: string, + serviceRevisionId: string, +) { + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`); + const service = await tx + .select({ previewOfService: services.previewOfService }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!service) return false; + if (!service.previewOfService) return true; + const latest = await tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, serviceId)) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(1) + .then((rows) => rows[0]); + return latest?.id === serviceRevisionId; + }); +} + +export async function updatePreviewGitHubStatus(input: { + serviceId: string; + serviceRevisionId: string; + state: "pending" | "in_progress" | "success" | "failure" | "inactive"; + description: string; + logUrl?: string; + expectedDeploymentId?: number; +}) { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${input.serviceId}))`, + ); + const service = await tx + .select({ previewOfService: services.previewOfService }) + .from(services) + .where(and(eq(services.id, input.serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!service?.previewOfService) return null; + const latest = await tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, input.serviceId)) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(1) + .then((rows) => rows[0]); + if (latest?.id !== input.serviceRevisionId) return null; + const deploymentConditions = [ + eq(builds.serviceId, input.serviceId), + eq(builds.serviceRevisionId, input.serviceRevisionId), + isNotNull(builds.githubDeploymentId), + ]; + if (input.expectedDeploymentId !== undefined) { + deploymentConditions.push( + eq(builds.githubDeploymentId, input.expectedDeploymentId), + ); + } + const [deployment, githubRepo] = await Promise.all([ + tx + .select({ id: builds.githubDeploymentId }) + .from(builds) + .where(and(...deploymentConditions)) + .orderBy(desc(builds.createdAt), desc(builds.id)) + .limit(1) + .then((rows) => rows[0]), + tx + .select({ + installationId: githubRepos.installationId, + repoFullName: githubRepos.repoFullName, + }) + .from(githubRepos) + .where(eq(githubRepos.serviceId, service.previewOfService)) + .then((rows) => rows[0]), + ]); + if (!deployment?.id || !githubRepo) return null; + const primary = await tx + .select({ + id: servicePorts.id, + port: servicePorts.port, + domain: servicePorts.domain, + }) + .from(servicePorts) + .where( + and( + eq(servicePorts.serviceId, input.serviceId), + eq(servicePorts.protocol, "http"), + eq(servicePorts.isPublic, true), + ), + ) + .then( + (ports) => + ports + .filter((port) => port.domain) + .sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0], + ); + await updateGitHubDeploymentStatus( + githubRepo.installationId, + githubRepo.repoFullName, + deployment.id, + input.state, + { + description: input.description.substring(0, 140), + logUrl: input.logUrl, + environmentUrl: primary?.domain + ? `https://${primary.domain}` + : undefined, + }, + ); + return true; + }); +} + +export async function createPreviewGitHubDeployment(input: { + serviceId: string; + serviceRevisionId: string; + commitSha: string; +}) { + const preview = await db + .select({ + previewOfService: services.previewOfService, + previewGitRef: services.previewGitRef, + }) + .from(services) + .where(and(eq(services.id, input.serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!preview?.previewOfService || !preview.previewGitRef) return null; + const existing = await db + .select({ id: builds.githubDeploymentId }) + .from(builds) + .where( + and( + eq(builds.serviceId, input.serviceId), + eq(builds.serviceRevisionId, input.serviceRevisionId), + isNotNull(builds.githubDeploymentId), + ), + ) + .limit(1) + .then((rows) => rows[0]?.id ?? null); + if (existing) return existing; + const githubRepo = await db + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, preview.previewOfService)) + .then((rows) => rows[0]); + if (!githubRepo) return null; + const pullRequestNumber = pullRequestNumberFromMergeRef( + preview.previewGitRef, + ); + const environment = `preview/pr-${pullRequestNumber}-${preview.previewOfService.slice(0, 8)}`; + const payload = { + baseServiceId: preview.previewOfService, + previewServiceId: input.serviceId, + previewGitRef: preview.previewGitRef, + serviceRevisionId: input.serviceRevisionId, + }; + const deploymentId = + (await findGitHubDeployment( + githubRepo.installationId, + githubRepo.repoFullName, + input.commitSha, + environment, + payload, + )) ?? + (await createGitHubDeployment( + githubRepo.installationId, + githubRepo.repoFullName, + input.commitSha, + environment, + `Preview PR #${pullRequestNumber}`, + { + transientEnvironment: true, + productionEnvironment: false, + payload, + }, + )); + const updated = await db + .update(builds) + .set({ githubDeploymentId: deploymentId }) + .where( + and( + eq(builds.serviceId, input.serviceId), + eq(builds.serviceRevisionId, input.serviceRevisionId), + isNull(builds.githubDeploymentId), + ), + ) + .returning({ id: builds.id }); + const current = + updated.length > 0 && + (await updatePreviewGitHubStatus({ + serviceId: input.serviceId, + serviceRevisionId: input.serviceRevisionId, + expectedDeploymentId: deploymentId, + state: "pending", + description: "Preview build queued", + })); + if (!current) { + await updateGitHubDeploymentStatus( + githubRepo.installationId, + githubRepo.repoFullName, + deploymentId, + "inactive", + { description: "Preview was superseded or removed" }, + ); + } + return deploymentId; +} + +export async function inactivatePreviewGitHubDeployments(input: { + serviceId: string; + description: string; + excludeServiceRevisionId?: string; +}) { + const service = await db + .select({ previewOfService: services.previewOfService }) + .from(services) + .where(eq(services.id, input.serviceId)) + .then((rows) => rows[0]); + if (!service?.previewOfService) return 0; + const githubRepo = await db + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, service.previewOfService)) + .then((rows) => rows[0]); + if (!githubRepo) return 0; + const conditions = [ + eq(builds.serviceId, input.serviceId), + isNotNull(builds.githubDeploymentId), + ]; + if (input.excludeServiceRevisionId) { + conditions.push( + ne(builds.serviceRevisionId, input.excludeServiceRevisionId), + ); + } + const deploymentIds = await db + .selectDistinct({ id: builds.githubDeploymentId }) + .from(builds) + .where(and(...conditions)); + await Promise.all( + deploymentIds.flatMap(({ id }) => + id + ? [ + updateGitHubDeploymentStatus( + githubRepo.installationId, + githubRepo.repoFullName, + id, + "inactive", + { description: input.description.substring(0, 140) }, + ), + ] + : [], + ), + ); + return deploymentIds.length; +} diff --git a/web/lib/preview-lifecycle.ts b/web/lib/preview-lifecycle.ts new file mode 100644 index 00000000..22528603 --- /dev/null +++ b/web/lib/preview-lifecycle.ts @@ -0,0 +1,293 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { + builds, + deploymentPorts, + deployments, + githubRepos, + rollouts, + services, +} from "@/db/schema"; +import { markDeploymentRemoved } from "@/lib/deployment-status"; +import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; +import { inactivatePreviewGitHubDeployments } from "@/lib/preview-deployments"; +import { + cleanupRegistryArtifactsForService, + prepareRegistryArtifactCleanup, +} from "@/lib/registry-retention"; +import { pullRequestNumberFromMergeRef } from "@/lib/service-revision-spec"; +import { + enqueueReconcileForAllOnlineServers, + enqueueWork, +} from "@/lib/work-queue"; + +const activeBuildStatuses = [ + "pending", + "claimed", + "cloning", + "building", + "pushing", +] as const; + +async function cancelBuildRows(serviceId: string, serviceRevisionId?: string) { + const conditions = [ + eq(builds.serviceId, serviceId), + inArray(builds.status, [...activeBuildStatuses]), + ]; + if (serviceRevisionId) { + conditions.push(eq(builds.serviceRevisionId, serviceRevisionId)); + } + const cancelled = await db + .update(builds) + .set({ status: "cancelled", completedAt: new Date() }) + .where(and(...conditions)) + .returning({ buildGroupId: builds.buildGroupId }); + for (const buildGroupId of new Set( + cancelled.map((row) => row.buildGroupId), + )) { + await inngest.send( + inngestEvents.buildCancelled.create( + { buildId: `preview-${buildGroupId}`, buildGroupId }, + { id: `preview-build-cancelled-${buildGroupId}` }, + ), + ); + } +} + +async function cancelRolloutRows( + serviceId: string, + serviceRevisionId?: string, +) { + const { cancelled, rolloutDeployments } = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`); + const conditions = [ + eq(rollouts.serviceId, serviceId), + inArray(rollouts.status, ["queued", "in_progress"]), + ]; + if (serviceRevisionId) { + conditions.push(eq(rollouts.serviceRevisionId, serviceRevisionId)); + } + const cancelled = await tx + .update(rollouts) + .set({ + status: "failed", + currentStage: "superseded", + completedAt: new Date(), + }) + .where(and(...conditions)) + .returning({ id: rollouts.id }); + if (cancelled.length === 0) { + return { cancelled, rolloutDeployments: [] }; + } + + const rolloutIds = cancelled.map(({ id }) => id); + const rolloutDeployments = await tx + .select() + .from(deployments) + .where(inArray(deployments.rolloutId, rolloutIds)); + if ( + rolloutDeployments.some( + (deployment) => deployment.trafficState === "active", + ) + ) { + await tx + .update(deployments) + .set({ trafficState: "active" }) + .where( + and( + eq(deployments.serviceId, serviceId), + eq(deployments.trafficState, "draining"), + ), + ); + } + await tx + .update(deployments) + .set(markDeploymentRemoved()) + .where(inArray(deployments.rolloutId, rolloutIds)); + await enqueueReconcileForAllOnlineServers("preview_rollout_cancelled", tx); + return { cancelled, rolloutDeployments }; + }); + if (cancelled.length === 0) return; + + for (const deployment of rolloutDeployments) { + if (!deployment.containerId) continue; + await enqueueWork(deployment.serverId, "stop", { + deploymentId: deployment.id, + containerId: deployment.containerId, + }); + } + for (const { id } of cancelled) { + await inngest.send( + inngestEvents.rolloutCancelled.create( + { rolloutId: id }, + { id: `preview-rollout-cancelled-${id}` }, + ), + ); + } +} + +export async function cancelPreviewRevisionWork( + serviceId: string, + serviceRevisionId: string, +) { + await Promise.all([ + cancelBuildRows(serviceId, serviceRevisionId), + cancelRolloutRows(serviceId, serviceRevisionId), + ]); +} + +export async function deletePreviewService( + baseServiceId: string, + previewGitRef: string, + reason = "removed", + options: { reportGitHubDeployment?: boolean } = {}, +) { + pullRequestNumberFromMergeRef(previewGitRef); + const claimed = await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`, + ); + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`, + ); + const context = await tx + .select({ service: services }) + .from(services) + .where( + and( + eq(services.previewOfService, baseServiceId), + eq(services.previewGitRef, previewGitRef), + ), + ) + .then((rows) => rows[0]); + if (!context) return null; + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${context.service.id}))`, + ); + if (!(await prepareRegistryArtifactCleanup(tx, context.service.id))) { + throw new Error( + "Preview deletion deferred while registry manifest work is processing", + ); + } + await tx + .update(services) + .set({ + deletedAt: new Date(), + purgeAfter: null, + deletionStatus: "deleting", + }) + .where(eq(services.id, context.service.id)); + return context; + }); + if (!claimed) return null; + + await Promise.all([ + cancelBuildRows(claimed.service.id), + cancelRolloutRows(claimed.service.id), + ]); + const runtime = await db + .select() + .from(deployments) + .where(eq(deployments.serviceId, claimed.service.id)); + await Promise.all( + runtime.flatMap((deployment) => + deployment.containerId + ? [ + enqueueWork(deployment.serverId, "stop", { + deploymentId: deployment.id, + containerId: deployment.containerId, + }), + ] + : [], + ), + ); + if (runtime.length > 0) { + await db.delete(deploymentPorts).where( + inArray( + deploymentPorts.deploymentId, + runtime.map((deployment) => deployment.id), + ), + ); + } + await db + .delete(deployments) + .where(eq(deployments.serviceId, claimed.service.id)); + await db.transaction((tx) => + enqueueReconcileForAllOnlineServers("preview_deleted", tx), + ); + await cleanupRegistryArtifactsForService(claimed.service.id); + if (options.reportGitHubDeployment !== false) { + try { + await inactivatePreviewGitHubDeployments({ + serviceId: claimed.service.id, + description: `Preview removed: ${reason}`, + }); + } catch (error) { + console.error( + `[preview-lifecycle] failed to inactivate GitHub deployments for ${claimed.service.id}:`, + error, + ); + } + } + await db.delete(services).where(eq(services.id, claimed.service.id)); + return claimed; +} + +export async function deletePreviewsForBaseService( + baseServiceId: string, + reason: string, + options: { reportGitHubDeployment?: boolean } = {}, +) { + const previews = await db + .select({ service: services }) + .from(services) + .where(eq(services.previewOfService, baseServiceId)); + for (const preview of previews) { + const previewGitRef = preview.service.previewGitRef; + if (!previewGitRef) continue; + await deletePreviewService(baseServiceId, previewGitRef, reason, options); + } +} + +export async function deletePreviewsForGitHubInstallation( + installationId: number, + reason: string, + options: { + removeRepositoryLinks?: boolean; + } = {}, +) { + const baseServiceIds = await db.transaction(async (tx) => { + const ids = await tx + .select({ id: services.id }) + .from(services) + .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id)) + .where( + and( + eq(githubRepos.installationId, installationId), + isNull(services.previewOfService), + ), + ) + .then((rows) => rows.map(({ id }) => id).sort()); + for (const id of ids) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${id}))`); + } + if (ids.length > 0) { + await tx + .update(services) + .set({ previewDeploymentsEnabled: false }) + .where(inArray(services.id, ids)); + } + if (options.removeRepositoryLinks) { + await tx + .delete(githubRepos) + .where(eq(githubRepos.installationId, installationId)); + } + return ids; + }); + for (const baseServiceId of baseServiceIds) { + await deletePreviewsForBaseService(baseServiceId, reason, { + reportGitHubDeployment: false, + }); + } +} diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts index 343dd508..6544ea93 100644 --- a/web/lib/service-revision-spec.ts +++ b/web/lib/service-revision-spec.ts @@ -1,5 +1,22 @@ export const SERVICE_REVISION_SCHEMA_VERSION = 3 as const; +export function pullRequestMergeRef(pullRequestNumber: number): string { + if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) { + throw new Error("Invalid pull request number"); + } + return `refs/pull/${pullRequestNumber}/merge`; +} + +export function pullRequestNumberFromMergeRef(gitRef: string): number { + const match = /^refs\/pull\/([1-9]\d*)\/merge$/.exec(gitRef); + if (!match) throw new Error("Invalid pull request merge ref"); + const pullRequestNumber = Number(match[1]); + if (!Number.isSafeInteger(pullRequestNumber)) { + throw new Error("Invalid pull request merge ref"); + } + return pullRequestNumber; +} + export function getDefaultServiceHostname( name: string, serviceId: string, diff --git a/web/lib/service-revisions.ts b/web/lib/service-revisions.ts index 4af8aba7..1c627083 100644 --- a/web/lib/service-revisions.ts +++ b/web/lib/service-revisions.ts @@ -585,7 +585,7 @@ export async function createRolloutForServiceRevision( ) { return db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`); - const [revision, activeService] = await Promise.all([ + const [revision, activeService, latestRevision] = await Promise.all([ tx .select() .from(serviceRevisions) @@ -597,15 +597,31 @@ export async function createRolloutForServiceRevision( ) .then((rows) => rows[0]), tx - .select({ id: services.id }) + .select({ + id: services.id, + previewOfService: services.previewOfService, + }) .from(services) .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) .then((rows) => rows[0]), + tx + .select({ id: serviceRevisions.id }) + .from(serviceRevisions) + .where(eq(serviceRevisions.serviceId, serviceId)) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(1) + .then((rows) => rows[0]), ]); if (!revision) throw new Error("Service revision not found"); if (!activeService) { return { rolloutId: null, revision, created: false }; } + if ( + activeService.previewOfService && + latestRevision?.id !== serviceRevisionId + ) { + return { rolloutId: null, revision, created: false }; + } const specification = parseServiceRevisionSpec(revision.specification); if (specification.source.type === "github" && revision.artifactDeletedAt) { diff --git a/web/lib/trigger-build.ts b/web/lib/trigger-build.ts index 06942fbb..0f81ad93 100644 --- a/web/lib/trigger-build.ts +++ b/web/lib/trigger-build.ts @@ -17,7 +17,7 @@ import { createGitHubBuildServiceRevision, } from "@/lib/service-revisions"; -type BuildTrigger = "manual" | "scheduled" | "push"; +type BuildTrigger = "manual" | "scheduled" | "push" | "preview"; const fullCommitSha = /^[0-9a-f]{40}$/i; type ResolvedBuildInput = { @@ -28,6 +28,7 @@ type ResolvedBuildInput = { actor: ServiceRevisionActor; expectedRepository?: string; expectedBranch?: string; + gitRef?: string; githubDeploymentId?: number; idempotencyKey?: string; }; @@ -106,6 +107,13 @@ async function queueResolvedBuild( ) { throw new Error("GitHub source changed before the build was queued"); } + if ( + service.previewOfService + ? !service.previewGitRef || input.gitRef !== service.previewGitRef + : input.gitRef !== undefined + ) { + throw new Error("Build Git ref does not match the service"); + } const registryHost = resolveRegistryImageHost(); const serviceRevisionId = input.idempotencyKey @@ -133,6 +141,7 @@ async function queueResolvedBuild( commitSha, commitMessage: input.commitMessage.substring(0, 500), branch: expectedBranch, + gitRef: input.gitRef, author: input.author, actor: input.actor, githubDeploymentId: input.githubDeploymentId, @@ -184,6 +193,26 @@ export async function triggerBuildInternal( actor: ServiceRevisionActor, ) { const sourceContext = await getGitHubBuildSource(serviceId); + if ( + sourceContext.service.previewOfService && + sourceContext.service.previewGitRef + ) { + await inngest.send( + inngestEvents.previewSyncRequested.create( + { + baseServiceId: sourceContext.service.previewOfService, + previewGitRef: sourceContext.service.previewGitRef, + force: true, + }, + { id: `preview-user-sync:${serviceId}:${randomUUID()}` }, + ), + ); + return { + buildId: null, + serviceRevisionId: null, + status: "queued" as const, + }; + } const { repo, source } = sourceContext; const repoFullName = repo?.repoFullName ?? diff --git a/web/tests/build-claim-route.test.ts b/web/tests/build-claim-route.test.ts index 3a68a2bf..73e6c2f4 100644 --- a/web/tests/build-claim-route.test.ts +++ b/web/tests/build-claim-route.test.ts @@ -103,4 +103,63 @@ describe("agent build claim", () => { }); expect(mocks.send).not.toHaveBeenCalled(); }); + + it("returns the preview service's exact pull request ref", async () => { + mocks.updateResults.push([build]); + mocks.selectResults.push( + [ + { + id: "service-1", + projectId: "project-1", + previewGitRef: "refs/pull/42/merge", + }, + ], + [ + { + specification: { + schemaVersion: 3, + image: "registry.example.com/project/service:revision-1", + source: { + type: "github", + repository: "https://github.com/acme/app", + repositoryId: null, + branch: "main", + commitSha: build.commitSha, + rootDir: null, + authentication: { type: "anonymous" }, + }, + hostname: "service-1", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placement: { mode: "manual" }, + placements: [], + ports: [], + secrets: [], + volumes: [], + }, + }, + ], + ); + + const response = await POST( + new Request("http://localhost/api/v1/agent/builds/build-amd64", { + method: "POST", + }) as NextRequest, + { params: Promise.resolve({ id: "build-amd64" }) }, + ); + + expect(response.status).toBe(200); + expect((await response.json()).build).toMatchObject({ + commitSha: build.commitSha, + branch: "main", + gitRef: "refs/pull/42/merge", + }); + }); }); diff --git a/web/tests/build-status-route.test.ts b/web/tests/build-status-route.test.ts index b568207b..414afb2f 100644 --- a/web/tests/build-status-route.test.ts +++ b/web/tests/build-status-route.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => { from: vi.fn(() => query), innerJoin: vi.fn(() => query), where: vi.fn(() => query), + orderBy: vi.fn(() => query), limit: vi.fn(() => query), // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. then: ( @@ -49,6 +50,7 @@ const mocks = vi.hoisted(() => { enqueueWork: vi.fn(), send: vi.fn(), updateGitHubDeploymentStatus: vi.fn(), + updatePreviewGitHubStatus: vi.fn(), notify: vi.fn(), createBuildCompleted: vi.fn((data, options) => ({ name: "build/completed", @@ -66,6 +68,9 @@ vi.mock("@/lib/notifications", () => ({ notify: mocks.notify })); vi.mock("@/lib/github", () => ({ updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus, })); +vi.mock("@/lib/preview-deployments", () => ({ + updatePreviewGitHubStatus: mocks.updatePreviewGitHubStatus, +})); vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork })); vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } })); vi.mock("@/lib/inngest/events", () => ({ @@ -158,6 +163,7 @@ describe("agent build status transitions", () => { mocks.enqueueWork.mockResolvedValue(undefined); mocks.send.mockResolvedValue(undefined); mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined); + mocks.updatePreviewGitHubStatus.mockResolvedValue(true); mocks.notify.mockResolvedValue(undefined); }); @@ -286,6 +292,56 @@ describe("agent build status transitions", () => { ); }); + it("keeps a completed preview build in progress until rollout readiness", async () => { + const completedBuild = build("completed", { + githubDeploymentId: 456, + imageUri: amd64Image, + }); + const previewSpecification = { + ...specification, + source: { + ...specification.source, + authentication: { type: "github_app" as const, installationId: 123 }, + }, + }; + mocks.selectResults.push( + [ + build("pushing", { + githubDeploymentId: 456, + }), + ], + [ + { + specification: previewSpecification, + projectSlug: "cloud", + environmentName: "production", + previewOfService: "base-service", + }, + ], + [completedBuild], + [ + { + id: "service-1", + previewOfService: "base-service", + }, + ], + [{ id: "revision-1" }], + ); + mocks.updateResults.push([completedBuild]); + + expect((await post("completed")).status).toBe(200); + expect(mocks.updatePreviewGitHubStatus).toHaveBeenCalledWith({ + serviceId: "service-1", + serviceRevisionId: "revision-1", + expectedDeploymentId: 456, + state: "in_progress", + description: "Preview image built; preparing deployment", + logUrl: + "https://cloud.techulus.com/dashboard/projects/cloud/production/services/service-1/builds/build-amd64", + }); + expect(mocks.updateGitHubDeploymentStatus).not.toHaveBeenCalled(); + }); + it("does not enqueue manifest work after the service is deleted", async () => { const completedBuild = build("completed", { imageUri: amd64Image }); mocks.selectResults.push( diff --git a/web/tests/build-trigger-workflow.test.ts b/web/tests/build-trigger-workflow.test.ts index c7f25687..fdc5a9db 100644 --- a/web/tests/build-trigger-workflow.test.ts +++ b/web/tests/build-trigger-workflow.test.ts @@ -4,36 +4,81 @@ const mocks = vi.hoisted(() => ({ values: vi.fn(), onConflictDoNothing: vi.fn(), returning: vi.fn(), + set: vi.fn(), + updateWhere: vi.fn(), revisionRows: [] as unknown[], + transactionSelectResults: [] as unknown[][], + execute: vi.fn(), getTargetPlatformsForRevision: vi.fn(), selectBuildServerForRevision: vi.fn(), enqueueWork: vi.fn(), + createPreviewGitHubDeployment: vi.fn(), send: vi.fn(), createBuildStarted: vi.fn((data) => ({ name: "build/started", data })), })); vi.mock("@/db", () => ({ - db: { - insert: vi.fn(() => ({ values: mocks.values })), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve(mocks.revisionRows)), - })), - })), - }, + db: (() => { + function query(rows: unknown[]) { + const query = { + from: vi.fn(() => query), + innerJoin: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + limit: vi.fn(() => query), + for: vi.fn(() => query), + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. + then: ( + resolve: (rows: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(rows).then(resolve, reject), + }; + return query; + } + const tx = { + execute: mocks.execute, + insert: vi.fn(() => ({ values: mocks.values })), + update: vi.fn(() => ({ set: mocks.set })), + select: vi.fn(() => query(mocks.transactionSelectResults.shift() ?? [])), + }; + return { + select: vi.fn(() => query(mocks.revisionRows)), + transaction: vi.fn((operation: (transaction: typeof tx) => unknown) => + operation(tx), + ), + }; + })(), })); vi.mock("@/db/schema", () => ({ - builds: { id: "id" }, + builds: { + id: "id", + serviceId: "service_id", + serviceRevisionId: "service_revision_id", + commitSha: "commit_sha", + branch: "branch", + targetPlatform: "target_platform", + buildGroupId: "build_group_id", + status: "status", + }, + services: { + id: "id", + deletedAt: "deleted_at", + previewGitRef: "preview_git_ref", + }, serviceRevisions: { id: "id", serviceId: "service_id", specification: "specification", + createdAt: "created_at", }, })); vi.mock("@/lib/build-assignment", () => ({ getTargetPlatformsForRevision: mocks.getTargetPlatformsForRevision, selectBuildServerForRevision: mocks.selectBuildServerForRevision, })); +vi.mock("@/lib/preview-deployments", () => ({ + createPreviewGitHubDeployment: mocks.createPreviewGitHubDeployment, +})); vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork })); vi.mock("@/lib/inngest/client", () => ({ inngest: { @@ -54,7 +99,7 @@ import { buildTriggerWorkflow } from "@/lib/inngest/functions/build-trigger-work const exactSha = "0123456789ABCDEF0123456789ABCDEF01234567"; -function invoke(commitSha: string) { +function invoke(commitSha: string, gitRef?: string) { const step = { run: vi.fn(async (_name: string, operation: () => Promise) => operation(), @@ -70,10 +115,11 @@ function invoke(commitSha: string) { serviceId: "service-1", serviceRevisionId: "revision-1", buildRequestId: "request-1", - trigger: "manual", + trigger: gitRef ? "preview" : "manual", commitSha, commitMessage: "Exact source commit", branch: "main", + gitRef, author: "octocat", actor: { type: "system" }, }, @@ -86,7 +132,9 @@ describe("build trigger fan-out", () => { beforeEach(() => { vi.clearAllMocks(); mocks.revisionRows.length = 0; + mocks.transactionSelectResults.length = 0; mocks.revisionRows.push({ + previewGitRef: null, specification: { schemaVersion: 2, image: "registry.example.com/service-1:revision-1", @@ -119,6 +167,7 @@ describe("build trigger fan-out", () => { onConflictDoNothing: mocks.onConflictDoNothing, }); mocks.onConflictDoNothing.mockReturnValue({ returning: mocks.returning }); + mocks.set.mockReturnValue({ where: mocks.updateWhere }); mocks.returning.mockResolvedValue([{ id: "build-1" }, { id: "build-2" }]); mocks.getTargetPlatformsForRevision.mockResolvedValue([ "linux/amd64", @@ -128,6 +177,10 @@ describe("build trigger fan-out", () => { }); it("persists one immutable commit for every target platform", async () => { + mocks.transactionSelectResults.push([ + { id: "build-1", status: "pending" }, + { id: "build-2", status: "pending" }, + ]); await invoke(exactSha); expect(mocks.values).toHaveBeenCalledTimes(1); @@ -159,6 +212,73 @@ describe("build trigger fan-out", () => { expect(mocks.enqueueWork).toHaveBeenCalledTimes(2); }); + it("does not persist work for a superseded preview revision", async () => { + const previewGitRef = "refs/pull/42/merge"; + (mocks.revisionRows[0] as Record).previewGitRef = + previewGitRef; + mocks.transactionSelectResults.push( + [{ id: "service-1" }], + [{ id: "newer-revision" }], + ); + + await expect(invoke(exactSha, previewGitRef)).resolves.toMatchObject({ + status: "cancelled", + reason: "superseded_preview_revision", + }); + expect(mocks.values).not.toHaveBeenCalled(); + expect(mocks.enqueueWork).not.toHaveBeenCalled(); + expect(mocks.createPreviewGitHubDeployment).not.toHaveBeenCalled(); + }); + + it("cancels a preview superseded before agent work is enqueued", async () => { + const previewGitRef = "refs/pull/42/merge"; + (mocks.revisionRows[0] as Record).previewGitRef = + previewGitRef; + mocks.transactionSelectResults.push( + [{ id: "service-1" }], + [{ id: "revision-1" }], + [{ id: "service-1" }], + [{ id: "newer-revision" }], + ); + + await expect(invoke(exactSha, previewGitRef)).resolves.toMatchObject({ + status: "cancelled", + reason: "superseded_preview_revision", + }); + expect(mocks.values).toHaveBeenCalled(); + expect(mocks.set).toHaveBeenCalledWith( + expect.objectContaining({ status: "cancelled" }), + ); + expect(mocks.enqueueWork).not.toHaveBeenCalled(); + expect(mocks.createBuildStarted).not.toHaveBeenCalled(); + }); + + it("does not enqueue agent work after a build is cancelled", async () => { + const previewGitRef = "refs/pull/42/merge"; + (mocks.revisionRows[0] as Record).previewGitRef = + previewGitRef; + mocks.transactionSelectResults.push( + [{ id: "service-1" }], + [{ id: "revision-1" }], + [{ id: "service-1" }], + [{ id: "revision-1" }], + [ + { id: "build-1", status: "cancelled" }, + { id: "build-2", status: "pending" }, + ], + ); + + await expect(invoke(exactSha, previewGitRef)).resolves.toMatchObject({ + status: "cancelled", + reason: "build_cancelled_before_enqueue", + }); + expect(mocks.set).toHaveBeenCalledWith( + expect.objectContaining({ status: "cancelled" }), + ); + expect(mocks.enqueueWork).not.toHaveBeenCalled(); + expect(mocks.createBuildStarted).not.toHaveBeenCalled(); + }); + it("rejects a moving ref before creating any platform build", async () => { await expect(invoke("HEAD")).rejects.toThrow( "Build fan-out requires a full 40-character commit SHA", diff --git a/web/tests/build-workflow.test.ts b/web/tests/build-workflow.test.ts index 6de1c9e9..d2a4a174 100644 --- a/web/tests/build-workflow.test.ts +++ b/web/tests/build-workflow.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => { queryResults, select: vi.fn(() => query(queryResults.shift() ?? [])), deployServiceRevisionInternal: vi.fn(), + updatePreviewGitHubStatus: vi.fn(), }; }); @@ -25,6 +26,9 @@ vi.mock("@/db", () => ({ db: { select: mocks.select } })); vi.mock("@/lib/deploy-service", () => ({ deployServiceRevisionInternal: mocks.deployServiceRevisionInternal, })); +vi.mock("@/lib/preview-deployments", () => ({ + updatePreviewGitHubStatus: mocks.updatePreviewGitHubStatus, +})); vi.mock("@/lib/inngest/client", () => ({ inngest: { createFunction: vi.fn( @@ -122,6 +126,7 @@ describe("revision-first build completion", () => { rolloutId: "rollout-1", created: true, }); + mocks.updatePreviewGitHubStatus.mockResolvedValue(false); }); it("deploys each out-of-order build using its own immutable revision", async () => { @@ -162,6 +167,12 @@ describe("revision-first build completion", () => { buildGroupId: "group-failed", }); expect(mocks.deployServiceRevisionInternal).not.toHaveBeenCalled(); + expect(mocks.updatePreviewGitHubStatus).toHaveBeenCalledWith({ + serviceId: "service-1", + serviceRevisionId: "revision-failed", + state: "failure", + description: "Preview build failed", + }); }); it("uses persisted completion when the build event was missed", async () => { diff --git a/web/tests/github-webhook.test.ts b/web/tests/github-webhook.test.ts index 8e9fc32e..d3f06ce2 100644 --- a/web/tests/github-webhook.test.ts +++ b/web/tests/github-webhook.test.ts @@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => { updateGitHubDeploymentStatus: vi.fn(), send: vi.fn(), createBuildTrigger: vi.fn(), + createPreviewSync: vi.fn(), + createPreviewClose: vi.fn(), triggerResolvedBuildInternal: vi.fn(), }; }); @@ -44,6 +46,8 @@ vi.mock("@/lib/inngest/client", () => ({ vi.mock("@/lib/inngest/events", () => ({ inngestEvents: { buildTrigger: { create: mocks.createBuildTrigger }, + previewSyncRequested: { create: mocks.createPreviewSync }, + previewCloseRequested: { create: mocks.createPreviewClose }, }, })); vi.mock("@/lib/trigger-build", () => ({ @@ -65,6 +69,10 @@ function linkedService({ projectName = "Cloud", projectSlug = "cloud", environmentName = "production", + previewDeploymentsEnabled = false, + previewOfService = null, + previewGitRef = null, + stateful = false, }: { serviceId: string; name?: string; @@ -76,6 +84,10 @@ function linkedService({ projectName?: string; projectSlug?: string; environmentName?: string; + previewDeploymentsEnabled?: boolean; + previewOfService?: string | null; + previewGitRef?: string | null; + stateful?: boolean; }) { return { githubRepo: { @@ -95,6 +107,10 @@ function linkedService({ sourceType, deletedAt, githubRootDir: rootDir, + previewDeploymentsEnabled, + previewOfService, + previewGitRef, + stateful, }, project: { id: "project-1", name: projectName, slug: projectSlug }, environment: { id: "environment-1", name: environmentName }, @@ -126,6 +142,45 @@ function pushRequest(branch = "main") { }); } +function pullRequest( + action: string, + options: { + draft?: boolean; + merged?: boolean; + headRepoId?: number; + baseBranch?: string; + } = {}, +) { + return new NextRequest("http://localhost/api/webhooks/github", { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "pull_request", + "x-github-delivery": `delivery-${action}`, + "x-hub-signature-256": "sha256=test", + }, + body: JSON.stringify({ + action, + number: 42, + repository: { id: 456, full_name: "techulus/cloud" }, + pull_request: { + draft: options.draft ?? false, + merged: options.merged ?? false, + base: { + ref: options.baseBranch ?? "main", + repo: { id: 456, full_name: "techulus/cloud" }, + }, + head: { + repo: { + id: options.headRepoId ?? 456, + full_name: "techulus/cloud", + }, + }, + }, + }), + }); +} + describe("GitHub push webhook", () => { beforeEach(() => { mocks.queryResults.length = 0; @@ -144,6 +199,18 @@ describe("GitHub push webhook", () => { data, ...options, })); + mocks.createPreviewSync.mockReset(); + mocks.createPreviewSync.mockImplementation((data, options) => ({ + name: "preview/sync-requested", + data, + ...options, + })); + mocks.createPreviewClose.mockReset(); + mocks.createPreviewClose.mockImplementation((data, options) => ({ + name: "preview/close-requested", + data, + ...options, + })); mocks.triggerResolvedBuildInternal.mockReset(); mocks.triggerResolvedBuildInternal.mockResolvedValue({ status: "queued" }); }); @@ -350,3 +417,156 @@ describe("GitHub push webhook", () => { ); }); }); + +describe("GitHub pull request webhook", () => { + beforeEach(() => { + mocks.queryResults.length = 0; + mocks.verifyWebhookSignature.mockReturnValue(true); + mocks.send.mockReset(); + mocks.send.mockResolvedValue(undefined); + mocks.createPreviewSync.mockImplementation((data, options) => ({ + name: "preview/sync-requested", + data, + ...options, + })); + mocks.createPreviewClose.mockImplementation((data, options) => ({ + name: "preview/close-requested", + data, + ...options, + })); + }); + + it("queues one durable sync per eligible enabled base service", async () => { + mocks.queryResults.push([ + linkedService({ + serviceId: "service-a", + previewDeploymentsEnabled: true, + }), + linkedService({ + serviceId: "service-b", + previewDeploymentsEnabled: true, + }), + linkedService({ serviceId: "service-disabled" }), + linkedService({ + serviceId: "service-stateful", + previewDeploymentsEnabled: true, + stateful: true, + }), + ]); + + const response = await POST(pullRequest("opened")); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, queued: 4 }); + expect(mocks.send).toHaveBeenCalledWith([ + expect.objectContaining({ + name: "preview/sync-requested", + data: { + baseServiceId: "service-a", + previewGitRef: "refs/pull/42/merge", + }, + }), + expect.objectContaining({ + name: "preview/sync-requested", + data: { + baseServiceId: "service-b", + previewGitRef: "refs/pull/42/merge", + }, + }), + expect.objectContaining({ + name: "preview/close-requested", + data: expect.objectContaining({ + baseServiceId: "service-disabled", + previewGitRef: "refs/pull/42/merge", + }), + }), + expect.objectContaining({ + name: "preview/close-requested", + data: expect.objectContaining({ + baseServiceId: "service-stateful", + previewGitRef: "refs/pull/42/merge", + }), + }), + ]); + }); + + it("closes an existing preview when the pull request changes base branch", async () => { + mocks.queryResults.push([ + linkedService({ + serviceId: "service-a", + previewDeploymentsEnabled: true, + }), + ]); + + const response = await POST( + pullRequest("edited", { baseBranch: "release" }), + ); + + expect(response.status).toBe(200); + expect(mocks.send).toHaveBeenCalledWith([ + expect.objectContaining({ + name: "preview/close-requested", + data: { + baseServiceId: "service-a", + previewGitRef: "refs/pull/42/merge", + reason: "pull_request_ineligible", + verifyWithGitHub: true, + }, + }), + ]); + }); + + it.each([ + ["draft", { draft: true }], + ["fork", { headRepoId: 999 }], + ])( + "queues teardown instead of deploying a %s pull request", + async (_case, options) => { + mocks.queryResults.push([ + linkedService({ + serviceId: "service-a", + previewDeploymentsEnabled: true, + }), + ]); + + const response = await POST(pullRequest("opened", options)); + + expect(response.status).toBe(200); + expect(mocks.send).toHaveBeenCalledWith([ + expect.objectContaining({ + name: "preview/close-requested", + data: expect.objectContaining({ + baseServiceId: "service-a", + previewGitRef: "refs/pull/42/merge", + }), + }), + ]); + }, + ); + + it.each([ + ["closed", true, "pull_request_merged"], + ["closed", false, "pull_request_closed"], + ["converted_to_draft", false, "converted_to_draft"], + ])( + "queues teardown for %s even before a clone exists", + async (action, merged, reason) => { + mocks.queryResults.push([linkedService({ serviceId: "service-a" })]); + + const response = await POST(pullRequest(action, { merged })); + + expect(response.status).toBe(200); + expect(mocks.send).toHaveBeenCalledWith([ + expect.objectContaining({ + name: "preview/close-requested", + data: { + baseServiceId: "service-a", + previewGitRef: "refs/pull/42/merge", + reason, + verifyWithGitHub: true, + }, + }), + ]); + }, + ); +}); diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts index 4a2436b5..47b793d2 100644 --- a/web/tests/github.test.ts +++ b/web/tests/github.test.ts @@ -1,10 +1,32 @@ +import { generateKeyPairSync } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { isFullCommitSha, resolveGitHubCommit } from "@/lib/github"; +import { + findGitHubDeployment, + getGitHubPullRequest, + GitHubApiError, + isFullCommitSha, + resolveGitHubCommit, + resolveGitHubPullRequestMergeRef, +} from "@/lib/github"; afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); +function configureGitHubApp() { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + vi.stubEnv("GITHUB_APP_ID", "123"); + vi.stubEnv( + "GITHUB_APP_PRIVATE_KEY", + Buffer.from(privateKey).toString("base64"), + ); +} + describe("GitHub commit SHA validation", () => { it("accepts only full hexadecimal commit SHAs", () => { expect(isFullCommitSha("0123456789abcdef0123456789abcdef01234567")).toBe( @@ -60,3 +82,87 @@ describe("public GitHub branch resolution", () => { ); }); }); + +describe("GitHub pull request deployment helpers", () => { + it("exposes pull request response status for lifecycle decisions", async () => { + configureGitHubApp(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json({ token: "installation-token" })) + .mockResolvedValueOnce(new Response("Not Found", { status: 404 })), + ); + + const request = getGitHubPullRequest(10, "acme/app", 42); + await expect(request).rejects.toBeInstanceOf(GitHubApiError); + await expect(request).rejects.toMatchObject({ status: 404 }); + }); + + it("finds a deployment created for the same preview revision", async () => { + configureGitHubApp(); + const fetchMock = vi.fn( + async (input: string | URL | Request, _init?: RequestInit) => { + const url = String(input); + if (url.includes("/access_tokens")) { + return Response.json({ token: "installation-token" }); + } + return Response.json([ + { + id: 101, + payload: { + previewServiceId: "preview-1", + serviceRevisionId: "revision-1", + }, + }, + ]); + }, + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + findGitHubDeployment( + 10, + "acme/app", + "0123456789abcdef0123456789abcdef01234567", + "preview/app/pr-42", + { + previewServiceId: "preview-1", + serviceRevisionId: "revision-1", + }, + ), + ).resolves.toBe(101); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://api.github.com/repos/acme/app/deployments?sha=0123456789abcdef0123456789abcdef01234567&environment=preview%2Fapp%2Fpr-42&per_page=100", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer installation-token", + }), + }), + ); + }); + + it("fails when the synthetic merge ref is unavailable without using the PR head", async () => { + configureGitHubApp(); + const fetchMock = vi.fn( + async (input: string | URL | Request, _init?: RequestInit) => { + const url = String(input); + if (url.includes("/access_tokens")) { + return Response.json({ token: "installation-token" }); + } + return new Response("Not Found", { status: 404 }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + resolveGitHubPullRequestMergeRef(10, "acme/app", 42), + ).rejects.toThrow("refs/pull/42/merge is unavailable"); + const commitRequests = fetchMock.mock.calls + .map(([input]) => String(input)) + .filter((url) => url.includes("/commits")); + expect(commitRequests).toEqual([ + "https://api.github.com/repos/acme/app/commits?sha=refs%2Fpull%2F42%2Fmerge&per_page=1", + ]); + }); +}); diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts index e532acea..881d0295 100644 --- a/web/tests/inngest-route.test.ts +++ b/web/tests/inngest-route.test.ts @@ -21,6 +21,11 @@ const mocks = vi.hoisted(() => { oldBackupsCleanup: { id: "old-backups-cleanup" }, onDeploymentFailed: { id: "on-deployment-failed" }, onRestoreFailed: { id: "on-restore-failed" }, + previewReconciliation: { id: "preview-reconciliation" }, + previewServiceReconcileWorkflow: { + id: "preview-service-reconcile-workflow", + }, + previewSyncWorkflow: { id: "preview-sync-workflow" }, restoreTriggerWorkflow: { id: "restore-trigger-workflow" }, restoreWorkflow: { id: "restore-workflow" }, registryArtifactRetention: { id: "registry-artifact-retention" }, diff --git a/web/tests/preview-deployments.test.ts b/web/tests/preview-deployments.test.ts new file mode 100644 index 00000000..187d5379 --- /dev/null +++ b/web/tests/preview-deployments.test.ts @@ -0,0 +1,383 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const selectResults: unknown[][] = []; + const returningResults: unknown[][] = []; + const insertedValues: unknown[] = []; + const updatedValues: unknown[] = []; + const transactionState = { active: false }; + function query(result: unknown[]) { + const value = { + from: vi.fn(() => value), + where: vi.fn(() => value), + orderBy: vi.fn(() => value), + limit: vi.fn(() => value), + innerJoin: vi.fn(() => value), + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. + then: ( + resolve: (rows: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return value; + } + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn(() => query(selectResults.shift() ?? [])), + insert: vi.fn(() => ({ + values: vi.fn((values: unknown) => { + insertedValues.push(values); + const result = { + onConflictDoNothing: vi.fn(() => result), + returning: vi.fn(() => + Promise.resolve(returningResults.shift() ?? []), + ), + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. + then: ( + resolve: (value: undefined) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(undefined).then(resolve, reject), + }; + return result; + }), + })), + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => { + updatedValues.push(values); + return { where: vi.fn().mockResolvedValue(undefined) }; + }), + })), + delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })), + }; + return { + selectResults, + returningResults, + insertedValues, + updatedValues, + transactionState, + tx, + getSetting: vi.fn(), + updateGitHubDeploymentStatus: vi.fn(), + db: { + transaction: vi.fn( + async (operation: (transaction: typeof tx) => unknown) => { + transactionState.active = true; + try { + return await operation(tx); + } finally { + transactionState.active = false; + } + }, + ), + select: vi.fn(() => query([])), + }, + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting })); +vi.mock("@/lib/github", () => ({ + createGitHubDeployment: vi.fn(), + findGitHubDeployment: vi.fn(), + updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus, +})); + +import { + createPreviewClone, + ensurePreviewEnvironment, + previewPortConfiguration, + updatePreviewGitHubStatus, +} from "@/lib/preview-deployments"; + +const baseService = { + id: "12345678-abcd-4321-abcd-1234567890ab", + projectId: "project-1", + environmentId: "environment-1", + name: "Web API", + sourceType: "github", + githubRepoUrl: "https://github.com/acme/app", + githubBranch: "main", + githubRootDir: "apps/web", + previewDeploymentsEnabled: true, + previewOfService: null, + stateful: false, + replicas: 2, + autoscalingEnabled: false, + autoscalingMinReplicas: 1, + autoscalingMaxReplicas: 4, + placementMode: "manual", + lockedServerId: "server-1", + healthCheckCmd: "curl -f http://localhost/health", + healthCheckInterval: 10, + healthCheckTimeout: 5, + healthCheckRetries: 3, + healthCheckStartPeriod: 30, + startCommand: "node server.js", + resourceCpuLimit: 1, + resourceMemoryLimitMb: 512, + serverlessEnabled: true, + serverlessSleepAfterSeconds: 300, + serverlessWakeTimeoutSeconds: 60, + deploymentSchedule: "0 9 * * *", + backupEnabled: false, + backupSchedule: null, +}; + +const repo = { + installationId: 101, + repoId: 202, + repoFullName: "acme/app", + defaultBranch: "main", + deployBranch: "main", +}; + +const ports = [ + { + id: "port-http", + serviceId: baseService.id, + port: 3000, + isPublic: true, + domain: "app.example.com", + protocol: "http" as const, + externalPort: null, + tlsPassthrough: false, + createdAt: new Date(), + }, + { + id: "port-tcp", + serviceId: baseService.id, + port: 5432, + isPublic: true, + domain: null, + protocol: "tcp" as const, + externalPort: 15432, + tlsPassthrough: true, + createdAt: new Date(), + }, +]; + +function queueFactoryReads( + existing: unknown[] = [], + service: typeof baseService = baseService, +) { + mocks.selectResults.push( + [service], + existing, + [repo], + ports, + [ + { + id: "secret-1", + serviceId: baseService.id, + key: "TOKEN", + encryptedValue: "ciphertext", + createdAt: new Date(), + updatedAt: new Date("2026-08-01T00:00:00Z"), + }, + ], + [ + { + serverId: "server-1", + count: 2, + status: "online", + wireguardIp: "10.0.0.1", + }, + ], + [{ id: "preview-environment", projectId: "project-1", name: "previews" }], + ); +} + +describe("preview service cloning", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.selectResults.length = 0; + mocks.returningResults.length = 0; + mocks.insertedValues.length = 0; + mocks.updatedValues.length = 0; + mocks.getSetting.mockResolvedValue("apps.example.com"); + mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined); + process.env.REGISTRY_HOST = "registry.example.com"; + }); + + it("copies runtime configuration and secrets but not automation", async () => { + queueFactoryReads(); + + const result = await createPreviewClone({ + baseServiceId: baseService.id, + previewGitRef: "refs/pull/42/merge", + }); + + expect(result).toMatchObject({ + created: true, + primaryUrl: "https://web-api-pr-42-12345678.apps.example.com", + }); + const [service, clonedPorts, placement, clonedSecrets, clonedRepo] = + mocks.insertedValues as Array>; + expect(service).toMatchObject({ + projectId: "project-1", + environmentId: "preview-environment", + replicas: 2, + stateful: false, + autoscalingEnabled: false, + serverlessEnabled: true, + lockedServerId: "server-1", + deploymentSchedule: null, + backupEnabled: false, + previewDeploymentsEnabled: false, + previewOfService: baseService.id, + previewGitRef: "refs/pull/42/merge", + }); + expect(clonedPorts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + serviceId: result.serviceId, + port: 3000, + isPublic: true, + domain: "web-api-pr-42-12345678.apps.example.com", + }), + expect.objectContaining({ + serviceId: result.serviceId, + port: 5432, + isPublic: false, + externalPort: null, + tlsPassthrough: false, + }), + ]), + ); + expect(clonedPorts).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "port-http" }), + expect.objectContaining({ id: "port-tcp" }), + ]), + ); + expect(placement).toEqual([ + expect.objectContaining({ serverId: "server-1", count: 2 }), + ]); + expect(clonedSecrets).toEqual([ + expect.objectContaining({ key: "TOKEN", encryptedValue: "ciphertext" }), + ]); + expect(clonedRepo).toMatchObject({ + installationId: 101, + repoId: 202, + autoDeploy: false, + }); + }); + + it("preserves the same visible clone instead of refreshing its configuration", async () => { + mocks.selectResults.push( + [baseService], + [ + { + id: "preview-service-1", + previewOfService: baseService.id, + previewGitRef: "refs/pull/42/merge", + }, + ], + [{ domain: "custom-preview.apps.example.com" }], + ); + + await expect( + createPreviewClone({ + baseServiceId: baseService.id, + previewGitRef: "refs/pull/42/merge", + }), + ).resolves.toMatchObject({ + serviceId: "preview-service-1", + created: false, + primaryUrl: "https://custom-preview.apps.example.com", + }); + expect(mocks.updatedValues).toHaveLength(0); + expect(mocks.insertedValues).toHaveLength(0); + }); + + it("rejects stateful services", async () => { + mocks.selectResults.push([{ ...baseService, stateful: true }]); + await expect( + createPreviewClone({ + baseServiceId: baseService.id, + previewGitRef: "refs/pull/42/merge", + }), + ).rejects.toThrow("require a stateless service"); + expect(mocks.tx.insert).not.toHaveBeenCalled(); + }); + + it("creates the ordinary previews environment when it is missing", async () => { + mocks.selectResults.push([]); + mocks.returningResults.push([ + { id: "preview-environment", projectId: "project-1", name: "previews" }, + ]); + + await expect(ensurePreviewEnvironment("project-1")).resolves.toMatchObject({ + id: "preview-environment", + name: "previews", + }); + expect(mocks.insertedValues).toContainEqual( + expect.objectContaining({ projectId: "project-1", name: "previews" }), + ); + }); + + it("keeps ports private when no automatic domain is configured", () => { + expect( + previewPortConfiguration({ + ports, + serviceName: baseService.name, + serviceId: baseService.id, + pullRequestNumber: 42, + domain: null, + }), + ).toEqual( + ports.map((port) => ({ + ...port, + isPublic: false, + domain: null, + externalPort: null, + tlsPassthrough: false, + })), + ); + }); + + it("disables copied serverless mode when no public preview URL exists", async () => { + mocks.getSetting.mockResolvedValue(null); + queueFactoryReads([], { ...baseService, serverlessEnabled: true }); + + await createPreviewClone({ + baseServiceId: baseService.id, + previewGitRef: "refs/pull/42/merge", + }); + + expect(mocks.insertedValues[0]).toMatchObject({ + serverlessEnabled: false, + }); + }); + + it("holds the service lock while publishing the current GitHub status", async () => { + mocks.selectResults.push( + [{ previewOfService: baseService.id }], + [{ id: "revision-1" }], + [{ id: 303 }], + [{ installationId: 101, repoFullName: "acme/app" }], + [{ id: "port-1", port: 3000, domain: "preview.apps.example.com" }], + ); + mocks.updateGitHubDeploymentStatus.mockImplementation(async () => { + expect(mocks.transactionState.active).toBe(true); + }); + + await expect( + updatePreviewGitHubStatus({ + serviceId: "preview-service", + serviceRevisionId: "revision-1", + state: "success", + description: "Preview is ready", + }), + ).resolves.toBe(true); + expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith( + 101, + "acme/app", + 303, + "success", + expect.objectContaining({ + environmentUrl: "https://preview.apps.example.com", + }), + ); + }); +}); diff --git a/web/tests/preview-lifecycle.test.ts b/web/tests/preview-lifecycle.test.ts new file mode 100644 index 00000000..a7ceaaa0 --- /dev/null +++ b/web/tests/preview-lifecycle.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { services } from "@/db/schema"; + +const mocks = vi.hoisted(() => { + function query(result: unknown[]) { + const value = { + from: vi.fn(() => value), + where: vi.fn(() => value), + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. + then: ( + resolve: (rows: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return value; + } + function mutation(returning: unknown[] = []) { + const value = { + set: vi.fn(() => value), + where: vi.fn(() => value), + returning: vi.fn(() => Promise.resolve(returning)), + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. + then: ( + resolve: (result: undefined) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(undefined).then(resolve, reject), + }; + return value; + } + + const txSelectResults: unknown[][] = []; + const dbSelectResults: unknown[][] = []; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn(() => query(txSelectResults.shift() ?? [])), + update: vi.fn(() => mutation()), + }; + const db = { + transaction: vi.fn((operation: (transaction: typeof tx) => unknown) => + operation(tx), + ), + select: vi.fn(() => query(dbSelectResults.shift() ?? [])), + update: vi.fn(() => mutation()), + delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })), + }; + return { + txSelectResults, + dbSelectResults, + db, + prepareRegistryArtifactCleanup: vi.fn(), + cleanupRegistryArtifactsForService: vi.fn(), + inactivatePreviewGitHubDeployments: vi.fn(), + enqueueReconcileForAllOnlineServers: vi.fn(), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/inngest/client", () => ({ + inngest: { send: vi.fn().mockResolvedValue(undefined) }, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + buildCancelled: { create: vi.fn() }, + rolloutCancelled: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/preview-deployments", () => ({ + inactivatePreviewGitHubDeployments: mocks.inactivatePreviewGitHubDeployments, +})); +vi.mock("@/lib/registry-retention", () => ({ + prepareRegistryArtifactCleanup: mocks.prepareRegistryArtifactCleanup, + cleanupRegistryArtifactsForService: mocks.cleanupRegistryArtifactsForService, +})); +vi.mock("@/lib/work-queue", () => ({ + enqueueReconcileForAllOnlineServers: + mocks.enqueueReconcileForAllOnlineServers, + enqueueWork: vi.fn(), +})); + +import { deletePreviewService } from "@/lib/preview-lifecycle"; + +describe("preview deletion", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.txSelectResults.length = 0; + mocks.dbSelectResults.length = 0; + mocks.prepareRegistryArtifactCleanup.mockResolvedValue(true); + mocks.cleanupRegistryArtifactsForService.mockResolvedValue(undefined); + }); + afterEach(() => vi.restoreAllMocks()); + + it("hard-deletes the service when GitHub inactivation fails", async () => { + mocks.txSelectResults.push([{ service: { id: "preview-service" } }]); + mocks.dbSelectResults.push([]); + mocks.inactivatePreviewGitHubDeployments.mockRejectedValue( + new Error("GitHub unavailable"), + ); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + await expect( + deletePreviewService( + "base-service", + "refs/pull/42/merge", + "pull request closed", + ), + ).resolves.toMatchObject({ service: { id: "preview-service" } }); + expect(mocks.db.delete).toHaveBeenCalledTimes(2); + expect(mocks.db.delete).toHaveBeenLastCalledWith(services); + expect(consoleError).toHaveBeenCalledWith( + "[preview-lifecycle] failed to inactivate GitHub deployments for preview-service:", + expect.any(Error), + ); + }); +}); diff --git a/web/tests/preview-workflow.test.ts b/web/tests/preview-workflow.test.ts new file mode 100644 index 00000000..859c36a8 --- /dev/null +++ b/web/tests/preview-workflow.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + class GitHubApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + } + } + const selectResults: unknown[][] = []; + function query(result: unknown[]) { + const value = { + from: vi.fn(() => value), + innerJoin: vi.fn(() => value), + where: vi.fn(() => value), + orderBy: vi.fn(() => value), + limit: vi.fn(() => value), + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable. + then: ( + resolve: (rows: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return value; + } + const db = { select: vi.fn(() => query(selectResults.shift() ?? [])) }; + return { + GitHubApiError, + selectResults, + db, + getGitHubPullRequest: vi.fn(), + listOpenGitHubPullRequests: vi.fn(), + resolveGitHubPullRequestMergeRef: vi.fn(), + createPreviewClone: vi.fn(), + inactivatePreviewGitHubDeployments: vi.fn(), + cancelPreviewRevisionWork: vi.fn(), + deletePreviewService: vi.fn(), + triggerResolvedBuildInternal: vi.fn(), + parseServiceRevisionSpec: vi.fn(), + send: vi.fn(), + createSyncEvent: vi.fn((data, options) => ({ + name: "preview/sync-requested", + data, + ...options, + })), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/github", () => ({ + GitHubApiError: mocks.GitHubApiError, + getGitHubPullRequest: mocks.getGitHubPullRequest, + listOpenGitHubPullRequests: mocks.listOpenGitHubPullRequests, + resolveGitHubPullRequestMergeRef: mocks.resolveGitHubPullRequestMergeRef, +})); +vi.mock("@/lib/preview-deployments", () => ({ + createPreviewClone: mocks.createPreviewClone, + inactivatePreviewGitHubDeployments: mocks.inactivatePreviewGitHubDeployments, +})); +vi.mock("@/lib/preview-lifecycle", () => ({ + cancelPreviewRevisionWork: mocks.cancelPreviewRevisionWork, + deletePreviewService: mocks.deletePreviewService, +})); +vi.mock("@/lib/service-revision-changes", () => ({ + parseServiceRevisionSpec: mocks.parseServiceRevisionSpec, +})); +vi.mock("@/lib/trigger-build", () => ({ + triggerResolvedBuildInternal: mocks.triggerResolvedBuildInternal, +})); +vi.mock("@/lib/inngest/client", () => ({ + inngest: { + createFunction: vi.fn( + (_options: unknown, handler: (input: unknown) => unknown) => handler, + ), + send: mocks.send, + }, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + previewSyncRequested: { + name: "preview/sync-requested", + create: mocks.createSyncEvent, + }, + previewCloseRequested: { name: "preview/close-requested" }, + previewServiceReconcileRequested: { + name: "preview/service-reconcile-requested", + }, + }, +})); + +import { previewSyncWorkflow } from "@/lib/inngest/functions/preview-workflow"; + +const baseContext = { + service: { + id: "base-service", + previewDeploymentsEnabled: true, + previewOfService: null, + stateful: false, + sourceType: "github" as const, + }, + githubRepo: { + installationId: 10, + repoId: 20, + repoFullName: "acme/app", + deployBranch: "main", + defaultBranch: "main", + }, +}; + +const pullRequest = { + number: 42, + state: "open" as const, + draft: false, + merged: false, + title: "Add previews", + updatedAt: "2026-08-16T00:00:00Z", + user: { id: 30, login: "octocat" }, + base: { ref: "main", repository: { id: 20, fullName: "acme/app" } }, + head: { sha: "1".repeat(40), repository: { id: 20, fullName: "acme/app" } }, +}; + +function invoke( + workflow: unknown, + data: Record, + name = "preview/sync-requested", +) { + return ( + workflow as (input: { + event: { id: string; name: string; data: Record }; + step: { run: (_name: string, operation: () => unknown) => unknown }; + }) => Promise + )({ + event: { id: "event-1", name, data }, + step: { run: async (_name, operation) => operation() }, + }); +} + +describe("preview lifecycle workflows", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.selectResults.length = 0; + mocks.createPreviewClone.mockResolvedValue({ + serviceId: "preview-service", + created: false, + primaryUrl: "https://preview.example.com", + }); + mocks.getGitHubPullRequest.mockResolvedValue(pullRequest); + mocks.deletePreviewService.mockResolvedValue({ + service: { id: "preview-service" }, + }); + mocks.inactivatePreviewGitHubDeployments.mockResolvedValue(1); + mocks.cancelPreviewRevisionWork.mockResolvedValue(undefined); + mocks.send.mockResolvedValue(undefined); + }); + + it("builds the exact merge ref and supersedes the prior revision", async () => { + mocks.selectResults.push( + [baseContext], + [{ previewOfService: "base-service" }], + [{ id: "revision-old", specification: {} }], + ); + mocks.parseServiceRevisionSpec.mockReturnValue({ + source: { type: "github", commitSha: "2".repeat(40) }, + }); + mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({ + gitRef: "refs/pull/42/merge", + sha: "3".repeat(40), + }); + mocks.triggerResolvedBuildInternal.mockResolvedValue({ + status: "queued", + serviceRevisionId: "revision-new", + }); + + await expect( + invoke(previewSyncWorkflow, { + baseServiceId: "base-service", + previewGitRef: "refs/pull/42/merge", + }), + ).resolves.toMatchObject({ serviceRevisionId: "revision-new" }); + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledWith( + "preview-service", + expect.objectContaining({ + trigger: "preview", + commitSha: "3".repeat(40), + gitRef: "refs/pull/42/merge", + }), + ); + expect(mocks.cancelPreviewRevisionWork).toHaveBeenCalledWith( + "preview-service", + "revision-old", + ); + expect(mocks.inactivatePreviewGitHubDeployments).toHaveBeenCalledWith({ + serviceId: "preview-service", + excludeServiceRevisionId: "revision-new", + description: "Superseded by a newer preview revision", + }); + }); + + it("deletes the preview when GitHub has no merge ref", async () => { + mocks.selectResults.push( + [baseContext], + [{ previewOfService: "base-service" }], + [], + ); + mocks.resolveGitHubPullRequestMergeRef.mockRejectedValue( + new Error("merge ref unavailable"), + ); + + await expect( + invoke(previewSyncWorkflow, { + baseServiceId: "base-service", + previewGitRef: "refs/pull/42/merge", + }), + ).resolves.toEqual({ + status: "failed", + reason: "merge_ref_unavailable", + }); + expect(mocks.deletePreviewService).toHaveBeenCalledWith( + "base-service", + "refs/pull/42/merge", + "merge ref is unavailable", + ); + expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled(); + }); + + it("deletes the preview when a pull request closes", async () => { + await expect( + invoke( + previewSyncWorkflow, + { + baseServiceId: "base-service", + previewGitRef: "refs/pull/42/merge", + reason: "pull_request_closed", + }, + "preview/close-requested", + ), + ).resolves.toEqual({ status: "deleted", serviceId: "preview-service" }); + expect(mocks.deletePreviewService).toHaveBeenCalledWith( + "base-service", + "refs/pull/42/merge", + "pull_request_closed", + ); + }); + + it("deletes the preview when close verification returns not found", async () => { + mocks.selectResults.push( + [baseContext], + [ + { + service: { id: "preview-service" }, + githubRepo: baseContext.githubRepo, + }, + ], + ); + mocks.getGitHubPullRequest.mockRejectedValue( + new mocks.GitHubApiError("Pull request not found", 404), + ); + + await expect( + invoke( + previewSyncWorkflow, + { + baseServiceId: "base-service", + previewGitRef: "refs/pull/42/merge", + reason: "pull_request_closed", + verifyWithGitHub: true, + }, + "preview/close-requested", + ), + ).resolves.toEqual({ status: "deleted", serviceId: "preview-service" }); + expect(mocks.deletePreviewService).toHaveBeenCalled(); + }); + + it("retries close verification after a transient GitHub failure", async () => { + mocks.selectResults.push( + [baseContext], + [ + { + service: { id: "preview-service" }, + githubRepo: baseContext.githubRepo, + }, + ], + ); + mocks.getGitHubPullRequest.mockRejectedValue( + new mocks.GitHubApiError("GitHub unavailable", 503), + ); + + await expect( + invoke( + previewSyncWorkflow, + { + baseServiceId: "base-service", + previewGitRef: "refs/pull/42/merge", + reason: "pull_request_closed", + verifyWithGitHub: true, + }, + "preview/close-requested", + ), + ).rejects.toThrow("GitHub unavailable"); + expect(mocks.deletePreviewService).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/trigger-build.test.ts b/web/tests/trigger-build.test.ts index 15ed2198..dae5b9ec 100644 --- a/web/tests/trigger-build.test.ts +++ b/web/tests/trigger-build.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ send: vi.fn(), resolveGitHubCommit: vi.fn(), createBuildTrigger: vi.fn(), + createPreviewSync: vi.fn(), createGitHubBuildServiceRevision: vi.fn(), cloneGitHubBuildServiceRevision: vi.fn(), })); @@ -22,6 +23,7 @@ vi.mock("@/lib/github", () => ({ vi.mock("@/lib/inngest/events", () => ({ inngestEvents: { buildTrigger: { create: mocks.createBuildTrigger }, + previewSyncRequested: { create: mocks.createPreviewSync }, }, })); vi.mock("@/lib/service-revisions", () => ({ @@ -55,6 +57,11 @@ describe("internal GitHub build trigger", () => { process.env.REGISTRY_HOST = "registry.test"; mocks.rows = []; mocks.createGitHubBuildServiceRevision.mockResolvedValue({}); + mocks.createPreviewSync.mockImplementation((data, options) => ({ + name: "preview/sync-requested", + data, + ...options, + })); mocks.resolveGitHubCommit.mockResolvedValue({ sha: "0123456789abcdef0123456789abcdef01234567", message: "Resolved source commit", @@ -171,6 +178,51 @@ describe("internal GitHub build trigger", () => { ); }); + it("routes a visible preview build through its pull request merge ref", async () => { + mocks.rows = [ + [ + { + id: "preview-service", + projectId: "project-1", + sourceType: "github", + deletedAt: null, + githubRepoUrl: "https://github.com/acme/app", + githubBranch: "main", + previewOfService: "base-service", + previewGitRef: "refs/pull/42/merge", + }, + ], + [ + { + installationId: 123, + repoFullName: "acme/app", + deployBranch: "main", + defaultBranch: "main", + }, + ], + ]; + + await expect( + triggerBuildInternal("preview-service", "manual", { + type: "system", + }), + ).resolves.toMatchObject({ status: "queued" }); + + expect(mocks.createPreviewSync).toHaveBeenCalledWith( + { + baseServiceId: "base-service", + previewGitRef: "refs/pull/42/merge", + force: true, + }, + { id: expect.stringContaining("preview-user-sync:preview-service:") }, + ); + expect(mocks.send).toHaveBeenCalledWith( + expect.objectContaining({ name: "preview/sync-requested" }), + ); + expect(mocks.resolveGitHubCommit).not.toHaveBeenCalled(); + expect(mocks.createGitHubBuildServiceRevision).not.toHaveBeenCalled(); + }); + it("rejects a non-GitHub service before queueing work", async () => { mocks.rows = [ [