Ctrl K
ComunidadGuías…

app/scripts/smoke-deploy-lifecycle.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { buildRuntimeId } from '#devpush/lib/runtime-id'
import { randomUUID } from 'node:crypto'

import { eq } from 'drizzle-orm'

import {
  claimNextDeploymentJob,
  completeDeploymentJob,
  failStaleRunningDeployments,
  recordDeploymentProgress,
} from '#devpush/server/agents/jobs'
import {
  currentAgentCapabilities,
  currentAgentProtocolVersion,
} from '#devpush/server/agents/compatibility'
import { readProjectDeploymentLogs } from '#devpush/server/projects/deployment-logs'
import { db, logsDb } from '#devpush/server/db'
import { deploymentLogs, runtimeLogs } from '#devpush/server/db/log-schema'
import {
  aliases,
  environments,
  deployments,
  githubInstallations,
  projects,
  servers,
  teamMembers,
  teams,
  user,
} from '#devpush/server/db/schema'
import {
  createDeploymentsForGitHubPush,
  createManualDeployment,
} from '#devpush/server/projects/deployments'
import { encryptDeploymentEnvVars } from '#devpush/server/projects/deployment-config'
import { encryptProjectEnvVars } from '#devpush/server/projects/project-env-vars'
import { createComposeRuntimeConfig } from '#devpush/lib/project-runtime-config'
import {
  createPendingServer,
  recordAgentHeartbeat,
  registerAgent,
} from '#devpush/server/servers/service'

