Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions e2e-live/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { pathToFileURL } from "node:url";
import { runInit } from "../src/cli/commands/init.js";
import { runDeploy } from "../src/cli/commands/deploy.js";
import { listDatasources, listPipesV1 } from "../src/api/resources.js";
import { tinybirdFetch } from "../src/api/fetcher.js";
import {
getLiveE2EConfigFromEnv,
assertWorkspaceAdminToken,
Expand Down Expand Up @@ -48,6 +49,12 @@ interface ProductionTinybirdClient {
};
}

interface DeploymentListItem {
id: string;
status: string;
live?: boolean;
}

function toTinybirdDateTime(value: Date): string {
return value.toISOString().slice(0, 19).replace("T", " ");
}
Expand Down Expand Up @@ -137,6 +144,72 @@ async function waitForEndpointRows(
return [];
}

async function listDeployments(
config: LiveE2EConfig,
workspaceToken: string,
options?: { includeDeleted?: boolean }
): Promise<DeploymentListItem[]> {
const endpoint = new URL("/v1/deployments", config.baseUrl);
if (options?.includeDeleted) {
endpoint.searchParams.set("include_deleted", "true");
}

const response = await tinybirdFetch(endpoint.toString(), {
headers: {
Authorization: `Bearer ${workspaceToken}`,
},
});
const responseText = await response.text();

if (!response.ok) {
throw new Error(
`Failed to list deployments: ${response.status} ${response.statusText} - ${responseText}`
);
}

const payload = JSON.parse(responseText) as { deployments?: DeploymentListItem[] };
return payload.deployments ?? [];
}

async function waitForDeploymentStatus(
config: LiveE2EConfig,
workspaceToken: string,
deploymentId: string,
expectedStatus: string
): Promise<DeploymentListItem> {
for (let attempt = 0; attempt < 30; attempt++) {
const deployments = await listDeployments(config, workspaceToken, { includeDeleted: true });
const deployment = deployments.find((item) => item.id === deploymentId);

if (deployment?.status === expectedStatus) {
return deployment;
}

await new Promise((resolve) => setTimeout(resolve, 1_000));
}

const deployments = await listDeployments(config, workspaceToken, { includeDeleted: true });
const deployment = deployments.find((item) => item.id === deploymentId);
throw new Error(
`Timed out waiting for deployment ${deploymentId} to become ${expectedStatus}. ` +
`Last status: ${deployment?.status ?? "missing"}`
);
}

async function getCurrentLiveDeployment(
config: LiveE2EConfig,
workspaceToken: string
): Promise<DeploymentListItem> {
const deployments = await listDeployments(config, workspaceToken);
const liveDeployment = deployments.find((deployment) => deployment.live);

if (!liveDeployment) {
throw new Error("Expected a live deployment, but none was found.");
}

return liveDeployment;
}

