Skip to content

Commit 937b486

Browse files
committed
chore(sdl): merge main into aep-86 sdk branch
Resolve the SDL validator conflict by preserving the AEP-86 verification schema and the upstream reclamation duration validation added on main. Signed-off-by: Joseph Chalabi <chalabi.joseph@gmail.com>
2 parents d312b2b + 0ed2e9b commit 937b486

11 files changed

Lines changed: 243 additions & 7 deletions

File tree

go/sdl/sdl-input.schema.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -565,10 +565,10 @@ properties:
565565
properties:
566566
min_window:
567567
type: string
568+
pattern: "^[1-9][0-9]*(s|m|h)$"
568569
description: >-
569-
Minimum reclamation window duration the tenant requires.
570-
Go duration format (e.g. "1h", "24h", "720h").
571-
minLength: 1
570+
Minimum reclamation window the tenant requires, as a whole number
571+
followed by a unit (s, m, or h). E.g. "1h", "24h", "720h".
572572
required:
573573
- min_window
574574
type: object
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# yaml-language-server: $schema=../../../../go/sdl/sdl-input.schema.yaml
2+
---
3+
version: "2.1"
4+
services:
5+
web:
6+
image: nginx:latest
7+
expose:
8+
- port: 80
9+
as: 80
10+
to:
11+
- global: true
12+
profiles:
13+
compute:
14+
web:
15+
resources:
16+
cpu:
17+
units: 100m
18+
memory:
19+
size: 512Mi
20+
storage:
21+
size: 512Mi
22+
placement:
23+
akash:
24+
pricing:
25+
web:
26+
denom: uakt
27+
amount: 1000
28+
deployment:
29+
web:
30+
akash:
31+
profile: web
32+
count: 1
33+
reclamation:
34+
# Structurally valid (non-empty string) but semantically invalid: a window of
35+
# zero is rejected by the Go-parity check (min_window must be > 0).
36+
min_window: "0s"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# yaml-language-server: $schema=../../../../go/sdl/sdl-input.schema.yaml
2+
---
3+
version: "2.1"
4+
services:
5+
web:
6+
image: nginx:latest
7+
expose:
8+
- port: 80
9+
as: 80
10+
to:
11+
- global: true
12+
profiles:
13+
compute:
14+
web:
15+
resources:
16+
cpu:
17+
units: 100m
18+
memory:
19+
size: 512Mi
20+
storage:
21+
size: 512Mi
22+
placement:
23+
akash:
24+
pricing:
25+
web:
26+
denom: uakt
27+
amount: 1000
28+
deployment:
29+
web:
30+
akash:
31+
profile: web
32+
count: 1
33+
reclamation:
34+
# Accepted by Go's time.ParseDuration (compound, 90m > 0) but rejected by the
35+
# stricter schema pattern (^[1-9][0-9]*(s|m|h)$). Schema-only-invalid: schema
36+
# rejects, Go parser accepts.
37+
min_window: "1h30m"

ts/src/sdl/manifest/generateManifest.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,45 @@ describe(generateManifest.name, () => {
806806
});
807807
});
808808

