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
34 changes: 28 additions & 6 deletions apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* v8 ignore start */
import { z } from "@hono/zod-openapi";

const UIntStringSchema = z.string().regex(/^\d+$/, "Must be an unsigned integer string");
Expand All @@ -6,16 +7,37 @@ const ResourceValueSchema = z.object({
val: UIntStringSchema.openapi({ description: "String-encoded integer value", example: "1000" })
});

// Mirrors AttributeNameRegexpStringWildcard in akash-network/chain-sdk
// (go/node/types/v1beta3/attribute.go) — only trailing `*` is a permitted glob metachar.
const SDL_ATTRIBUTE_KEY_REGEX = /^([a-zA-Z][\w/.-]{1,126}[\w*]?)$/;
const AttributeSchema = z.object({
key: z.string().openapi({ description: "Attribute key", example: "persistent" }),
key: z
.string()
.min(1)
.max(128)
.regex(SDL_ATTRIBUTE_KEY_REGEX, "Invalid attribute key format")
.openapi({ description: "Attribute key", example: "persistent" }),
value: z.string().openapi({ description: "Attribute value", example: "false" })
});

const StorageResourceSchema = z.object({
name: z.string().openapi({ description: "Storage volume name", example: "default" }),
quantity: ResourceValueSchema,
attributes: z.array(AttributeSchema)
});
const StorageResourceSchema = z
.object({
name: z.string().openapi({ description: "Storage volume name", example: "default" }),
quantity: ResourceValueSchema,
attributes: z.array(AttributeSchema)
})
.superRefine((vol, ctx) => {
const isPersistent = vol.attributes.some(a => a.key === "persistent" && a.value === "true");
if (!isPersistent) return;
const storageClass = vol.attributes.find(a => a.key === "class")?.value;
if (!storageClass || storageClass === "ram") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Persistent storage volume "${vol.name}" must specify a valid storage class (not "${storageClass || "empty"}")`,
path: ["attributes"]
});
}
});
Comment thread
stalniy marked this conversation as resolved.

const ResourceSchema = z.object({
id: z.number().int().openapi({ description: "Resource unit ID", example: 1 }),
Expand Down
15 changes: 15 additions & 0 deletions apps/api/test/functional/__snapshots__/docs.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2759,6 +2759,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"key": {
"description": "Attribute key",
"example": "persistent",
"maxLength": 128,
"minLength": 1,
"pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$",
"type": "string",
},
"value": {
Expand Down Expand Up @@ -2834,6 +2837,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"key": {
"description": "Attribute key",
"example": "persistent",
"maxLength": 128,
"minLength": 1,
"pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$",
"type": "string",
},
"value": {
Expand Down Expand Up @@ -2885,6 +2891,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"key": {
"description": "Attribute key",
"example": "persistent",
"maxLength": 128,
"minLength": 1,
"pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$",
"type": "string",
},
"value": {
Expand Down Expand Up @@ -2935,6 +2944,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"key": {
"description": "Attribute key",
"example": "persistent",
"maxLength": 128,
"minLength": 1,
"pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$",
"type": "string",
},
"value": {
Expand Down Expand Up @@ -2981,6 +2993,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"key": {
"description": "Attribute key",
"example": "persistent",
"maxLength": 128,
"minLength": 1,
"pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$",
"type": "string",
},
"value": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,37 @@ const ResourceValueSchema = z.object({
val: UIntStringSchema.openapi({ description: "String-encoded integer value", example: "1000" })
});

// Mirrors AttributeNameRegexpStringWildcard in akash-network/chain-sdk
// (go/node/types/v1beta3/attribute.go) — only trailing `*` is a permitted glob metachar.
const SDL_ATTRIBUTE_KEY_REGEX = /^([a-zA-Z][\w/.-]{1,126}[\w*]?)$/;
const AttributeSchema = z.object({
key: z.string().openapi({ description: "Attribute key", example: "persistent" }),
key: z
.string()
.min(1)
.max(128)
.regex(SDL_ATTRIBUTE_KEY_REGEX, "Invalid attribute key format")
.openapi({ description: "Attribute key", example: "persistent" }),
value: z.string().openapi({ description: "Attribute value", example: "false" })
});

const StorageResourceSchema = z.object({
name: z.string().openapi({ description: "Storage volume name", example: "default" }),
quantity: ResourceValueSchema,
attributes: z.array(AttributeSchema)
});
const StorageResourceSchema = z
.object({
name: z.string().openapi({ description: "Storage volume name", example: "default" }),
quantity: ResourceValueSchema,
attributes: z.array(AttributeSchema)
})
.superRefine((vol, ctx) => {
const isPersistent = vol.attributes.some(a => a.key === "persistent" && a.value === "true");
if (!isPersistent) return;
const storageClass = vol.attributes.find(a => a.key === "class")?.value;
if (!storageClass || storageClass === "ram") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Persistent storage volume "${vol.name}" must specify a valid storage class (not "${storageClass || "empty"}")`,
path: ["attributes"]
});
}
});

