diff --git a/.changeset/nice-pillows-hide.md b/.changeset/nice-pillows-hide.md new file mode 100644 index 0000000000..c8a00be105 --- /dev/null +++ b/.changeset/nice-pillows-hide.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Added a hint about the `--force-local-build` flag on failed deployments due to upstream provider outages. diff --git a/apps/webapp/app/routes/api.v1.remote-build-provider-status.ts b/apps/webapp/app/routes/api.v1.remote-build-provider-status.ts new file mode 100644 index 0000000000..555194e34d --- /dev/null +++ b/apps/webapp/app/routes/api.v1.remote-build-provider-status.ts @@ -0,0 +1,94 @@ +import { json } from "@remix-run/node"; +import { err, fromPromise, fromSafePromise, ok } from "neverthrow"; +import z from "zod"; +import { logger } from "~/services/logger.server"; +import { type RemoteBuildProviderStatusResponseBody } from "@trigger.dev/core/v3/schemas"; + +const DEPOT_STATUS_URL = "https://status.depot.dev/proxy/status.depot.dev"; +const FETCH_TIMEOUT_MS = 2000; + +export async function loader() { + return await fetchDepotStatus().match( + ({ summary: { ongoing_incidents } }) => { + if (ongoing_incidents.length > 0) { + return json( + { + status: "degraded", + message: + "Our remote build provider is currently facing issues. You can use the `--force-local-build` flag to build and deploy the image locally. Read more about local builds here: https://trigger.dev/docs/deployment/overview#local-builds", + } satisfies RemoteBuildProviderStatusResponseBody, + { status: 200 } + ); + } + + return json( + { + status: "operational", + message: "Depot is operational", + } satisfies RemoteBuildProviderStatusResponseBody, + { status: 200 } + ); + }, + () => { + return json( + { + status: "unknown", + message: "Failed to fetch remote build provider status", + } satisfies RemoteBuildProviderStatusResponseBody, + { status: 200 } + ); + } + ); +} + +function fetchDepotStatus() { + return fromPromise( + fetch(DEPOT_STATUS_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }), + (error) => { + if ( + error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError") + ) { + return { + type: "timeout" as const, + }; + } + + return { + type: "other" as const, + cause: error, + }; + } + ) + .andThen((response) => { + if (!response.ok) { + return err({ + type: "other" as const, + cause: new Error(`Failed to fetch Depot status: ${response.status}`), + }); + } + + return fromSafePromise(response.json()); + }) + .andThen((json) => { + const parsed = DepotStatusResponseSchema.safeParse(json); + + if (!parsed.success) { + logger.warn("Invalid Depot status response", { error: parsed.error }); + return err({ + type: "validation_failed" as const, + }); + } + + return ok(parsed.data); + }); +} + +// partial schema +const DepotStatusResponseSchema = z.object({ + summary: z.object({ + ongoing_incidents: z.array(z.any()), + }), +}); diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 6f65bce114..4a2f6232c0 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -22,7 +22,6 @@ import { PromoteDeploymentResponseBody, StartDeploymentIndexingRequestBody, StartDeploymentIndexingResponseBody, - TaskRunExecution, TriggerTaskRequestBody, TriggerTaskResponse, UpsertBranchRequestBody, @@ -38,6 +37,7 @@ import { GetJWTResponse, ApiBranchListResponseBody, GenerateRegistryCredentialsResponseBody, + RemoteBuildProviderStatusResponseBody, } from "@trigger.dev/core/v3"; import { WorkloadDebugLogRequestBody, @@ -52,6 +52,7 @@ import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfe import { EventSource } from "eventsource"; import { z } from "zod"; import { logger } from "./utilities/logger.js"; +import { VERSION } from "./version.js"; export class CliApiClient { private engineURL: string; @@ -328,6 +329,21 @@ export class CliApiClient { ); } + async getRemoteBuildProviderStatus() { + return wrapZodFetch( + RemoteBuildProviderStatusResponseBody, + `${this.apiURL}/api/v1/remote-build-provider-status`, + { + method: "GET", + headers: { + ...this.getHeaders(), + // probably a good idea to add this to the other requests too + "x-trigger-cli-version": VERSION, + }, + } + ); + } + async generateRegistryCredentials(deploymentId: string) { if (!this.accessToken) { throw new Error("generateRegistryCredentials: No access token"); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 4c7bbd92bf..36f35d442e 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -28,7 +28,13 @@ import { printWarnings, saveLogs, } from "../deploy/logs.js"; -import { chalkError, cliLink, isLinksSupported, prettyError } from "../utilities/cliOutput.js"; +import { + chalkError, + cliLink, + isLinksSupported, + prettyError, + prettyWarning, +} from "../utilities/cliOutput.js"; import { loadDotEnvVars } from "../utilities/dotEnv.js"; import { isDirectory } from "../utilities/fileSystem.js"; import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; @@ -128,9 +134,7 @@ export function configureDeployCommand(program: Command) { ).hideHelp() ) // Local build options - .addOption( - new CommandOption("--force-local-build", "Force a local build of the image").hideHelp() - ) + .addOption(new CommandOption("--force-local-build", "Force a local build of the image")) .addOption(new CommandOption("--push", "Push the image after local builds").hideHelp()) .addOption( new CommandOption("--no-push", "Do not push the image after local builds").hideHelp() @@ -458,6 +462,17 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const warnings = checkLogsForWarnings(buildResult.logs); + const canShowLocalBuildHint = !isLocalBuild && !process.env.TRIGGER_LOCAL_BUILD_HINT_DISABLED; + const buildFailed = !warnings.ok || !buildResult.ok; + + if (buildFailed && canShowLocalBuildHint) { + const providerStatus = await projectClient.client.getRemoteBuildProviderStatus(); + + if (providerStatus.success && providerStatus.data.status === "degraded") { + prettyWarning(providerStatus.data.message + "\n"); + } + } + if (!warnings.ok) { await failDeploy( projectClient.client, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index c86f302a66..b018b2a4a8 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -439,6 +439,15 @@ export const InitializeDeploymentRequestBody = z.object({ export type InitializeDeploymentRequestBody = z.infer; +export const RemoteBuildProviderStatusResponseBody = z.object({ + status: z.enum(["operational", "degraded", "unknown"]), + message: z.string(), +}); + +export type RemoteBuildProviderStatusResponseBody = z.infer< + typeof RemoteBuildProviderStatusResponseBody +>; + export const GenerateRegistryCredentialsResponseBody = z.object({ username: z.string(), password: z.string(),