809+
describe("reclamation", () => {
810+
it.each([
811+
["24h", "86400"],
812+
["30m", "1800"],
813+
["720h", "2592000"],
814+
["8760h", "31536000"],
815+
])("surfaces min_window %j as a proto Duration (%s seconds)", (minWindow, seconds) => {
816+
const { result } = setup({ sdl: createBasicSdl({ reclamation: { min_window: minWindow } }) });
817+
expect(result.reclamation?.minWindow?.seconds.toString()).toBe(seconds);
818+
expect(result.reclamation?.minWindow?.nanos).toBe(0);
819+
});
820+
821+
it("leaves reclamation undefined when no block is present", () => {
822+
const { result } = setup();
823+
expect(result.reclamation).toBeUndefined();
824+
});
825+
826+
// Compound ("1h30m"), fractional ("1.5h") and sub-second ("500ms") forms are
827+
// accepted by Go's `time.ParseDuration` but rejected by the stricter SDL
828+
// schema pattern (`^[1-9][0-9]*(s|m|h)$`).
829+
it.each(["abc", "0s", "-1h", "100", "1h30m", "1.5h", "500ms"])("rejects an invalid min_window %j", (minWindow) => {
830+
const result = generateManifest(createBasicSdl({ reclamation: { min_window: minWindow } }));
831+
expect(result.ok).toBe(false);
832+
if (!result.ok) {
833+
expect(result.value).toContainEqual(expect.objectContaining({
834+
instancePath: "/reclamation/min_window",
835+
}));
836+
}
837+
});
838+
839+
it("exposes reclamation as a lazy, memoized getter", () => {
840+
const { result } = setup({ sdl: createBasicSdl({ reclamation: { min_window: "24h" } }) });
841+
// It's a getter (computed on access, not eagerly)...
842+
expect(Object.getOwnPropertyDescriptor(result, "reclamation")?.get).toBeTypeOf("function");
843+
// ...and memoized: repeated reads return the same instance.
844+
expect(result.reclamation).toBe(result.reclamation);
845+
});
846+
});
847+
809848
function setup(input?: {
810849
sdl: SDLInput;
811850
}) {
@@ -837,6 +876,7 @@ describe(generateManifest.name, () => {
837876
credentials?: SDLInput["services"][string]["credentials"];
838877
expose?: SDLInput["services"][string]["expose"];
839878
endpoints?: SDLInput["endpoints"];
879+
reclamation?: SDLInput["reclamation"];
840880
} = {}): SDLInput {
841881
const {
842882
port = 80,
@@ -880,6 +920,7 @@ describe(generateManifest.name, () => {
880920
profile: web
881921
count: 1
882922
endpoints: ${input.endpoints}
923+
reclamation: ${input.reclamation}
883924
`;
884925
}
885926

ts/src/sdl/manifest/generateManifest.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { GPU } from "../../generated/protos/akash/base/resources/v1beta4/gpu.ts"
44
import { Memory } from "../../generated/protos/akash/base/resources/v1beta4/memory.ts";
55
import { Resources } from "../../generated/protos/akash/base/resources/v1beta4/resources.ts";
66
import { Storage } from "../../generated/protos/akash/base/resources/v1beta4/storage.ts";
7+
import { DeploymentReclamation } from "../../generated/protos/akash/deployment/v1/deployment.ts";
78
import { GroupSpec } from "../../generated/protos/akash/deployment/v1beta4/groupspec.ts";
89
import { ResourceUnit } from "../../generated/protos/akash/deployment/v1beta4/resourceunit.ts";
910
import { Group } from "../../generated/protos/akash/manifest/v2beta3/group.ts";
@@ -29,10 +30,12 @@ import {
2930
type SDLService,
3031
transformGpuAttributes,
3132
} from "./manifestUtils.ts";
33+
import { minWindowToDuration } from "./reclamationDuration.ts";
3234

3335
export interface GenerateManifestOkResult {
3436
groups: Group[];
3537
groupSpecs: GroupSpec[];
38+
reclamation?: DeploymentReclamation;
3639
}
3740

3841
export type Manifest = GenerateManifestOkResult["groups"];
@@ -143,8 +146,21 @@ export function generateManifest(sdl: SDLInput): GenerateManifestResult {
143146
const sortedGroupNames = [...groupsMap.keys()].sort();
144147
let groups: Group[] | undefined;
145148
let groupSpecs: GroupSpec[] | undefined;
149+
let reclamation: DeploymentReclamation | undefined;
146150

147151
const manifest = {
152+
// reclamation is a `MsgCreateDeployment` field, not a manifest group, and is
153+
// not needed in every call — so it's lazy like `groups`/`groupSpecs`.
154+
// `validateSDL` (run above) already guaranteed `min_window` is valid, so
155+
// `minWindowToDuration` never throws here.
156+
get reclamation() {
157+
if (sdl.reclamation && !reclamation) {
158+
reclamation = DeploymentReclamation.fromPartial({
159+
minWindow: minWindowToDuration(sdl.reclamation.min_window),
160+
});
161+
}
162+
return reclamation;
163+
},
148164
get groups() {
149165
groups ??= sortedGroupNames.map((placementName) => {
150166
const deployments = deploymentsByPlacement.get(placementName)!;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { minWindowToDuration } from "./reclamationDuration.ts";
4+
5+
describe("minWindowToDuration", () => {
6+
it.each([
7+
["1s", "1"],
8+
["30m", "1800"],
9+
["1h", "3600"],
10+
["24h", "86400"],
11+
["720h", "2592000"],
12+
["8760h", "31536000"], // year-scale window: exact seconds, no precision loss
13+
])("converts %j to %s seconds with zero nanos", (input, seconds) => {
14+
const duration = minWindowToDuration(input);
15+
expect(duration.seconds.toString()).toBe(seconds);
16+
expect(duration.nanos).toBe(0);
17+
});
18+
19+
// Defensive: the SDL schema pattern guarantees a valid format upstream, but a
20+
// direct call with a value the schema would have rejected throws clearly.
21+
it.each(["1h30m", "1.5h", "500ms", "0s", "-1h", "100", "abc", ""])("throws on the schema-rejected value %j", (input) => {
22+
expect(() => minWindowToDuration(input)).toThrow(/invalid reclamation min_window/);
23+
});
24+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Duration } from "../../generated/protos/google/protobuf/duration.ts";
2+
3+
const UNIT_SECONDS: Record<string, bigint> = { s: 1n, m: 60n, h: 3600n };
4+
5+
/**
6+
* Converts a reclamation `min_window` into a proto `Duration`. The SDL schema
7+
* pattern (`^[1-9][0-9]*(s|m|h)$`) has already guaranteed the format (and `> 0`)
8+
* before this runs, so on the request hot path we split off the trailing unit
9+
* and parse the amount directly instead of re-running a regex. The cheap
10+
* unit/integer/positivity guards stay only to reject obviously-invalid direct
11+
* calls. Whole-unit windows are an exact second count, so `nanos` is always 0
12+
* and the BigInt product never loses precision.
13+
*/
14+
export function minWindowToDuration(value: string): Duration {
15+
const unitSeconds = UNIT_SECONDS[value.at(-1) ?? ""];
16+
const amount = Number(value.slice(0, -1));
17+
if (unitSeconds === undefined || !Number.isInteger(amount) || amount <= 0) {
18+
throw new Error(`invalid reclamation min_window "${value}"`);
19+
}
20+
return Duration.fromPartial({ seconds: (BigInt(amount) * unitSeconds).toString(), nanos: 0 });
21+
}

ts/src/sdl/validateSDL/validateSDL.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,6 +2190,42 @@ describe(validateSDL.name, () => {
21902190
});
21912191
});
21922192

2193+
describe("reclamation validation", () => {
2194+
it.each(["24h", "720h", "8760h", "30m", "1s"])("accepts a valid min_window %j", (minWindow) => {
2195+
const { validate } = setup({ reclamation: { min_window: minWindow } });
2196+
expect(validate()).toBeUndefined();
2197+
});
2198+
2199+
it("accepts reclamation on a v2.0 SDL (version-agnostic)", () => {
2200+
const { validate } = setup({ version: "2.0", reclamation: { min_window: "720h" } });
2201+
expect(validate()).toBeUndefined();
2202+
});
2203+
2204+
it("accepts reclamation on a v2.1 SDL", () => {
2205+
const { validate } = setup({ version: "2.1", reclamation: { min_window: "30m" } });
2206+
expect(validate()).toBeUndefined();
2207+
});
2208+
2209+
it("returns no reclamation error when the block is absent", () => {
2210+
const { validate } = setup();
2211+
expect(validate()).toBeUndefined();
2212+
});
2213+
2214+
// The schema pattern is intentionally stricter than Go's `time.ParseDuration`:
2215+
// it rejects compound ("1h30m"), fractional ("1.5h"), sub-second units
2216+
// ("500ms"), signs, zero, and unitless values, leaving only whole s/m/h
2217+
// windows. Go stays the lenient layer (see go/sdl/reclamation.go).
2218+
it.each(["abc", "0s", "-1h", "100", "1h30m", "1.5h", "500ms"])("rejects an invalid min_window %j", (minWindow) => {
2219+
const { validate } = setup({ reclamation: { min_window: minWindow } });
2220+
expect(validate()).toContainEqual(expect.objectContaining({
2221+
instancePath: "/reclamation/min_window",
2222+
schemaPath: "#/properties/reclamation/properties/min_window/pattern",
2223+
keyword: "pattern",
2224+
message: expect.stringContaining("whole number followed by s, m, or h"),
2225+
}));
2226+
});
2227+
});
2228+
21932229
function setup(overrides: DeepPartial<SDLInput> = {}, networkId: NetworkId = "sandbox") {
21942230
const defaultSDL: SDLInput = {
21952231
version: "2.0",

ts/src/sdl/validateSDL/validateSDL.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ const ERROR_MESSAGES: ErrorMessages = {
1313
"#/definitions/exposeToWithIpEnforcesGlobal"() {
1414
return `If an IP is declared, the directive must be declared as global.`;
1515
},
16+
// Mirrors the shared schema's `min_window` pattern (`^[1-9][0-9]*(s|m|h)$`).
17+
// Go stays the lenient layer (`go/sdl/reclamation.go` accepts any `> 0`
18+
// `time.ParseDuration`), so this is a sanctioned schema-only-stricter rule.
19+
"#/properties/reclamation/properties/min_window/pattern"() {
20+
return `Reclamation min_window must be a whole number followed by s, m, or h (e.g. "24h", "30m").`;
21+
},
1622
};
1723

1824
export function validateSDL(sdl: SDLInput): undefined | ValidationError[] {

0 commit comments

Comments
 (0)