describeLive("E2E Live: deploy", () => {
const config = liveConfig as LiveE2EConfig;

Expand Down Expand Up @@ -233,6 +306,7 @@ describeLive("E2E Live: deploy", () => {
const firstDeployResult = await runDeploy({ cwd: tempDir });
expect(firstDeployResult.success).toBe(true);
expect(firstDeployResult.deploy?.success).toBe(true);
const previousLiveDeployment = await getCurrentLiveDeployment(config, workspaceToken);

const tinybird = await importTinybirdClient(tempDir);
const runId = `prod_deploy_second_${Date.now()}`;
Expand All @@ -259,6 +333,15 @@ describeLive("E2E Live: deploy", () => {
expect(secondDeployResult.deploy?.success).toBe(true);
expect(secondDeployResult.deploy?.result).toBe("success");
expect(secondDeployResult.deploy?.buildId).toBeTruthy();
expect(secondDeployResult.deploy?.buildId).not.toBe(previousLiveDeployment.id);

const previousDeployment = await waitForDeploymentStatus(
config,
workspaceToken,
previousLiveDeployment.id,
"deleted"
);
expect(previousDeployment.live).not.toBe(true);

const rowsAfterSecondDeploy = await waitForEndpointRows(tinybird, runId);
expect(rowsAfterSecondDeploy.length).toBeGreaterThan(0);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tinybirdco/sdk",
"version": "0.0.76",
"version": "0.0.77",
"description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript",
"type": "module",
"main": "./dist/index.js",
Expand Down
44 changes: 42 additions & 2 deletions src/api/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ describe("Deploy API", () => {
expect(parsed.searchParams.get("check")).toBe("true");
});

it("deletes stale non-live deployments on a normal deploy", async () => {
it("deletes stale non-live deployments before deploy and previous live deployment after promotion", async () => {
const deletedIds: string[] = [];

server.use(
Expand All @@ -318,7 +318,47 @@ describe("Deploy API", () => {

await deployToMain(config, resources, { pollIntervalMs: 1 });

expect(deletedIds).toEqual(["stale-1", "stale-2"]);
expect(deletedIds).toEqual(["stale-1", "stale-2", "live-1"]);
});

it("deletes the previous live deployment after promoting the new deployment", async () => {
const events: string[] = [];

server.use(
http.get(`${BASE_URL}/v1/deployments`, () => {
return HttpResponse.json(
createDeploymentsListResponse({
deployments: [
{ id: "previous-live", status: "live", live: true },
],
})
);
}),
http.post(`${BASE_URL}/v1/deploy`, () => {
events.push("create");
return HttpResponse.json(
createDeploySuccessResponse({ deploymentId: "new-deploy", status: "pending" })
);
}),
http.get(`${BASE_URL}/v1/deployments/new-deploy`, () => {
return HttpResponse.json(
createDeploymentStatusResponse({ deploymentId: "new-deploy", status: "data_ready" })
);
}),
http.post(`${BASE_URL}/v1/deployments/new-deploy/set-live`, () => {
events.push("set-live");
return HttpResponse.json(createSetLiveSuccessResponse());
}),
http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => {
events.push(`delete:${params.id as string}`);
return HttpResponse.json({ result: "success" });
})
);

const result = await deployToMain(config, resources, { pollIntervalMs: 1 });

expect(result.success).toBe(true);
expect(events).toEqual(["create", "set-live", "delete:previous-live"]);
});

it("adds actionable guidance to Forward/Classic workspace errors", async () => {
Expand Down
34 changes: 34 additions & 0 deletions src/api/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export async function deployToMain(
const pollIntervalMs = options?.pollIntervalMs ?? 1000;
const maxPollAttempts = options?.maxPollAttempts ?? 120; // 2 minutes max
const baseUrl = config.baseUrl.replace(/\/$/, "");
let previousLiveDeploymentId: string | undefined;

const formData = new FormData();

Expand Down Expand Up @@ -230,6 +231,10 @@ export async function deployToMain(

if (deploymentsResponse.ok) {
const deploymentsBody = (await deploymentsResponse.json()) as DeploymentsListResponse;
const previousLiveDeployment = deploymentsBody.deployments.find(
(d) => d.live || d.status === "live"
);
previousLiveDeploymentId = previousLiveDeployment?.id;
const staleDeployments = deploymentsBody.deployments.filter(
(d) => !d.live && d.status !== "live"
);
Expand Down Expand Up @@ -525,6 +530,35 @@ export async function deployToMain(
console.log(`[debug] Deployment ${deploymentId} is now live`);
}

if (previousLiveDeploymentId && previousLiveDeploymentId !== deploymentId) {
if (debug) {
console.log(`[debug] Removing previous deployment: ${previousLiveDeploymentId}`);
}

const deletePreviousResponse = await tinybirdFetch(
`${baseUrl}/v1/deployments/${previousLiveDeploymentId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${config.token}`,
},
}
);

if (!deletePreviousResponse.ok) {
const deletePreviousBody = await deletePreviousResponse.text();
return {
success: false,
result: "failed",
error: `Failed to remove previous deployment: ${deletePreviousResponse.status} ${deletePreviousResponse.statusText}\n${deletePreviousBody}`,
datasourceCount: resources.datasources.length,
pipeCount: resources.pipes.length,
connectionCount: resources.connections?.length ?? 0,
buildId: deploymentId,
};
}
}

options?.callbacks?.onDeploymentLive?.(deploymentId);

return {
Expand Down
Loading