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
9 changes: 9 additions & 0 deletions apps/server/src/cloud/pinnedRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export class PinnedRuntimeInstallError extends Schema.TaggedError<PinnedRuntimeI
exitCode: Schema.optional(Schema.Number),
stdoutLength: Schema.optional(Schema.Number),
stderrLength: Schema.optional(Schema.Number),
httpStatus: Schema.optional(Schema.Number),
downloadHost: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
},
) {
Expand Down Expand Up @@ -163,6 +165,13 @@ const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(
(cause) =>
new PinnedRuntimeInstallError({
step: cause._tag === "TimeoutError" ? `${step} (timed out)` : step,
httpStatus:
HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError"
? cause.reason.response.status
: undefined,
downloadHost: URL.parse(
HttpClientError.isHttpClientError(cause) ? cause.request.url : url,
)?.host,
cause,
}),
),
Expand Down
42 changes: 39 additions & 3 deletions apps/server/src/cloud/selfUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/http";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import packageJson from "../../package.json" with { type: "json" };
Expand All @@ -30,6 +30,7 @@ interface HarnessOptions {
readonly preflightDuration?: Duration.Input;
readonly preflightStarted?: Deferred.Deferred<void>;
readonly beforeDownload?: Effect.Effect<void>;
readonly httpClient?: HttpClient.HttpClient;
readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
readonly policy?: ManagedPolicyDocument;
Expand Down Expand Up @@ -145,7 +146,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
),
Effect.provideService(
HttpClient.HttpClient,
releaseHttpClient(order, urls, options.beforeDownload),
options.httpClient ?? releaseHttpClient(order, urls, options.beforeDownload),
),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Architecture, "x64"),
Expand All @@ -156,10 +157,45 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
),
),
);
return { selfUpdate, order, urls };
return { selfUpdate, order, urls, baseDir };
});

it.layer(NodeServices.layer)("server self update", (it) => {
it.effect.each([
{ asset: "checksums", status: 504 },
{ asset: "archive", status: 503 },
{ asset: "checksums", status: 403 },
])("reports an HTTP $status downloading $asset and allows retry", ({ asset, status }) =>
Effect.gen(function* () {
let failDownload = true;
const { selfUpdate, order, baseDir } = yield* makeHarness({
httpClient: releaseHttpClient([], []).pipe(
HttpClient.transform((response, request) =>
failDownload && request.url.endsWith("/SHA256SUMS") === (asset === "checksums")
? Effect.succeed(
HttpClientResponse.fromWeb(
HttpClientRequest.get("https://user:password@cdn.example/private?token=secret"),
new Response("private response body", { status }),
),
)
: response,
),
),
});
const error = yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip);
expect(error.reason).toBe(
`Could not prepare t3@1.1.0 while downloading the t3 release ${asset} (HTTP ${status} from cdn.example).`,
);
expect(order).toEqual([]);
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
expect(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions"))).toEqual([]);
failDownload = false;
expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id");
expect(order).toEqual(["extract", "preflight", "accept"]);
}),
);

// regression: #5181 make remote updates rollback-safe
it.effect("rejects invalid versions and desktop-managed servers before staging", () =>
Effect.gen(function* () {
Expand Down
31 changes: 21 additions & 10 deletions apps/server/src/cloud/selfUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,16 +282,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
),
),
}).pipe(
Effect.mapError((error) =>
error._tag === "PinnedRuntimePreflightBlockedError"
? failWith(error.reason, error)
: mirror !== undefined && isUnpublishedRelease(error)
? failWith(
`${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`,
error,
)
: failWith(`Could not prepare t3@${targetVersion}.`, error),
),
Effect.mapError((error) => {
if (error._tag === "PinnedRuntimePreflightBlockedError") {
return failWith(error.reason, error);
}
if (mirror !== undefined && isUnpublishedRelease(error)) {
return failWith(
`${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`,
error,
);
}
const downloadFailure = [
error.httpStatus === undefined ? undefined : `HTTP ${error.httpStatus}`,
error.downloadHost === undefined ? undefined : `from ${error.downloadHost}`,
]
.filter((part) => part !== undefined)
.join(" ");
return failWith(
`Could not prepare t3@${targetVersion} while ${error.step}${downloadFailure ? ` (${downloadFailure})` : ""}.`,
error,
);
}),
);

yield* reportProgress("installing");
Expand Down
Loading