function extractAgentToken(installCommand: string) {
  const match = installCommand.match(/\/api\/install\/agent\/([^'\s|]+)/)

  if (!match?.[1]) {
    throw new Error('Unable to extract enrollment token from install command.')
  }

  return decodeURIComponent(match[1])
}

function hasCommitMessage(value: unknown, message: string) {
  return (
    typeof value === 'object' &&
    value !== null &&
    'message' in value &&
    value.message === message
  )
}

async function main() {
  const suffix = randomUUID().slice(0, 8)
  const userId = `smoke-user-${suffix}`
  const teamId = `smoke-team-${suffix}`
  const teamSlug = `smoke-${suffix}`
  const serverName = `smoke-server-${suffix}`
  const projectId = `smoke-project-${suffix}`
  const projectName = `smoke-app-${suffix}`
  const environmentId = `smoke-env-${suffix}`
  const brokenProjectId = `smoke-broken-project-${suffix}`
  const brokenProjectSlug = `smoke-broken-app-${suffix}`
  const brokenEnvironmentId = `smoke-broken-env-${suffix}`
  const installationId = 9_000_000_000 + Number.parseInt(suffix.slice(0, 6), 16)

  try {
    await db.insert(user).values({
      id: userId,
      name: 'Smoke Test',
      username: `smoke-${suffix}`,
      email: `smoke-${suffix}@devpush.local`,
      emailVerified: true,
    })
    await db.insert(teams).values({
      id: teamId,
      slug: teamSlug,
      name: `Smoke ${suffix}`,
    })
    await db.insert(teamMembers).values({
      id: `smoke-member-${suffix}`,
      teamId,
      userId,
      role: 'owner',
    })

    const createdServer = await createPendingServer({
      userId,
      teamSlug,
      name: serverName,
    })
    let token = extractAgentToken(createdServer.installCommand)

    await db.insert(githubInstallations).values({
      installationId,
      accountLogin: 'devpush-smoke',
      accountType: 'User',
      repositorySelection: 'selected',
      status: 'active',
    })
    await db.insert(projects).values({
      id: projectId,
      teamId,
      slug: projectName,
      name: `Smoke app ${suffix}`,
      githubRepoId: 123456789,
      githubRepo: 'devpush/smoke-compose',
      githubInstallationId: installationId,
      runtimeConfig: createComposeRuntimeConfig({
        path: 'compose.yml',
        releaseCommand: 'echo release',
      }),
      routes: [{ path: '/', service: 'web', port: 3000, stripPrefix: false }],
      envVarsEncrypted: encryptProjectEnvVars([
        { key: 'SMOKE_VALUE', value: 'ok', environment: '' },
      ]),
    })
    await db.insert(environments).values({
      id: environmentId,
      projectId,
      serverId: createdServer.id,
      name: 'Production',
      slug: 'production',
      branch: 'main',
      isProduction: true,
      runtimeId: buildRuntimeId({ projectId, environmentId }),
    })
    await db.insert(aliases).values({
      id: `smoke-hostname-${suffix}`,
      projectId,
      hostname: `${projectName}.devpush.local`,
      type: 'environment',
      environmentId,
      https: false,
    })

    await expectDeploymentToFail({
      environmentId,
      userId,
      teamSlug,
      projectName,
      message: 'Wait until the assigned server is connected before deploying.',
    })

    const registration = await registerAgent({
      token,
      agentVersion: 'smoke',
      capabilities: [...currentAgentCapabilities],
      protocolVersion: currentAgentProtocolVersion,
      publicAddress: '203.0.113.10',
    })
    token = registration.credential ?? token

    const registeredServerRows = await db
      .select()
      .from(servers)
      .where(eq(servers.id, registration.serverId))
      .limit(1)

    if (registeredServerRows.at(0)?.publicAddress !== '203.0.113.10') {
      throw new Error('Expected agent public address to be persisted.')
    }

    await recordAgentHeartbeat({
      serverId: registration.serverId,
      token,
      agentVersion: 'smoke',
      capabilities: [...currentAgentCapabilities],
      protocolVersion: currentAgentProtocolVersion,
      publicAddress: null,
    })
    const heartbeatServerRows = await db
      .select()
      .from(servers)
      .where(eq(servers.id, registration.serverId))
      .limit(1)

    if (heartbeatServerRows.at(0)?.publicAddress !== '203.0.113.10') {
      throw new Error(
        'Expected heartbeat without address to preserve public address.',
      )
    }

    const canceledDeployment = await createManualDeployment({
      environmentId,
      gitBranch: 'main',
      userId,
      teamSlug,
      projectName,
    })

    const deployment = await createManualDeployment({
      environmentId,
      gitBranch: 'main',
      userId,
      teamSlug,
      projectName,
    })

    const canceledDeploymentRows = await db
      .select()
      .from(deployments)
      .where(eq(deployments.id, canceledDeployment.deploymentId))
      .limit(1)

    if (canceledDeploymentRows.at(0)?.status !== 'canceled') {
      throw new Error('Expected superseded deployment to be canceled.')
    }

    const accessTokenRequests: Array<{
      installationId: number
      repositoryId: number | null
    }> = []
    const job = await claimNextDeploymentJob({
      serverId: registration.serverId,
      token,
      createAccessToken: async (request) => {
        accessTokenRequests.push(request)

        return {
          token: 'smoke-github-token',
          expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
        }
      },
    })

    if (!job) {
      throw new Error('Expected queued deployment job to be claimed.')
    }

    if (job.id !== deployment.deploymentId) {
      throw new Error(
        `Claimed unexpected deployment ${job.id}; expected ${deployment.deploymentId}.`,
      )
    }

    if (job.envVars.at(0)?.key !== 'SMOKE_VALUE') {
      throw new Error('Expected environment variables in claimed job.')
    }

    if (
      accessTokenRequests.length !== 1 ||
      accessTokenRequests[0]?.installationId !== installationId ||
      accessTokenRequests[0].repositoryId !== 123456789
    ) {
      throw new Error(
        'Expected GitHub token request to be scoped to the repository.',
      )
    }

    await recordDeploymentProgress({
      serverId: registration.serverId,
      token,
      progress: {
        deploymentId: job.id,
        logStartSeq: job.logStartSeq,
        logs: ['smoke progress log 1', 'smoke progress log 2'],
        events: [
          {
            phase: 'compose_resolved',
            message: 'Smoke Compose file resolved.',
          },
        ],
      },
    })

    await completeDeploymentJob({
      serverId: registration.serverId,
      token,
      completion: {
        deploymentId: job.id,
        status: 'succeeded',
        gitSha: 'abc1234',
        logStartSeq: job.logStartSeq + 2,
        logs: ['smoke deploy log'],
        events: [
          {
            phase: 'completed',
            message: 'Smoke deployment completed.',
          },
        ],
      },
    })

    const deploymentRows = await db
      .select()
      .from(deployments)
      .where(eq(deployments.id, deployment.deploymentId))
      .limit(1)
    const deploymentRow = deploymentRows.at(0)
    const logRows = await readProjectDeploymentLogs(
      { projectId: projectId, deploymentId: deployment.deploymentId },
      1000,
    )

    if (!deploymentRow) {
      throw new Error('Expected deployment row to exist.')
    }

    if (deploymentRow.status !== 'succeeded') {
      throw new Error(
        `Expected succeeded deployment, got ${deploymentRow.status}.`,
      )
    }

    const expectedLogRows = [
      { seq: job.logStartSeq, content: 'smoke progress log 1' },
      { seq: job.logStartSeq + 1, content: 'smoke progress log 2' },
      { seq: job.logStartSeq + 2, content: 'smoke deploy log' },
    ]
    for (const expected of expectedLogRows) {
      if (
        !logRows.some(
          (row) => row.seq === expected.seq && row.content === expected.content,
        )
      ) {
        throw new Error(
          `Expected deployment log seq ${expected.seq} to contain ${expected.content}.`,
        )
      }
    }

    if (
      !logRows.some(
        (row) => row.stream === 'control' && row.content.includes('Queued'),
      )
    ) {
      throw new Error('Expected initial queued log chunk to be persisted.')
    }

    if (logRows.length !== expectedLogRows.length + 1) {
      throw new Error(
        `Expected exactly ${expectedLogRows.length + 1} deployment logs, got ${logRows.length}.`,
      )
    }

    if (deploymentRow.phase !== 'completed') {
      throw new Error(`Expected completed phase, got ${deploymentRow.phase}.`)
    }

    const pushResult = await createDeploymentsForGitHubPush({
      deliveryId: 'smoke-push-1',
      installationId,
      repositoryId: 123456789,
      repositoryFullName: 'devpush/smoke-compose',
      branch: 'main',
      gitSha: 'feed1234',
      message: 'Webhook smoke deploy',
    })

    if (pushResult.created.length !== 1 || pushResult.matched !== 1) {
      throw new Error(
        `Expected GitHub push to queue one deployment, got ${JSON.stringify(pushResult)}.`,
      )
    }

    const pushDeploymentId = pushResult.created[0]!.deploymentId
    const pushDeploymentRows = await db
      .select()
      .from(deployments)
      .where(eq(deployments.id, pushDeploymentId))
      .limit(1)
    const pushDeploymentRow = pushDeploymentRows.at(0)

    if (!pushDeploymentRow) {
      throw new Error('Expected GitHub push deployment row to exist.')
    }

    if (
      pushDeploymentRow.status !== 'queued' ||
      pushDeploymentRow.gitSha !== 'feed1234' ||
      !hasCommitMessage(pushDeploymentRow.commitMeta, 'Webhook smoke deploy') ||
      pushDeploymentRow.trigger !== 'webhook'
    ) {
      throw new Error(
        `Unexpected GitHub push deployment: ${JSON.stringify(pushDeploymentRow)}.`,
      )
    }

    const duplicatePushResult = await createDeploymentsForGitHubPush({
      deliveryId: 'smoke-push-2',
      installationId,
      repositoryId: 123456789,
      repositoryFullName: 'devpush/smoke-compose',
      branch: 'main',
      gitSha: 'feed5678',
      message: 'Duplicate webhook smoke deploy',
    })

    if (
      duplicatePushResult.created.length !== 1 ||
      duplicatePushResult.matched !== 1 ||
      duplicatePushResult.skipped.length !== 0
    ) {
      throw new Error(
        `Expected duplicate GitHub push deployment to queue a replacement, got ${JSON.stringify(duplicatePushResult)}.`,
      )
    }

    const canceledPushDeploymentRows = await db
      .select()
      .from(deployments)
      .where(eq(deployments.id, pushDeploymentId))
      .limit(1)

    if (canceledPushDeploymentRows.at(0)?.status !== 'canceled') {
      throw new Error('Expected superseded GitHub push deployment to cancel.')
    }

    const duplicatePushDeploymentId =
      duplicatePushResult.created[0]!.deploymentId

    await db
      .update(deployments)
      .set({ status: 'canceled', finishedAt: new Date() })
      .where(eq(deployments.id, duplicatePushDeploymentId))

    await expectCompletionToBeIgnored({
      serverId: registration.serverId,
      token,
      deploymentId: deployment.deploymentId,
    })

    await db.insert(projects).values({
      id: brokenProjectId,
      teamId,
      slug: brokenProjectSlug,
      name: `Broken smoke app ${suffix}`,
      githubRepoId: 987654321,
      githubRepo: 'devpush/broken-smoke-compose',
      runtimeConfig: createComposeRuntimeConfig({ path: 'compose.yml' }),
      routes: [{ path: '/', service: 'web', port: 3000, stripPrefix: false }],
    })
    await db.insert(environments).values({
      id: brokenEnvironmentId,
      projectId: brokenProjectId,
      serverId: registration.serverId,
      name: 'Production',
      slug: 'production',
      branch: 'main',
      isProduction: true,
      runtimeId: buildRuntimeId({
        projectId: brokenProjectId,
        environmentId: brokenEnvironmentId,
      }),
    })
    await db.insert(aliases).values({
      id: `smoke-broken-hostname-${suffix}`,
      projectId: brokenProjectId,
      environmentId: brokenEnvironmentId,
      hostname: `${brokenProjectSlug}.devpush.local`,
      type: 'environment',
      https: false,
    })
    const brokenDeploymentId = `smoke-broken-deployment-${suffix}`
    await db.insert(deployments).values({
      id: brokenDeploymentId,
      environmentId: brokenEnvironmentId,
      serverId: registration.serverId,
      status: 'queued',
      phase: 'queued',
      trigger: 'user',
      repoId: 987654321,
      repoFullName: 'devpush/broken-smoke-compose',
      gitSha: 'broken-sha',
      gitBranch: 'main',
      commitMeta: {
        message: 'Broken smoke deployment',
        authorName: null,
        authoredAt: null,
        url: null,
      },
      config: {
        schemaVersion: 1,
        runtime: {
          type: 'compose',
          path: 'compose.yml',
          releaseCommand: null,
          releaseService: 'web',
          readinessPath: null,
          deployCommand: null,
          rollbackCommand: null,
          timeoutSeconds: 1800,
        },
        routes: [
          {
            host: `${brokenProjectSlug}.devpush.local`,
            service: 'web',
            port: 3000,
            path: '/',
            https: false,
            stripPrefix: false,
          },
        ],
      },
      envVars: encryptDeploymentEnvVars([]),
      runtimeId: buildRuntimeId({
        projectId: brokenProjectId,
        environmentId: brokenEnvironmentId,
      }),
    })
    const missingInstallJob = await claimNextDeploymentJob({
      serverId: registration.serverId,
      token,
      createAccessToken: async () => ({
        token: 'unused',
        expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
      }),
    })

    if (missingInstallJob) {
      throw new Error(
        'Expected missing GitHub installation deployment to fail.',
      )
    }

    const brokenDeploymentRows = await db
      .select()
      .from(deployments)
      .where(eq(deployments.id, brokenDeploymentId))
      .limit(1)
    const brokenLogRows = await readProjectDeploymentLogs(
      { projectId: brokenProjectId, deploymentId: brokenDeploymentId },
      1000,
    )
    const brokenDeploymentRow = brokenDeploymentRows.at(0)

    if (brokenDeploymentRow?.status !== 'failed') {
      throw new Error(
        'Expected missing GitHub installation deployment to fail.',
      )
    }

    if (
      !brokenLogRows.some(
        (row) =>
          row.content === 'Project is missing a GitHub App installation.',
      )
    ) {
      throw new Error('Expected missing GitHub installation failure log.')
    }

    if (
      brokenDeploymentRow.errorMessage !==
      'Project is missing a GitHub App installation.'
    ) {
      throw new Error(
        'Expected missing GitHub installation failure error message.',
      )
    }

    const staleDeploymentId = `smoke-stale-${suffix}`
    await db.insert(deployments).values({
      id: staleDeploymentId,
      environmentId,
      serverId: registration.serverId,
      status: 'running',
      phase: 'deploy',
      trigger: 'user',
      repoId: 123456789,
      repoFullName: 'devpush/smoke-compose',
      gitSha: 'pending',
      gitBranch: 'main',
      commitMeta: {
        message: 'Stale smoke deployment',
        authorName: null,
        authoredAt: null,
        url: null,
      },
      config: {
        schemaVersion: 1,
        runtime: {
          type: 'compose',
          path: 'compose.yml',
          releaseCommand: 'echo release',
          releaseService: 'web',
          readinessPath: null,
          deployCommand: null,
          rollbackCommand: null,
          timeoutSeconds: 1800,
        },
        routes: [
          {
            host: `${projectName}.devpush.local`,
            service: 'web',
            port: 3000,
            path: '/',
            https: false,
            stripPrefix: false,
          },
        ],
      },
      envVars: encryptDeploymentEnvVars([{ key: 'SMOKE_VALUE', value: 'ok' }]),
      runtimeId: buildRuntimeId({ projectId, environmentId }),
      startedAt: new Date(Date.now() - 4 * 60 * 60 * 1000),
    })

    await failStaleRunningDeployments()

    const staleDeploymentRows = await db
      .select()
      .from(deployments)
      .where(eq(deployments.id, staleDeploymentId))
      .limit(1)
    const staleLogRows = await readProjectDeploymentLogs(
      { projectId: projectId, deploymentId: staleDeploymentId },
      1000,
    )
    const staleMessage =
      'Deployment timed out after the agent stopped reporting.'

    if (staleDeploymentRows.at(0)?.status !== 'failed') {
      throw new Error('Expected stale running deployment to fail on read.')
    }

    if (!staleLogRows.some((row) => row.content === staleMessage)) {
      throw new Error('Expected stale deployment timeout log.')
    }

    if (staleDeploymentRows.at(0)?.errorMessage !== staleMessage) {
      throw new Error('Expected stale deployment timeout error message.')
    }

    console.log(
      JSON.stringify(
        {
          ok: true,
          teamSlug,
          serverId: registration.serverId,
          deploymentId: deployment.deploymentId,
          status: deploymentRow.status,
          deploymentLogs: logRows.length,
        },
        null,
        2,
      ),
    )
  } finally {
    await cleanup({ teamId, userId })
  }
}

async function expectDeploymentToFail({
  environmentId,
  userId,
  teamSlug,
  projectName,
  message,
}: {
  environmentId: string
  userId: string
  teamSlug: string
  projectName: string
  message: string
}) {
  try {
    await createManualDeployment({
      environmentId,
      gitBranch: 'main',
      userId,
      teamSlug,
      projectName,
    })
  } catch (error) {
    if (error instanceof Error && error.message === message) {
      return
    }

    throw error
  }

  throw new Error('Expected deployment creation to fail.')
}

async function expectCompletionToBeIgnored({
  serverId,
  token,
  deploymentId,
}: {
  serverId: string
  token: string
  deploymentId: string
}) {
  const result = await completeDeploymentJob({
    serverId,
    token,
    completion: {
      deploymentId,
      status: 'succeeded',
      gitSha: 'late-sha',
      logStartSeq: 1,
      logs: ['late completion'],
      events: [
        {
          phase: 'completed',
          message: 'Late completion should be ignored.',
        },
      ],
    },
  })

  if (!result.ignored) {
    throw new Error('Expected repeated deployment completion to be ignored.')
  }
}

async function cleanup({ teamId, userId }: { teamId: string; userId: string }) {
  try {
    const removedProjects = await db
      .select({ id: projects.id })
      .from(projects)
      .where(eq(projects.teamId, teamId))
    await db.delete(projects).where(eq(projects.teamId, teamId))
    for (const project of removedProjects) {
      await logsDb
        .delete(deploymentLogs)
        .where(eq(deploymentLogs.projectId, project.id))
      await logsDb
        .delete(runtimeLogs)
        .where(eq(runtimeLogs.projectId, project.id))
    }
    await db.delete(servers).where(eq(servers.teamId, teamId))
    await db.delete(teams).where(eq(teams.id, teamId))
    await db.delete(user).where(eq(user.id, userId))
  } catch (error) {
    console.error(
      'Smoke cleanup failed:',
      error instanceof Error ? error.message : error,
    )
  }
}

main()
  .then(() => process.exit(0))
  .catch((error: unknown) => {
    console.error(error)
    process.exit(1)
  })