Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Local generation can now determine whether a generator needs the raw API specs from a label on
the generator image, in addition to the existing first-party name allowlist. This lets an image
published under an existing generator name in a different registry declare what it needs
without a CLI release. Generators without the label are unaffected.
type: internal
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { getImageLabels } from "./inspectImage.js";
export {
copyFromContainer,
copyToContainer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ContainerRunner } from "@fern-api/core-utils";
import { Logger } from "@fern-api/logger";
import { loggingExeca } from "@fern-api/logging-execa";

export declare namespace getImageLabels {
export interface Args {
logger?: Logger;
imageName: string;
runner?: ContainerRunner;
/**
* Pull the image when it is not present locally. Local generation is about to pull and run
* this exact image, so the pull is moved earlier rather than added.
*/
pullIfAbsent?: boolean;
signal?: AbortSignal;
}
}

/**
* Reads the OCI labels declared on an image.
*
* Returns an empty record rather than throwing when the image cannot be inspected. A generator that
* declares no labels, an image that is not present and cannot be pulled, or a container runtime that
* is unavailable must all fall back to existing behaviour instead of failing a generation that would
* otherwise succeed.
*/
export async function getImageLabels({
logger,
imageName,
runner,
pullIfAbsent = true,
signal
}: getImageLabels.Args): Promise<Record<string, string>> {
const containerRunner = runner ?? "docker";

const inspect = async (): Promise<string | undefined> => {
const { stdout, exitCode } = await loggingExeca(
undefined,
containerRunner,
["image", "inspect", imageName, "--format", "{{json .Config.Labels}}"],
{ reject: false, doNotPipeOutput: true, signal }
);
return exitCode === 0 ? stdout : undefined;
};

let raw = await inspect();

if (raw == null && pullIfAbsent) {
const { exitCode } = await loggingExeca(undefined, containerRunner, ["pull", imageName], {
reject: false,
doNotPipeOutput: true,
signal
});
if (exitCode === 0) {
raw = await inspect();
}
}

if (raw == null) {
logger?.debug(`Could not inspect labels on ${imageName}; continuing without them.`);
return {};
}

try {
const parsed: unknown = JSON.parse(raw.trim());
if (typeof parsed !== "object" || parsed === null) {
return {};
}
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string"
)
);
} catch {
logger?.debug(`Labels on ${imageName} were not valid JSON; continuing without them.`);
return {};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";

import {
generatorWantsSpecs,
labelsRequestRawSpecs,
resolveGeneratorImage,
WANTS_RAW_SPECS_LABEL
} from "../constants.js";

describe("labelsRequestRawSpecs", () => {
it("opts the image in when the label is set", () => {
expect(labelsRequestRawSpecs({ [WANTS_RAW_SPECS_LABEL]: "true" })).toBe(true);
});

it("accepts the label case-insensitively", () => {
expect(labelsRequestRawSpecs({ [WANTS_RAW_SPECS_LABEL]: "TRUE" })).toBe(true);
});

it.each([
["the label is absent", {}],
["the label is false", { [WANTS_RAW_SPECS_LABEL]: "false" }],
["the label is empty", { [WANTS_RAW_SPECS_LABEL]: "" }],
["an unrelated label is present", { "org.opencontainers.image.title": "x" }]
])("does not opt in when %s", (_label, labels) => {
expect(labelsRequestRawSpecs(labels)).toBe(false);
});
});

describe("resolveGeneratorImage", () => {
it("uses the generator name when no custom image is configured", () => {
expect(
resolveGeneratorImage({
containerImage: undefined,
name: "fernapi/fern-python-sdk",
version: "4.0.0"
})
).toBe("fernapi/fern-python-sdk:4.0.0");
});

// A self-hosted adapter keeps the Fern generator name and repoints only the registry, so the
// resolved reference is the only thing that distinguishes it.
it("prefers a custom registry image when one is configured", () => {
expect(
resolveGeneratorImage({
containerImage: "ghcr.io/acme/fern-python-sdk",
name: "fernapi/fern-python-sdk",
version: "4.0.0"
})
).toBe("ghcr.io/acme/fern-python-sdk:4.0.0");
});
});

describe("generatorWantsSpecs", () => {
it("keeps the existing first-party allowlist working", () => {
expect(generatorWantsSpecs("fernapi/fern-cli-generator")).toBe(true);
});

it("does not opt in a generator by name alone", () => {
expect(generatorWantsSpecs("fernapi/fern-python-sdk")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,36 @@ export const TYPE_RELOCATIONS_OUTPUT_FILEPATH_ENV_VAR = "FERN_TYPE_RELOCATIONS_O
/**
* Generators that receive pre-processed raw API spec files mounted into their
* Docker container. Add new generator names here as they opt in.
*
* Prefer the label below for new images. A name allowlist cannot describe an image that is published
* under an existing generator name in a different registry, which is how a self-hosted adapter is
* configured -- the name is identical whether the image is Fern's or the vendor's.
*/
const GENERATORS_WANTING_SPECS: ReadonlySet<string> = new Set(["fernapi/fern-cli-generator"]);

export function generatorWantsSpecs(generatorName: string): boolean {
return GENERATORS_WANTING_SPECS.has(generatorName);
}

/**
* Image label by which a generator declares it wants the raw API specs rather than only the IR.
* Read off the resolved image, so an image can opt in without a CLI release.
*/
export const WANTS_RAW_SPECS_LABEL = "com.postman.sdk-gen.adapter.wants-raw-specs";

export function labelsRequestRawSpecs(labels: Record<string, string>): boolean {
return labels[WANTS_RAW_SPECS_LABEL]?.toLowerCase() === "true";
}

/**
* The image reference a generator invocation resolves to. Structurally typed so the capability check
* and the container execution agree on exactly which image is inspected and run.
*/
export function resolveGeneratorImage(generatorInvocation: {
containerImage: string | undefined;
name: string;
version: string;
}): string {
const repository = generatorInvocation.containerImage ?? generatorInvocation.name;
return `${repository}:${generatorInvocation.version}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
CONTAINER_SPECS_DIRECTORY,
GENERATOR_CONFIG_FILENAME,
IR_FILENAME,
resolveGeneratorImage,
SPECS_DIRECTORY_NAME,
SPECS_MANIFEST_FILENAME
} from "./constants.js";
Expand Down Expand Up @@ -192,9 +193,7 @@ export async function writeFilesToDiskAndRunGenerator({
const environment =
executionEnvironment ??
new ContainerExecutionEnvironment({
containerImage: generatorInvocation.containerImage
? `${generatorInvocation.containerImage}:${generatorInvocation.version}`
: `${generatorInvocation.name}:${generatorInvocation.version}`,
containerImage: resolveGeneratorImage(generatorInvocation),
keepContainer: keepDocker,
disableTelemetry
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SourceResolverImpl } from "@fern-api/cli-source-resolver";
import { fernConfigJson, generatorsYml } from "@fern-api/configuration";
import { createVenusService } from "@fern-api/core";
import { ContainerRunner, extractErrorMessage, replaceEnvVariables } from "@fern-api/core-utils";
import { getImageLabels } from "@fern-api/docker-utils";
import { AbsoluteFilePath, dirname, join, RelativeFilePath } from "@fern-api/fs-utils";
import {
AutoVersioningCache,
Expand Down Expand Up @@ -46,7 +47,7 @@ import * as fs from "fs/promises";
import os from "os";
import path from "path";
import tmp from "tmp-promise";
import { generatorWantsSpecs } from "./constants.js";
import { generatorWantsSpecs, labelsRequestRawSpecs, resolveGeneratorImage } from "./constants.js";
import { getGeneratorOutputSubfolder } from "./getGeneratorOutputSubfolder.js";
import { writeFilesToDiskAndRunGenerator } from "./runGenerator.js";

Expand Down Expand Up @@ -401,6 +402,21 @@ export async function runLocalGenerationForWorkspace({
// NOTE(tjb9dc): Important that we get a new temp dir per-generator, as we don't want their local files to collide.
const workspaceTempDir = await getWorkspaceTempDir();

// Whether this generator receives the raw specs. Resolved from the generator name for
// first-party generators, and otherwise from a label on the image itself -- an image
// published under an existing generator name in a different registry is
// indistinguishable by name, so only the image can declare what it needs.
const wantsRawSpecs =
workspace instanceof OSSWorkspace &&
(generatorWantsSpecs(generatorInvocation.name) ||
labelsRequestRawSpecs(
await getImageLabels({
logger: interactiveTaskContext.logger,
imageName: resolveGeneratorImage(generatorInvocation),
runner
})
));

const {
shouldCommit,
autoVersioningCommitMessage,
Expand Down Expand Up @@ -441,10 +457,7 @@ export async function runLocalGenerationForWorkspace({
absolutePathToSpecRepo: dirname(workspace.absoluteFilePath),
skipFernignore,
disableTelemetry,
rawApiSpecs:
workspace instanceof OSSWorkspace && generatorWantsSpecs(generatorInvocation.name)
? workspace.allSpecs
: undefined
rawApiSpecs: wantsRawSpecs ? workspace.allSpecs : undefined
});

interactiveTaskContext.logger.info(chalk.green("Wrote files to " + absolutePathToLocalOutput));
Expand Down
Loading