Files
Saadi Myftija 255a73a2fe feat(deployments): --native-build-server support for the deploy command (#2702)
This PR adds support for CLI deployments using the native build server.

**Background**

The deployment command currently does the following:
- bundles the code
- submits the build context to our external build provider and waits for
the build
- triggers deployment state transitions using the platform API

Upstream build provider outages cause issue with deployments,
potentially blocking deployments entirely. We recently introduced the
`--force-local-build` flag as a fallback to enable deployment without a
dependency on the upstream build provider, though it requires users to
have docker in their systems. This PR continues that work by providing a
remote build path which uses our own build server and does not rely on
the external provider.

**Changes in this PR**

Introduced the new `--native-build-server` flag, which does the
following:
- scans all files relevant for the Trigger deployment and evaluates
ignore rules
- packages it up in an archive and uploads it as a deployment artifact
- queues the deployment and triggers the build
- streams logs from the build server

This no longer relies on external build services. Also deployment state
transitions happen on the server-side, giving us more flexibility to
evolve the flow and schemas of related deployment API endpoints. In
general it gives us better control of the whole build and deployment
process. This path will eventually become the default.

The `--detach` flag is also new, allowing to trigger deployments without
waiting for the result.

The deployment artifacts are uploaded via pre-signed URLs to avoid
unnecessary load on the platform. The new `/artifacts` endpoint
generates the pre-signed URLs; size limits are enforced on s3. This
endpoint is deliberately generic, we could extend it in the future to
upload other artifacts client-side in a similar way, e.g., large payload
packets.
2025-12-03 16:40:21 +01:00

93 lines
2.9 KiB
TypeScript

import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService } from "./baseService.server";
import { env } from "~/env.server";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { S3Client } from "@aws-sdk/client-s3";
import { customAlphabet } from "nanoid";
import { errAsync, fromPromise } from "neverthrow";
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 24);
const objectStoreClient =
env.ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID &&
env.ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY &&
env.ARTIFACTS_OBJECT_STORE_BASE_URL
? new S3Client({
credentials: {
accessKeyId: env.ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID,
secretAccessKey: env.ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY,
},
region: env.ARTIFACTS_OBJECT_STORE_REGION,
endpoint: env.ARTIFACTS_OBJECT_STORE_BASE_URL,
forcePathStyle: true,
})
: new S3Client();
const artifactKeyPrefixByType = {
deployment_context: "deployments",
} as const;
const artifactBytesSizeLimitByType = {
deployment_context: 100 * 1024 * 1024, // 100MB
} as const;
export class ArtifactsService extends BaseService {
private readonly bucket = env.ARTIFACTS_OBJECT_STORE_BUCKET;
public createArtifact(
type: "deployment_context",
authenticatedEnv: AuthenticatedEnvironment,
contentLength?: number
) {
const limit = artifactBytesSizeLimitByType[type];
// this is just a validation using client-side data
// the actual limit will be enforced by S3
if (contentLength && contentLength > limit) {
return errAsync({
type: "artifact_size_exceeds_limit" as const,
contentLength,
sizeLimit: limit,
});
}
const uniqueId = nanoid();
const key = `${artifactKeyPrefixByType[type]}/${authenticatedEnv.project.externalRef}/${authenticatedEnv.slug}/${uniqueId}.tar.gz`;
return this.createPresignedPost(key, limit, contentLength).map((result) => ({
artifactKey: key,
uploadUrl: result.url,
uploadFields: result.fields,
expiresAt: result.expiresAt,
}));
}
private createPresignedPost(key: string, sizeLimit: number, contentLength?: number) {
if (!this.bucket) {
return errAsync({
type: "artifacts_bucket_not_configured" as const,
});
}
const ttlSeconds = 300; // 5 minutes
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
return fromPromise(
createPresignedPost(objectStoreClient, {
Bucket: this.bucket,
Key: key,
Conditions: [["content-length-range", 0, sizeLimit]],
Fields: {
"Content-Type": "application/gzip",
},
Expires: ttlSeconds,
}),
(error) => ({
type: "failed_to_create_presigned_post" as const,
cause: error,
})
).map((result) => ({
...result,
expiresAt,
}));
}
}