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
89 changes: 89 additions & 0 deletions apps/dashboard/src/api/amacrin/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { ApiError, SlugTakenError } from "@/api/http/errors";
import type { Archive } from "@/domain/archive";
import type { Build, ComponentBuild } from "@/domain/build";
import type { Deployment } from "@/domain/deployment";
import type { OsaVersion } from "@/domain/osa-version";
import { compareOsaVersions } from "@/domain/osa-version";
import type { Organisation } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { Session } from "@/domain/user";
Expand All @@ -32,6 +34,32 @@ function notFound(what: string): ApiError {
/** The OSA server version the mock control plane provisions. */
const MOCK_OSA_VERSION = "v0.0.9";

/** The mock registry: one older deprecated, the pin, and two upgrades. */
const MOCK_OSA_VERSIONS: OsaVersion[] = [
{
version: "v0.0.11",
status: "supported",
isDefault: true,
notesUrl:
"https://github.com/opensciencearchive/server/releases/tag/v0.0.11",
},
{
version: "v0.0.10",
status: "supported",
isDefault: false,
notesUrl:
"https://github.com/opensciencearchive/server/releases/tag/v0.0.10",
},
{
version: "v0.0.9",
status: "supported",
isDefault: false,
notesUrl:
"https://github.com/opensciencearchive/server/releases/tag/v0.0.9",
},
{ version: "v0.0.8", status: "deprecated", isDefault: false, notesUrl: null },
];

/** The signed-in user, as a member of any org they create. */
const SELF_MEMBER: OrgMember = {
userId: "user_mock000001",
Expand Down Expand Up @@ -152,6 +180,10 @@ export class MockAmacrinService implements AmacrinService {
organisationId: orgId,
name: input.name,
slug: input.slug,
// New archives are born on the registry default (the cloud rule).
osaVersionPin:
MOCK_OSA_VERSIONS.find((v) => v.isDefault)?.version ??
MOCK_OSA_VERSION,
domain: `${input.slug}.amacr.in`,
status: { kind: "deploying" },
orcidAdmins: input.adminOrcidIds,
Expand Down Expand Up @@ -217,6 +249,62 @@ export class MockAmacrinService implements AmacrinService {
return Promise.resolve([state.deployment, ...state.past]);
}

listOsaVersions(): Promise<OsaVersion[]> {
return Promise.resolve([...MOCK_OSA_VERSIONS]);
}

upgradeArchive(archiveId: string, toVersion: string): Promise<Deployment> {
const state = this.archives.get(archiveId);
if (!state) return Promise.reject(notFound("archive"));
const target = MOCK_OSA_VERSIONS.find((v) => v.version === toVersion);
if (!target) return Promise.reject(notFound("OSA version"));
if (target.status !== "supported") {
return Promise.reject(
new ApiError({
status: 400,
code: "validation_error",
message: `OSA version '${toVersion}' is ${target.status} — not an upgrade target`,
}),
);
}
if (compareOsaVersions(toVersion, state.archive.osaVersionPin) <= 0) {
return Promise.reject(
new ApiError({
status: 400,
code: "validation_error",
message: `'${toVersion}' is not newer than the archive's current version '${state.archive.osaVersionPin}' — upgrades are forward-only`,
}),
);
}
if (state.archive.status.kind === "deploying") {
return Promise.reject(
new ApiError({
status: 422,
code: "invalid_state",
message: "deployment already in progress for this archive",
}),
);
}
// The pin moves WITH the deployment start — mirroring the cloud tx.
state.archive = {
...state.archive,
osaVersionPin: toVersion,
status: { kind: "deploying" },
updatedAt: T0,
};
state.past = [state.deployment, ...state.past];
state.deployment = {
id: this.mintId("deploy"),
archiveId,
provider: "aws_eks",
status: { kind: "pending" },
osaVersion: null,
startedAt: new Date(state.past[0]!.startedAt.getTime() + 60_000),
};
state.pollsUntilAdvance = 2;
return Promise.resolve(state.deployment);
}

destroyArchive(
archiveId: string,
opts?: { force?: boolean },
Expand Down Expand Up @@ -402,6 +490,7 @@ export class MockAmacrinService implements AmacrinService {
const archive: Archive = {
...args,
domain: `${args.slug}.amacr.in`,
osaVersionPin: MOCK_OSA_VERSION,
deploymentConfig: {
provider: "aws_eks",
region: "eu-west-1",
Expand Down
17 changes: 17 additions & 0 deletions apps/dashboard/src/api/amacrin/real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ApiError, SlugTakenError } from "@/api/http/errors";
import type { Archive } from "@/domain/archive";
import type { Build } from "@/domain/build";
import type { Deployment } from "@/domain/deployment";
import type { OsaVersion } from "@/domain/osa-version";
import type { Organisation } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { Session } from "@/domain/user";
Expand All @@ -19,6 +20,7 @@ import {
decodeOrganisation,
decodeOrganisationList,
decodeSession,
decodeOsaVersionList,
} from "./wire/decode";
import type {
AmacrinService,
Expand Down Expand Up @@ -148,6 +150,21 @@ export class RealAmacrinService implements AmacrinService {
);
}

async listOsaVersions(): Promise<OsaVersion[]> {
return decodeOsaVersionList(await this.client.get("/api/v1/osa-versions"));
}

async upgradeArchive(
archiveId: string,
toVersion: string,
): Promise<Deployment> {
return decodeDeployment(
await this.client.post(`/api/v1/archives/${archiveId}/upgrade`, {
to_version: toVersion,
}),
);
}

async destroyArchive(
archiveId: string,
opts?: { force?: boolean },
Expand Down
9 changes: 9 additions & 0 deletions apps/dashboard/src/api/amacrin/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { Archive } from "@/domain/archive";
import type { Build } from "@/domain/build";
import type { Deployment } from "@/domain/deployment";
import type { OsaVersion } from "@/domain/osa-version";
import type { Organisation } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { Session } from "@/domain/user";
Expand Down Expand Up @@ -64,6 +65,14 @@ export interface AmacrinService {
getDeploymentStatus(archiveId: string): Promise<Deployment>;
/** GET /archives/{id}/deployments — deployment history, newest first. */
listDeployments(archiveId: string): Promise<Deployment[]>;
/** GET /osa-versions — the registry, newest first (#222). */
listOsaVersions(): Promise<OsaVersion[]>;
/**
* POST /archives/{id}/upgrade — move the version pin, forward-only (202).
* The pin moves in the same transaction that starts the deployment: a
* failed upgrade deployment retries at the NEW version, never rolls back.
*/
upgradeArchive(archiveId: string, toVersion: string): Promise<Deployment>;
/** POST /archives/{id}/destroy — the only removal path (Owner only). */
destroyArchive(
archiveId: string,
Expand Down
24 changes: 24 additions & 0 deletions apps/dashboard/src/api/amacrin/wire/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
import type { Deployment, DeploymentStatus } from "@/domain/deployment";
import { type Organisation, isRole } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { OsaVersion } from "@/domain/osa-version";
import type { Session } from "@/domain/user";

import {
Expand All @@ -40,6 +41,7 @@ import {
wireOrgMemberList,
wireOrganisation,
wireOrganisationList,
wireOsaVersionList,
} from "./schemas";

export class DecodeError extends Error {
Expand Down Expand Up @@ -175,6 +177,7 @@ export function decodeArchive(raw: unknown): Archive {
domain: wire.domain,
status: toArchiveStatus(wire.status, wire.error_message),
orcidAdmins: wire.config.auth?.admins?.orcid ?? [],
osaVersionPin: wire.osa_version_pin,
deploymentConfig,
createdAt: parseDate(wire.created_at, "archive"),
updatedAt: parseDate(wire.updated_at, "archive"),
Expand Down Expand Up @@ -364,3 +367,24 @@ export function decodeBuildList(raw: unknown): BuildListItem[] {
createdAt: parseDate(wire.created_at, "build"),
}));
}

// ---------------------------------------------------------------------------
// OSA versions (#222)
// ---------------------------------------------------------------------------

const OSA_VERSION_STATUSES = ["supported", "deprecated", "withdrawn"] as const;

export function decodeOsaVersionList(raw: unknown): OsaVersion[] {
return parse(wireOsaVersionList, raw, "osa-versions").map((wire) => {
const status = OSA_VERSION_STATUSES.find((s) => s === wire.status);
if (!status) {
throw new DecodeError(`unknown OSA version status "${wire.status}"`);
}
return {
version: wire.version,
status,
isDefault: wire.is_default,
notesUrl: wire.notes_url ?? null,
};
});
}
15 changes: 15 additions & 0 deletions apps/dashboard/src/api/amacrin/wire/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,28 @@ export const wireArchive = z.object({
domain: z.string(),
config: wireArchiveConfig,
status: z.string(),
/** Desired version (always present); deployed version rides osa_version. */
osa_version_pin: z.string(),
osa_version: z.string().nullish(),
error_message: z.string().nullish(),
created_at: z.string(),
updated_at: z.string(),
});

export const wireArchiveList = z.array(wireArchive);

export const wireOsaVersion = z.object({
version: z.string(),
image_digest: z.string().nullish(),
status: z.string(),
is_default: z.boolean(),
released_at: z.string().nullish(),
registered_at: z.string(),
notes_url: z.string().nullish(),
});

export const wireOsaVersionList = z.array(wireOsaVersion);

export const wireDeployment = z.object({
id: z.string(),
archive_id: z.string(),
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/api/osa/wire/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,5 @@ export const wireNodeOverview = z.object({
status: z.enum(["ready", "degraded", "unknown"]),
records: z.number().nullable(),
schemas: z.number(),
latestOsaVersion: z.string().nullable().default(null),
});
17 changes: 13 additions & 4 deletions apps/dashboard/src/app/api/node/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";

import { latestOsaVersion } from "@/server/ghcr";
import { osaApiUrl, sessionSecret } from "@/server/env";
import { SESSION_COOKIE, readSession } from "@/server/session";

Expand Down Expand Up @@ -38,10 +39,17 @@ export async function GET(req: NextRequest): Promise<NextResponse> {

const base = osaApiUrl();
const auth = { authorization: `Bearer ${session.osaToken}` };
const [discRes, readyRes, statsRes] = await Promise.allSettled([
fetch(`${base}/`, { headers: { accept: "application/json" } }),
fetch(`${base}/api/v1/ready`, { headers: { accept: "application/json" } }),
fetch(`${base}/api/v1/stats`, { headers: { ...auth, accept: "application/json" } }),
// Update awareness (#222) rides along: latest published release from the
// registry, cached ~1h, null on failure — never blocks the overview.
const [[discRes, readyRes, statsRes], latest] = await Promise.all([
Promise.allSettled([
fetch(`${base}/`, { headers: { accept: "application/json" } }),
fetch(`${base}/api/v1/ready`, { headers: { accept: "application/json" } }),
fetch(`${base}/api/v1/stats`, {
headers: { ...auth, accept: "application/json" },
}),
]),
latestOsaVersion(),
]);
Comment on lines +44 to 53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Optional registry lookup blocks overview

When GHCR responds slowly or stalls, /api/node waits for latestOsaVersion() before returning completed local node data, causing the self-host overview to remain on its loading skeleton and the settings version row to stay absent. Bound this optional lookup or decouple it from the core response so registry availability can degrade independently.

Knowledge Base Used: Management Dashboard (apps/dashboard)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f50cb7 — the whole ghcr lookup now runs under a single 4s AbortSignal.timeout deadline (token fetch + every tags page), so a stalled registry resolves to null and /api/node returns the local node data without waiting.


const discovery = await json(discRes);
Expand All @@ -63,5 +71,6 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
: "unknown",
records: typeof stats.records === "number" ? stats.records : null,
schemas: Array.isArray(schemas) ? schemas.length : 0,
latestOsaVersion: latest,
});
}
Loading
Loading