diff --git a/packages/cli/cli/changes/unreleased/adapter-capability-labels.yml b/packages/cli/cli/changes/unreleased/adapter-capability-labels.yml new file mode 100644 index 000000000000..396b062b6216 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/adapter-capability-labels.yml @@ -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 diff --git a/packages/cli/generation/local-generation/docker-utils/src/index.ts b/packages/cli/generation/local-generation/docker-utils/src/index.ts index e8265bf5f609..70bd8a268f19 100644 --- a/packages/cli/generation/local-generation/docker-utils/src/index.ts +++ b/packages/cli/generation/local-generation/docker-utils/src/index.ts @@ -1,3 +1,4 @@ +export { getImageLabels } from "./inspectImage.js"; export { copyFromContainer, copyToContainer, diff --git a/packages/cli/generation/local-generation/docker-utils/src/inspectImage.ts b/packages/cli/generation/local-generation/docker-utils/src/inspectImage.ts new file mode 100644 index 000000000000..655b2881b2d5 --- /dev/null +++ b/packages/cli/generation/local-generation/docker-utils/src/inspectImage.ts @@ -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> { + const containerRunner = runner ?? "docker"; + + const inspect = async (): Promise => { + 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).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); + } catch { + logger?.debug(`Labels on ${imageName} were not valid JSON; continuing without them.`); + return {}; + } +} diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/adapterCapabilityLabels.test.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/adapterCapabilityLabels.test.ts new file mode 100644 index 000000000000..c39af11bf4c7 --- /dev/null +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/adapterCapabilityLabels.test.ts @@ -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); + }); +}); diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts index 688ef5ef23c8..4b101ebac55d 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts @@ -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 = 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): 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}`; +} diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/runGenerator.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/runGenerator.ts index 0c6c7baab47e..51b10cf66b40 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/runGenerator.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/runGenerator.ts @@ -31,6 +31,7 @@ import { CONTAINER_SPECS_DIRECTORY, GENERATOR_CONFIG_FILENAME, IR_FILENAME, + resolveGeneratorImage, SPECS_DIRECTORY_NAME, SPECS_MANIFEST_FILENAME } from "./constants.js"; @@ -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 }); diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts index 278b217b2c8e..80d9811148d2 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts @@ -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, @@ -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"; @@ -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, @@ -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));