const ResourceSchema = z.object({
id: z.number().int().openapi({ description: "Resource unit ID", example: 1 }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";

import { hydrateClusterState } from "./hydrate-cluster-state";

describe(hydrateClusterState.name, () => {
it("rebuilds nodes with ResourcePair instances from persisted plain objects", () => {
const cluster = hydrateClusterState({
nodes: [
{
name: "node1",
cpu: { allocatable: 8000, allocated: 2000 },
memory: { allocatable: 17179869184, allocated: 4294967296 },
ephemeralStorage: { allocatable: 107374182400, allocated: 0 },
gpu: { quantity: { allocatable: 0, allocated: 0 }, info: [] },
storageClasses: ["beta2"],
cpus: []
}
],
storage: {}
});

const node = cluster.nodes[0];
expect(node.name).toBe("node1");
expect(node.cpu.allocatable).toBe(8000n);
expect(node.cpu.allocated).toBe(2000n);
expect(node.memory.allocatable).toBe(17179869184n);
expect(node.memory.allocated).toBe(4294967296n);
expect(node.ephemeralStorage.allocatable).toBe(107374182400n);
expect(node.ephemeralStorage.allocated).toBe(0n);
expect(node.storageClasses).toEqual(["beta2"]);
});

it("hydrates cluster storage pools as ResourcePair entries keyed by class", () => {
const cluster = hydrateClusterState({
nodes: [],
storage: { beta2: { class: "beta2", quantity: { allocatable: 536870912000, allocated: 0 } } }
});

const beta2 = cluster.storage["beta2"];
expect(beta2.class).toBe("beta2");
expect(beta2.quantity.allocatable).toBe(536870912000n);
expect(beta2.quantity.allocated).toBe(0n);
});

it("preserves bigint magnitudes that exceed Number.MAX_SAFE_INTEGER", () => {
const unsafe = 9007199254740993n;
const cluster = hydrateClusterState({
nodes: [
{
name: "node1",
cpu: { allocatable: unsafe, allocated: 0 },
memory: { allocatable: 0, allocated: 0 },
ephemeralStorage: { allocatable: 0, allocated: 0 },
gpu: { quantity: { allocatable: 0, allocated: 0 }, info: [] },
storageClasses: [],
cpus: []
}
],
storage: {}
});

expect(cluster.nodes[0].cpu.allocatable).toBe(unsafe);
});

it("returns empty cluster for nullish input", () => {
const cluster = hydrateClusterState(undefined);
expect(cluster.nodes).toEqual([]);
expect(cluster.storage).toEqual({});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ResourcePair } from "@src/lib/resource-pair/resource-pair";
import type { ClusterState, NodeState } from "@src/types/inventory.types";

type RawPair = { allocatable: number | bigint; allocated: number | bigint };
type RawNode = Omit<NodeState, "cpu" | "memory" | "ephemeralStorage" | "gpu"> & {
cpu: RawPair;
memory: RawPair;
ephemeralStorage: RawPair;
gpu: { quantity: RawPair; info: NodeState["gpu"]["info"] };
};
type RawCluster = {
nodes?: RawNode[];
storage?: Record<string, { class: string; quantity: RawPair }>;
};

export function hydrateClusterState(raw: unknown): ClusterState {
const cluster = (raw ?? {}) as RawCluster;
const nodes = (cluster.nodes ?? []).map(hydrateNode);
const storage: ClusterState["storage"] = Object.create(null);
for (const [key, pool] of Object.entries(cluster.storage ?? {})) {
storage[key] = { class: pool.class, quantity: hydratePair(pool.quantity) };
}
return { nodes, storage };
}

function hydrateNode(node: RawNode): NodeState {
return {
name: node.name,
cpu: hydratePair(node.cpu),
memory: hydratePair(node.memory),
ephemeralStorage: hydratePair(node.ephemeralStorage),
gpu: { quantity: hydratePair(node.gpu.quantity), info: node.gpu.info ?? [] },
storageClasses: node.storageClasses ?? [],
cpus: node.cpus ?? []
};
}

function hydratePair(pair: RawPair): ResourcePair {
const allocatable = typeof pair.allocatable === "bigint" ? pair.allocatable : BigInt(pair.allocatable || 0);
const allocated = typeof pair.allocated === "bigint" ? pair.allocated : BigInt(pair.allocated || 0);

return new ResourcePair(allocatable, allocated);
}
Comment thread
stalniy marked this conversation as resolved.
Loading
Loading