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
26 changes: 23 additions & 3 deletions docs/perfkit-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,29 @@ Rules for the whole file:

## images

A map from a short name to an image name without a tag or digest, like
`edgeport: fonoster/routr-edgeport`. The tag comes from `--target-version`, and perfkit pins each
image by digest when the run starts. At least one image is required.
A map from a short name (the key) to an image. At least one image is required. Each entry is
either:

- An image name without a tag or digest, like `edgeport: fonoster/routr-edgeport`. The tag is
`--target-version`.
- An object `{ name, tagSuffix }`. `name` is an image name without a tag or digest; the tag is
`--target-version` followed by `tagSuffix`. `tagSuffix` matches `[A-Za-z0-9._-]+`. With
`--target-version 2.32.0`, this entry runs `fonoster/routr-edgeport:2.32.0-go-experimental`:

```yaml
images:
edgeport: fonoster/routr-edgeport
edgeportGo:
name: fonoster/routr-edgeport
tagSuffix: -go-experimental
```

perfkit pins each image by digest when the run starts, so `--dry-run` shows the digests the run
will use. Each image reaches the Compose file as `PERFKIT_IMAGE_<KEY>`: the key in uppercase,
with every character other than `A-Z` and `0-9` turned into `_`. Uppercasing removes camelCase,
so `edgeportGo` becomes `PERFKIT_IMAGE_EDGEPORTGO` (not `EDGEPORT_GO`) and `edgeport-go` becomes
`PERFKIT_IMAGE_EDGEPORT_GO`. In `report.json`, `run.images` maps each key to the pinned image,
like `fonoster/routr-edgeport:2.32.0-go-experimental@sha256:…`.

## benchmarks

Expand Down
2 changes: 1 addition & 1 deletion docs/project-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ with these variables, which the compose file uses as `${PERFKIT_...}`:

