diff --git a/src/features/projects/releases/deployments/deploymentRepository.ts b/src/features/projects/releases/deployments/deploymentRepository.ts index 280abe9..2352d79 100644 --- a/src/features/projects/releases/deployments/deploymentRepository.ts +++ b/src/features/projects/releases/deployments/deploymentRepository.ts @@ -8,7 +8,7 @@ import { CreateDeploymentUntenantedCommandV1, CreateDeploymentUntenantedResponseV1, } from "."; -import { lt } from "semver"; +import { ensureServerVersionAtLeast } from "../../../../versionCheck"; // WARNING: we've had to do this to cover a mistake in Octopus' API. The API has been corrected to return PascalCase, but was returning camelCase // for a number of versions, so we'll deserialize both and use whichever actually has a value @@ -54,15 +54,7 @@ export class DeploymentRepository { } async create(command: CreateDeploymentUntenantedCommandV1): Promise { - const serverInformation = await this.client.getServerInformation(); - if (lt(serverInformation.version, "2022.3.5512")) { - this.client.error?.( - "The Octopus instance doesn't support deploying releases using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - throw new Error( - "The Octopus instance doesn't support deploying releases using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - } + await ensureServerVersionAtLeast(this.client, "2022.3.5512", "deploying releases using the Executions API"); this.client.debug(`Deploying a release...`); @@ -92,15 +84,7 @@ export class DeploymentRepository { } async createTenanted(command: CreateDeploymentTenantedCommandV1): Promise { - const serverInformation = await this.client.getServerInformation(); - if (lt(serverInformation.version, "2022.3.5512")) { - this.client.error?.( - "The Octopus instance doesn't support deploying tenanted releases using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - throw new Error( - "The Octopus instance doesn't support deploying tenanted releases using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - } + await ensureServerVersionAtLeast(this.client, "2022.3.5512", "deploying tenanted releases using the Executions API"); this.client.debug(`Deploying a tenanted release...`); diff --git a/src/features/projects/releases/releaseRepository.ts b/src/features/projects/releases/releaseRepository.ts index 5eafc51..68c6309 100644 --- a/src/features/projects/releases/releaseRepository.ts +++ b/src/features/projects/releases/releaseRepository.ts @@ -4,7 +4,7 @@ import { CreateReleaseCommandV1 } from "./createReleaseCommandV1"; import { CreateReleaseResponseV1 } from "./createReleaseResponseV1"; import { Release } from "./release"; import { ResourceCollection } from "../../../resourceCollection"; -import { lt } from "semver"; +import { ensureServerVersionAtLeast } from "../../../versionCheck"; type ReleaseListArgs = { skip?: number; @@ -25,15 +25,7 @@ export class ReleaseRepository { } async create(command: CreateReleaseCommandV1): Promise { - const serverInformation = await this.client.getServerInformation(); - if (lt(serverInformation.version, "2022.3.5512")) { - this.client.error?.( - "The Octopus instance doesn't support creating releases using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - throw new Error( - "The Octopus instance doesn't support creating releases using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - } + await ensureServerVersionAtLeast(this.client, "2022.3.5512", "creating releases using the Executions API"); this.client.debug(`Creating a release...`); diff --git a/src/features/projects/runbooks/runs/runbookRunRepository.ts b/src/features/projects/runbooks/runs/runbookRunRepository.ts index ee2a23b..561b4e0 100644 --- a/src/features/projects/runbooks/runs/runbookRunRepository.ts +++ b/src/features/projects/runbooks/runs/runbookRunRepository.ts @@ -5,7 +5,7 @@ import { spaceScopedRoutePrefix } from "../../../../spaceScopedRoutePrefix"; import { ListArgs } from "../../../basicRepository"; import { ResourceCollection } from "../../../../resourceCollection"; import { CreateRunbookRunCommandV1, CreateRunbookRunResponseV1 } from "./createRunbookRunCommandV1"; -import { lt } from "semver"; +import { ensureServerVersionAtLeast } from "../../../../versionCheck"; import { GitRef, Project } from "../../project"; import { RunbookRepository } from "../runbookRepository"; import { RunGitRunbookCommand } from "./RunGitRunbookCommand"; @@ -56,15 +56,7 @@ export class RunbookRunRepository { } async create(command: CreateRunbookRunCommandV1): Promise { - const serverInformation = await this.client.getServerInformation(); - if (lt(serverInformation.version, "2022.3.5512")) { - this.client.error?.( - "The Octopus instance doesn't support running runbooks using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - throw new Error( - "The Octopus instance doesn't support running runbooks using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - } + await ensureServerVersionAtLeast(this.client, "2022.3.5512", "running runbooks using the Executions API"); this.client.debug(`Running a runbook...`); @@ -94,15 +86,7 @@ export class RunbookRunRepository { } async createGit(command: RunGitRunbookCommand, gitRef: GitRef): Promise { - const serverInformation = await this.client.getServerInformation(); - if (lt(serverInformation.version, "2022.3.5512")) { - this.client.error?.( - "The Octopus instance doesn't support running runbooks using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - throw new Error( - "The Octopus instance doesn't support running runbooks using the Executions API, it will need to be upgraded to at least 2022.3.5512 in order to access this API." - ); - } + await ensureServerVersionAtLeast(this.client, "2022.3.5512", "running runbooks using the Executions API"); this.client.debug(`Running a runbook...`); diff --git a/src/index.ts b/src/index.ts index 8d327ee..6947feb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,4 @@ export * from "./spaceScopedResource"; export * from "./spaceScopedRoutePrefix"; export * from "./subscriptionRecord"; export * from "./utils"; +export * from "./versionCheck"; diff --git a/src/versionCheck.test.ts b/src/versionCheck.test.ts new file mode 100644 index 0000000..a1cbec6 --- /dev/null +++ b/src/versionCheck.test.ts @@ -0,0 +1,90 @@ +import { isLocalOctopusVersion, isServerVersionAtLeast, ensureServerVersionAtLeast } from "./versionCheck"; +import type { Client } from "./client"; + +describe("isLocalOctopusVersion", () => { + test.each([ + ["0.0.0", true], + ["0.0.0-local", true], + ["0.0.0-local-build.5", true], + ["0.0.0+sha1234", true], + ["0.0.0-alpha+sha1234", true], + ["0.0.1", false], + ["0.0.10", false], + ["1.0.0", false], + ["2022.3.5512", false], + ["", false], + ])("isLocalOctopusVersion(%s) === %s", (version, expected) => { + expect(isLocalOctopusVersion(version as string)).toBe(expected); + }); +}); + +describe("isServerVersionAtLeast", () => { + const min = "2022.3.5512"; + + test("returns true when server version is greater than minimum", () => { + expect(isServerVersionAtLeast("2023.1.0", min)).toBe(true); + }); + + test("returns true when server version equals minimum", () => { + expect(isServerVersionAtLeast("2022.3.5512", min)).toBe(true); + }); + + test("returns false when server version is below minimum", () => { + expect(isServerVersionAtLeast("2022.3.5511", min)).toBe(false); + expect(isServerVersionAtLeast("2021.1.0", min)).toBe(false); + }); + + test("returns true for local development versions regardless of minimum", () => { + expect(isServerVersionAtLeast("0.0.0", min)).toBe(true); + expect(isServerVersionAtLeast("0.0.0-local", min)).toBe(true); + expect(isServerVersionAtLeast("0.0.0+sha1234", min)).toBe(true); + }); + + test("returns false for invalid version strings", () => { + expect(isServerVersionAtLeast("not-a-version", min)).toBe(false); + expect(isServerVersionAtLeast("", min)).toBe(false); + }); +}); + +describe("ensureServerVersionAtLeast", () => { + const min = "2022.3.5512"; + const feature = "creating releases using the Executions API"; + const expectedMessage = + `The Octopus instance doesn't support creating releases using the Executions API, ` + + `it will need to be upgraded to at least 2022.3.5512 in order to access this API.`; + + function stubClient(version: string): { client: Client; errorCalls: string[] } { + const errorCalls: string[] = []; + const client = { + getServerInformation: async () => ({ version, installationId: "test" }), + error: (msg: string) => errorCalls.push(msg), + } as unknown as Client; + return { client, errorCalls }; + } + + test("resolves silently when server version satisfies minimum", async () => { + const { client, errorCalls } = stubClient("2022.3.5512"); + await expect(ensureServerVersionAtLeast(client, min, feature)).resolves.toBeUndefined(); + expect(errorCalls).toEqual([]); + }); + + test("resolves silently for local development versions", async () => { + const { client, errorCalls } = stubClient("0.0.0-local"); + await expect(ensureServerVersionAtLeast(client, min, feature)).resolves.toBeUndefined(); + expect(errorCalls).toEqual([]); + }); + + test("throws and logs identical message when server version is too old", async () => { + const { client, errorCalls } = stubClient("2022.3.5511"); + await expect(ensureServerVersionAtLeast(client, min, feature)).rejects.toThrow(expectedMessage); + expect(errorCalls).toEqual([expectedMessage]); + }); + + test("does not throw if client.error is undefined", async () => { + const client = { + getServerInformation: async () => ({ version: "2022.3.5511", installationId: "test" }), + error: undefined, + } as unknown as Client; + await expect(ensureServerVersionAtLeast(client, min, feature)).rejects.toThrow(expectedMessage); + }); +}); diff --git a/src/versionCheck.ts b/src/versionCheck.ts new file mode 100644 index 0000000..495a91b --- /dev/null +++ b/src/versionCheck.ts @@ -0,0 +1,29 @@ +import { lt, valid } from "semver"; +import type { Client } from "./client"; + +// Local development builds of Octopus report a version of "0.0.0" (optionally with a +// prerelease tag like "-local" or build metadata). Treat those as "latest" so version +// gates do not block developers running against a locally-built server. +export function isLocalOctopusVersion(version: string): boolean { + return /^0\.0\.0(?:[-+].*)?$/.test(version); +} + +// Returns true when the running server's version satisfies the minimum, OR when the +// running server is a local development build. +export function isServerVersionAtLeast(serverVersion: string, minimumVersion: string): boolean { + if (isLocalOctopusVersion(serverVersion)) return true; + if (!valid(serverVersion)) return false; + return !lt(serverVersion, minimumVersion); +} + +// Looks up the running server's version and throws a uniform error if it is too old. +// `featureDescription` is the snippet that fills the "" slot in the error +// template (e.g. "creating releases using the Executions API"). +export async function ensureServerVersionAtLeast(client: Client, minimumVersion: string, featureDescription: string): Promise { + const serverInformation = await client.getServerInformation(); + if (isServerVersionAtLeast(serverInformation.version, minimumVersion)) return; + + const message = `The Octopus instance doesn't support ${featureDescription}, it will need to be upgraded to at least ${minimumVersion} in order to access this API.`; + client.error?.(message); + throw new Error(message); +}