| Variable | Value |
| :--- | :--- |
| `PERFKIT_IMAGE_<NAME>` | One per entry of `images:`, pinned by digest, like `PERFKIT_IMAGE_EDGEPORT=fonoster/routr-edgeport:2.31.5@sha256:4f1c…`. `<NAME>` is the key in uppercase, any other character turned into `_`. |
| `PERFKIT_IMAGE_<NAME>` | One per entry of `images:`, pinned by digest, like `PERFKIT_IMAGE_EDGEPORT=fonoster/routr-edgeport:2.31.5@sha256:4f1c…`. `<NAME>` is the key in uppercase, any other character turned into `_`: `edgeportGo` becomes `PERFKIT_IMAGE_EDGEPORTGO`. |
| `PERFKIT_UUT_IP` | The UUT's private (VPC) IP. |
| `PERFKIT_LOADGEN_IP` | The load generator's private IP. |
| `PERFKIT_VPC_CIDR` | The VPC range, like `10.20.0.0/24`. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "perfkit",
"version": "0.1.5",
"version": "0.1.6",
"description": "Benchmark harness for Fonoster projects: DigitalOcean runs, one report.json schema, one branded report page",
"license": "MIT",
"author": "Pedro Sanders <psanders@fonoster.com>",
Expand Down
26 changes: 24 additions & 2 deletions schemas/perfkit-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,30 @@
"type": "string"
},
"additionalProperties": {
"type": "string",
"minLength": 1
"anyOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"tagSuffix": {
"type": "string",
"pattern": "^[A-Za-z0-9._-]+$"
}
},
"required": [
"name",
"tagSuffix"
],
"additionalProperties": false
}
]
}
},
"benchmarks": {
Expand Down
4 changes: 2 additions & 2 deletions src/infra/createPlanRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import type { z } from "zod/v4";
import { withErrorHandlingAndValidation } from "../errors/index.js";
import { formatRunId } from "../schemas/index.js";
import { formatRunId, type PerfkitConfig } from "../schemas/index.js";
import {
DROPLET_IMAGE,
PERFKIT_TAG,
Expand All @@ -23,7 +23,7 @@ import type { Plan, ResolvedImage } from "./types.js";
*/
export function createPlanRun(deps: {
resolveImageDigests: (input: {
images: Record<string, string>;
images: PerfkitConfig["images"];
tag: string;
}) => Promise<ResolvedImage[]>;
getPublicIp: () => Promise<string>;
Expand Down
13 changes: 9 additions & 4 deletions src/infra/createResolveImageDigests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import type { RegistryClient, ResolvedImage } from "./types.js";

/**
* Creates the function that pins every image of perfkit.yaml at one tag to
* its digest, so a run uses exactly the images `--dry-run` showed.
* its digest, so a run uses exactly the images `--dry-run` showed. An entry
* with a `tagSuffix` is pinned at the tag followed by its suffix.
*
* @param deps - The registry client (injected)
* @returns A validated function; it throws ImageNotFoundError for a missing
Expand All @@ -22,11 +23,15 @@ export function createResolveImageDigests(deps: { registry: RegistryClient }) {
images,
tag
}: ResolveImageDigestsInput): Promise<ResolvedImage[]> => {
const entries = Object.entries(images);
const entries = Object.entries(images).map(([key, entry]) =>
typeof entry === "string"
? { key, name: entry, tag }
: { key, name: entry.name, tag: `${tag}${entry.tagSuffix}` }
);
const digests = await Promise.all(
entries.map(([, name]) => deps.registry.getDigest(name, tag))
entries.map(({ name, tag }) => deps.registry.getDigest(name, tag))
);
return entries.map(([key, name], index) => {
return entries.map(({ key, name, tag }, index) => {
const digest = digests[index];
if (!digest) throw new ImageNotFoundError(`${name}:${tag}`);
return { key, name, tag, digest, ref: `${name}:${tag}@${digest}` };
Expand Down
3 changes: 2 additions & 1 deletion src/infra/infra.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from "zod/v4";
import {
benchmarkConfigSchema,
durationSchema,
imageEntrySchema,
perfkitConfigSchema
} from "../schemas/index.js";
import {
Expand Down Expand Up @@ -48,7 +49,7 @@ const ipv4Cidr32Schema = z

export const resolveImageDigestsInputSchema = z.strictObject({
images: z
.record(z.string(), nonEmptyString)
.record(z.string(), imageEntrySchema)
.refine((images) => Object.keys(images).length > 0, {
error: "expected at least one image"
}),
Expand Down
2 changes: 2 additions & 0 deletions src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ export { formatRunId } from "./formatRunId.js";
export {
benchmarkConfigSchema,
capacityBenchmarkConfigSchema,
imageEntrySchema,
perfkitConfigSchema,
relativePathSchema,
soakBenchmarkConfigSchema,
type BenchmarkConfig,
type CapacityBenchmarkConfig,
type ImageEntry,
type PerfkitConfig,
type PerfkitConfigInput,
type SoakBenchmarkConfig
Expand Down
19 changes: 18 additions & 1 deletion src/schemas/perfkitConfig.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,25 @@ const imageNameSchema = nonEmptyString.refine(
}
);

/**
* One `images` entry: an image name, tagged with `--target-version`, or
* `{ name, tagSuffix }`, tagged with `--target-version` followed by
* `tagSuffix` (like `2.32.0-go-experimental`).
*/
export const imageEntrySchema = z.union([
imageNameSchema,
z.strictObject({
name: imageNameSchema,
tagSuffix: z.string().regex(/^[A-Za-z0-9._-]+$/, {
error: "expected a tag suffix of letters, digits, '.', '_' or '-'"
})
})
]);

export type ImageEntry = z.output<typeof imageEntrySchema>;

const configImagesSchema = z
.record(z.string(), imageNameSchema)
.record(z.string(), imageEntrySchema)
.refine((images) => Object.keys(images).length > 0, {
error: "expected at least one image"
});
Expand Down
53 changes: 53 additions & 0 deletions test/infra/createResolveImageDigests.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,43 @@ describe("createResolveImageDigests", () => {
]);
});

it("should pin an entry with a tagSuffix at the tag followed by the suffix", async () => {
// Arrange
const registry = fakeRegistry({
"fonoster/routr-edgeport:2.32.0": DIGEST.edgeport,
"fonoster/routr-edgeport:2.32.0-go-experimental": DIGEST.one
});
const resolve = createResolveImageDigests({ registry });

// Act
const images = await resolve({
images: {
edgeport: "fonoster/routr-edgeport",
edgeportGo: {
name: "fonoster/routr-edgeport",
tagSuffix: "-go-experimental"
}
},
tag: "2.32.0"
});

// Assert
expect(images[1]).to.deep.equal({
key: "edgeportGo",
name: "fonoster/routr-edgeport",
tag: "2.32.0-go-experimental",
digest: DIGEST.one,
ref: `fonoster/routr-edgeport:2.32.0-go-experimental@${DIGEST.one}`
});
expect(images[0]?.ref).to.equal(
`fonoster/routr-edgeport:2.32.0@${DIGEST.edgeport}`
);
expect(registry.asked).to.deep.equal([
"fonoster/routr-edgeport:2.32.0",
"fonoster/routr-edgeport:2.32.0-go-experimental"
]);
});

it("should name the missing image", async () => {
// Arrange
const resolve = createResolveImageDigests({
Expand Down Expand Up @@ -78,5 +115,21 @@ describe("createResolveImageDigests", () => {
expect(err).to.be.instanceOf(ValidationError);
expect(registry.asked).to.deep.equal([]);
});

it("should reject a tagSuffix with characters a tag can't hold", async () => {
// Arrange
const registry = fakeRegistry();
const resolve = createResolveImageDigests({ registry });

// Act
const err = await resolve({
images: { go: { name: "fonoster/routr-edgeport", tagSuffix: "/go" } },
tag: "2.32.0"
}).catch((e: unknown) => e);

// Assert
expect(err).to.be.instanceOf(ValidationError);
expect(registry.asked).to.deep.equal([]);
});
});
});
40 changes: 40 additions & 0 deletions test/infra/run.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
* Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License.
*/
import { expect } from "chai";
import { cp, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import sinon from "sinon";
import { parse, stringify } from "yaml";
import { fingerprintOf } from "../../src/infra/index.js";
import { ROUTR_CONFIG } from "../fixtures.js";
import {
DIGEST,
fakeRegistry,
infraDeps,
perfkit,
Expand Down Expand Up @@ -53,6 +58,41 @@ describe("perfkit run", () => {
expect(infra.account.calls).to.deep.equal([]);
});

it("should pin an image with a tagSuffix at the target version followed by the suffix", async () => {
// Arrange
const dir = await mkdtemp(join(tmpdir(), "perfkit-suffix-"));
await cp(dirname(ROUTR_CONFIG), dir, { recursive: true });
const config = join(dir, "perfkit.yaml");
const raw = parse(await readFile(config, "utf8"));
raw.images.edgeportGo = {
name: "fonoster/routr-edgeport",
tagSuffix: "-go-experimental"
};
await writeFile(config, stringify(raw));
const infra = infraDeps({
registry: fakeRegistry({
"fonoster/routr-edgeport:2.31.5": DIGEST.edgeport,
"fonoster/routr-echo:2.31.5": DIGEST.echo,
"fonoster/routr-one:2.31.5": DIGEST.one,
"fonoster/routr-edgeport:2.31.5-go-experimental": DIGEST.one
})
});

// Act
const result = await perfkit(
["run", "--config", config, "--target-version", "2.31.5", "--dry-run"],
infra
);

// Assert
expect(result.err).to.equal("");
expect(result.code).to.equal(0);
expect(result.out).to.include(
" fonoster/routr-one:2.31.5@sha256:9b07…1d3c\n" +
" fonoster/routr-edgeport:2.31.5-go-experimental@sha256:9b07…1d3c\n"
);
});

it("should list only the benchmarks given with --benchmarks", async () => {
// Act
const result = await perfkit(
Expand Down
54 changes: 54 additions & 0 deletions test/orchestrate/buildComposeEnv.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License.
*/
import { expect } from "chai";
import { buildComposeEnv, type RunState } from "../../src/orchestrate/index.js";
import type { BenchmarkConfig } from "../../src/schemas/index.js";

const DIGEST = `sha256:${"a".repeat(64)}`;

const image = (key: string, tag: string) => ({
key,
name: "fonoster/routr-edgeport",
tag,
digest: DIGEST,
ref: `fonoster/routr-edgeport:${tag}@${DIGEST}`
});

const STATE = {
plan: {
targetVersion: "2.32.0",
images: [
image("edgeport", "2.32.0"),
image("edgeportGo", "2.32.0-go-experimental"),
image("routr-one.v2", "2.32.0")
]
},
hosts: {
uutIp: "10.116.0.2",
loadgenIp: "10.116.0.3",
vpcCidr: "10.116.0.0/20"
}
} as unknown as RunState;

const BENCHMARK = {
telemetry: { otlp: false, sampleRatio: 0.01 }
} as unknown as BenchmarkConfig;

describe("buildComposeEnv", () => {
it("should name each image PERFKIT_IMAGE_<KEY>, upper-cased with every other character turned into _", () => {
// Act
const env = buildComposeEnv(STATE, BENCHMARK);

// Assert
expect(env).to.deep.equal({
PERFKIT_IMAGE_EDGEPORT: `fonoster/routr-edgeport:2.32.0@${DIGEST}`,
PERFKIT_IMAGE_EDGEPORTGO: `fonoster/routr-edgeport:2.32.0-go-experimental@${DIGEST}`,
PERFKIT_IMAGE_ROUTR_ONE_V2: `fonoster/routr-edgeport:2.32.0@${DIGEST}`,
PERFKIT_UUT_IP: "10.116.0.2",
PERFKIT_LOADGEN_IP: "10.116.0.3",
PERFKIT_VPC_CIDR: "10.116.0.0/20",
PERFKIT_TARGET_VERSION: "2.32.0"
});
});
});
Loading
Loading