diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56e54d3..3b40b70 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,10 +39,10 @@ jobs: statuses: write checks: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3 with: cache: 'npm' node-version: 16 @@ -55,7 +55,7 @@ jobs: - run: npm run test name: 'Run the tests' env: - OCTOPUS_HOST: ${{ env.SERVER_URL }} + OCTOPUS_URL: ${{ env.SERVER_URL }} OCTOPUS_API_KEY: ${{ env.ADMIN_API_KEY }} - uses: dorny/test-reporter@v1 if: success() || failure() diff --git a/README.md b/README.md index 43d3ec5..6ad73db 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ import type { ProjectResource } from "@octopusdeploy/message-contracts"; // environment variables // // OCTOPUS_API_KEY: the API key used to connect to an instance of Octopus Deploy -// OCTOPUS_HOST: the host instance of Octopus Deploy +// OCTOPUS_URL: the host instance of Octopus Deploy // OCTOPUS_SPACE: the space to target API commands in Octopus Deploy // assume conventional configuration via environment variables diff --git a/src/apiClient.ts b/src/apiClient.ts index cf04a7d..9374908 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -15,10 +15,10 @@ export default class ApiClient { this.adapter = new AxiosAdapter(); } - async execute() { + async execute(useCamelCase: boolean = false) { try { const response = await this.adapter.execute(this.options); - this.handleSuccess(response); + this.handleSuccess(response, useCamelCase); } catch (error: unknown) { if (error instanceof AdapterError) { this.handleError(error); @@ -30,7 +30,7 @@ export default class ApiClient { } } - private handleSuccess = (response: AdapterResponse) => { + private handleSuccess = (response: AdapterResponse, useCamelCase: boolean) => { if (this.options.onResponseCallback) { const details: ResponseDetails = { method: this.options.method as any, @@ -47,7 +47,17 @@ export default class ApiClient { } else { responseText = JSON.stringify(response.data); if (responseText && responseText.length > 0) { - responseText = JSON.parse(responseText); + responseText = JSON.parse(responseText, (_, val) => { + if (val === null || val === undefined || Array.isArray(val) || typeof val !== "object" || !useCamelCase) { + return val; + } + return Object.entries(val).reduce((a, [key, val]) => { + const b = a as any; + const field = key[0].toLowerCase() + key.substring(1); + b[field] = val; + return a; + }, {}); + }); } } diff --git a/src/client.test.ts b/src/client.test.ts index 4f80e1d..925111f 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -14,9 +14,9 @@ describe("client", () => { test("connects using space id", async () => { const clientConfiguration: ClientConfiguration = { apiKey: process.env["OCTOPUS_API_KEY"] || "", - apiUri: process.env["OCTOPUS_HOST"] || "", + apiUri: process.env["OCTOPUS_URL"] || "", space: "Spaces-1", - autoConnect: true + autoConnect: true, }; const client = await Client.create(clientConfiguration); @@ -26,9 +26,9 @@ describe("client", () => { test("connects using space name", async () => { const clientConfiguration: ClientConfiguration = { apiKey: process.env["OCTOPUS_API_KEY"] || "", - apiUri: process.env["OCTOPUS_HOST"] || "", + apiUri: process.env["OCTOPUS_URL"] || "", space: "Default", - autoConnect: true + autoConnect: true, }; const client = await Client.create(clientConfiguration); @@ -38,11 +38,11 @@ describe("client", () => { test("throws with invalid space", async () => { const clientConfiguration: ClientConfiguration = { apiKey: process.env["OCTOPUS_API_KEY"] || "", - apiUri: process.env["OCTOPUS_HOST"] || "", + apiUri: process.env["OCTOPUS_URL"] || "", space: "NonExistent", - autoConnect: true + autoConnect: true, }; await expect(Client.create(clientConfiguration)).rejects.toThrow(); - }) + }); }); diff --git a/src/client.ts b/src/client.ts index 3a4d9c0..6c0539f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -276,6 +276,11 @@ export class Client { this.errorSubscriptions.notifyAll(details); } + do(path: string, command?: any, args?: RouteArgs): Promise { + const url = this.resolveUrlWithSpaceId(path, args); + return this.dispatchRequest("POST", url, command, true) as Promise; + } + post(path: string, resource?: any, args?: RouteArgs): Promise { const url = this.resolveUrlWithSpaceId(path, args); return this.dispatchRequest("POST", url, resource) as Promise; @@ -404,7 +409,7 @@ export class Client { return link; } - private dispatchRequest(method: any, url: string, requestBody?: any) { + private dispatchRequest(method: any, url: string, requestBody?: any, useCamelCase: boolean = false) { return new Promise((resolve, reject) => { new ApiClient({ configuration: this.configuration, @@ -419,7 +424,7 @@ export class Client { onRequestCallback: (r) => this.onRequest(r), onResponseCallback: (r) => this.onResponse(r), onErrorResponseCallback: (r) => this.onErrorResponse(r), - }).execute(); + }).execute(useCamelCase); }); } diff --git a/src/clientConfiguration.ts b/src/clientConfiguration.ts index b347ab3..0d4abc1 100644 --- a/src/clientConfiguration.ts +++ b/src/clientConfiguration.ts @@ -13,7 +13,7 @@ export interface ClientConfiguration { export function processConfiguration(configuration?: ClientConfiguration): ClientConfiguration { const apiKey = process.env[EnvironmentVariables.ApiKey] || ""; - const host = process.env[EnvironmentVariables.Host] || ""; + const host = process.env[EnvironmentVariables.URL] || ""; const space = process.env[EnvironmentVariables.Space] || ""; if (!configuration) { diff --git a/src/environmentVariables.ts b/src/environmentVariables.ts index 8914e47..d5303a8 100644 --- a/src/environmentVariables.ts +++ b/src/environmentVariables.ts @@ -1,6 +1,6 @@ export const EnvironmentVariables = { ApiKey: "OCTOPUS_API_KEY", - Host: "OCTOPUS_HOST", + URL: "OCTOPUS_URL", Proxy: "OCTOPUS_PROXY", ProxyPassword: "OCTOPUS_PROXY_PASSWORD", ProxyUsername: "OCTOPUS_PROXY_USERNAME", diff --git a/src/operations/createRelease/channel-version-rule-tester.ts b/src/operations/createRelease/channel-version-rule-tester.ts deleted file mode 100644 index 716b835..0000000 --- a/src/operations/createRelease/channel-version-rule-tester.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ChannelVersionRuleResource } from '@octopusdeploy/message-contracts'; -import { Resource } from '@octopusdeploy/message-contracts'; -import { SemVer } from 'semver'; -import {Client} from "../../client"; - -export interface IChannelVersionRuleTester { - test( - rule: ChannelVersionRuleResource | undefined, - packageVersion: string | undefined, - feedId: string | undefined - ): Promise; -} - -export class ChannelVersionRuleTester implements IChannelVersionRuleTester { - constructor(readonly client: Client) {} - - async test( - rule: ChannelVersionRuleResource | undefined, - packageVersion: string | undefined, - feedId: string - ) { - if (rule === undefined) - // Anything goes if there is no rule defined for this step - return ChannelVersionRuleTestResult.Null(); - - if (!packageVersion) - // If we don't have a package version, this rule should be ignored - return ChannelVersionRuleTestResult.Failed(); - - const link = this.client.getLink('VersionRuleTest'); - - const resource = { - version: packageVersion, - versionRange: rule.VersionRange, - preReleaseTag: rule.Tag, - feedId - }; - - const version = this.client.tryGetServerInformation()?.version; - - if (version !== undefined && new SemVer(version) > new SemVer('2021.2')) { - return this.client.post(link, resource); - } - - return this.client.get(link, resource); - } -} - -const Pass = 'PASS'; -const Fail = 'FAIL'; - -export class ChannelVersionRuleTestResult implements Resource { - satisfiesVersionRange = false; - satisfiesPreReleaseTag = false; - isNull = false; - - get isSatisfied() { - return this.satisfiesVersionRange && this.satisfiesPreReleaseTag; - } - - toSummaryString() { - return this.isNull - ? 'Allow any version' - : `Range: ${this.satisfiesVersionRange ? Pass : Fail} Tag: ${ - this.satisfiesPreReleaseTag ? Pass : Fail - }`; - } - - static Failed() { - return new ChannelVersionRuleTestResult(); - } - - static Null() { - const channelVersionRuleTestResult = new ChannelVersionRuleTestResult(); - channelVersionRuleTestResult.isNull = true; - channelVersionRuleTestResult.satisfiesVersionRange = true; - channelVersionRuleTestResult.satisfiesPreReleaseTag = true; - - return channelVersionRuleTestResult; - } -} diff --git a/src/operations/createRelease/create-release.test.ts b/src/operations/createRelease/create-release.test.ts index 1893c50..66c3a1e 100644 --- a/src/operations/createRelease/create-release.test.ts +++ b/src/operations/createRelease/create-release.test.ts @@ -1,6 +1,5 @@ import { CommunicationStyle, - ControlType, DeploymentTargetResource, EnvironmentResource, NewDeploymentTarget, @@ -13,20 +12,17 @@ import { StartTrigger, TenantedDeploymentMode, UserResource, - VariableType, } from "@octopusdeploy/message-contracts"; import { PackageRequirement } from "@octopusdeploy/message-contracts/dist/deploymentStepResource"; import { RunConditionForAction } from "@octopusdeploy/message-contracts/dist/runConditionForAction"; import AdmZip from "adm-zip"; import { randomUUID } from "crypto"; import { mkdtemp, readdir, readFile, rm } from "fs/promises"; -import moment from "moment"; import { tmpdir } from "os"; import path from "path"; import { Client } from "../../client"; import { OctopusSpaceRepository, Repository } from "../../repository"; -import { createRelease } from "./create-release"; -import { PackageIdentity } from "./package-identity"; +import { createRelease, CreateReleaseCommandV1 } from "./create-release"; describe("create a release", () => { let client: Client; @@ -50,7 +46,7 @@ describe("create a release", () => { beforeEach(async () => { const spaceName = randomUUID().substring(0, 20); console.log(`Creating space, "${spaceName}"...`); - space = await systemRepository.spaces.create(NewSpace(spaceName, undefined, [user])); + space = await systemRepository.spaces.create(NewSpace(spaceName, [], [user])); console.log(`Space "${spaceName}" created successfully.`); repository = await systemRepository.forSpace(space); @@ -129,211 +125,19 @@ describe("create a release", () => { console.log(`Machine "${machine.Name}" created successfully.`); }); - test("deploy to single environment", async () => { - await createRelease(repository, project, undefined, { - deployTo: [environment], - waitForDeployment: true, - }); - }); - - test("deploy to multiple environments", async () => { - const environment2Name = randomUUID(); - console.log(`Creating environment, "${environment2Name}"...`); - const environment2 = await repository.environments.create({ Name: environment2Name }); - - console.log(`Adding environment, ${environment2Name} to machine, ${machine}...`); - machine.EnvironmentIds = [environment2.Id, ...machine.EnvironmentIds]; - await repository.machines.modify(machine); - - const lifecycleName = "Development"; - const lifecycle = (await repository.lifecycles.list({ take: 1 })).Items[0]; - lifecycle.Phases = [ - { - Id: "", - Name: lifecycleName, - OptionalDeploymentTargets: [environment2.Id, environment.Id], - AutomaticDeploymentTargets: [], - IsOptionalPhase: false, - MinimumEnvironmentsBeforePromotion: 0, - ReleaseRetentionPolicy: undefined, - TentacleRetentionPolicy: undefined, - }, - ]; - console.log(`Updating lifecycle, ${lifecycleName}...`); - await repository.lifecycles.modify(lifecycle); - console.log(`Lifecycle, ${lifecycleName} updated successfully.`); - - await createRelease(repository, project, undefined, { - deployTo: [environment, environment2], - waitForDeployment: true, - }); + test("can create a release", async () => { + var command = { + spaceId: space.Id, + projectName: project.Name, + } as CreateReleaseCommandV1; + var response = await createRelease(repository, command); + expect(response.releaseId).toBeTruthy(); + expect(response.releaseVersion).toBeTruthy(); }); - test("deploy to multiple tenants", async () => { - project.TenantedDeploymentMode = TenantedDeploymentMode.Tenanted; - await repository.projects.modify(project); - - const tenant1Name = randomUUID(); - console.log(`Creating tenant, "${tenant1Name}"...`); - const tenant1 = await repository.tenants.create({ - Name: tenant1Name, - ProjectEnvironments: { [project.Id]: [environment.Id] }, - TenantTags: [], - }); - - const tenant2Name = randomUUID(); - console.log(`Creating tenant, "${tenant2Name}"...`); - const tenant2 = await repository.tenants.create({ - Name: tenant2Name, - ProjectEnvironments: { [project.Id]: [environment.Id] }, - TenantTags: [], - }); - console.log(`Tenant, "${tenant2Name}" created successfully.`); - - console.log(`Associating tenants to machine, ${machine.Name}...`); - machine.TenantIds = [tenant1.Id, tenant2.Id]; - await repository.machines.modify(machine); - - await createRelease(repository, project, undefined, { - tenants: [tenant1, tenant2], - deployTo: [environment], - waitForDeployment: true, - }); - }); - - test("deploy to multiple tenants via tag", async () => { - project.TenantedDeploymentMode = TenantedDeploymentMode.Tenanted; - await repository.projects.modify(project); - - const tag = "deploy"; - - const tagSet = await repository.tagSets.create({ - Id: "", - Description: "", - Links: {}, - Name: "tags", - SortOrder: 0, - Tags: [{ CanonicalTagName: `tags/${tag}`, Color: "#333333", Description: "", Id: "", Name: tag, SortOrder: 0 }], - }); - - const tenant1Name = randomUUID(); - console.log(`Creating tenant, "${tenant1Name}"...`); - const tenant1 = await repository.tenants.create({ - Name: tenant1Name, - ProjectEnvironments: { [project.Id]: [environment.Id] }, - TenantTags: [tagSet.Tags[0].CanonicalTagName], - }); - console.log(`Tenant, "${tenant1Name}" created successfully.`); - - const tenant2Name = randomUUID(); - console.log(`Creating tenant, "${tenant2Name}"...`); - const tenant2 = await repository.tenants.create({ - Name: tenant2Name, - ProjectEnvironments: { [project.Id]: [environment.Id] }, - TenantTags: [tagSet.Tags[0].CanonicalTagName], - }); - console.log(`Tenant, "${tenant2Name}" created successfully.`); - - machine.TenantIds = [tenant1.Id, tenant2.Id]; - await repository.machines.modify(machine); - - await createRelease(repository, project, undefined, { - tenantTags: [tagSet.Tags[0].CanonicalTagName], - deployTo: [environment], - waitForDeployment: true, - }); - }); - - test("schedule a deployment in the future", async () => { - const currentDate = new Date(); - const deployAt = moment(new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), 0, 0, 0, 0)).add(10, "days").toDate(); - const noDeployAfter = moment(new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), 0, 0, 0, 0)).add(11, "days").toDate(); - - await createRelease(repository, project, undefined, { - deployTo: [environment], - deployAt, - noDeployAfter, - waitForDeployment: false, - }); - const taskId = (await repository.deployments.list({ take: 1 })).Items[0].TaskId; - const task = await repository.tasks.get(taskId); - - await repository.tasks.cancel(task); - - expect(task.QueueTime).toBeDefined(); - expect(task.QueueTimeExpiry).toBeDefined(); - expect(new Date(Date.parse(task.QueueTime as string)).toISOString()).toStrictEqual(deployAt.toISOString()); - expect(new Date(Date.parse(task.QueueTimeExpiry as string)).toISOString()).toStrictEqual(noDeployAfter.toISOString()); - }); - - test("deploy to single environment with variables", async () => { - const variableSet = await repository.variables.get(project.VariableSetId); - variableSet.Variables = [ - { - Id: "", - Name: "Name", - Type: VariableType.String, - IsEditable: true, - IsSensitive: false, - Value: "", - Description: "", - Scope: {}, - Prompt: { - Label: "Name", - Required: true, - Description: "", - DisplaySettings: { "Octopus.ControlType": ControlType.SingleLineText }, - }, - }, - ]; - await repository.variables.modify(variableSet); - - await createRelease(repository, project, undefined, { - deployTo: [environment], - variable: [{ name: "Name", value: "John" }], - waitForDeployment: true, - }); - }); - - test("deploy to single environment in non default channel", async () => { - const channel = await repository.channels.createForProject( - project, - { - Name: randomUUID(), - LifecycleId: project.LifecycleId, - IsDefault: false, - ProjectId: project.Id, - SpaceId: project.SpaceId, - }, - {} - ); - - await createRelease( - repository, - project, - { channel: channel }, - { - deployTo: [environment], - waitForDeployment: true, - } - ); - }); - - test("deploy to single environment with a specified release number", async () => { - await createRelease( - repository, - project, - { releaseNumber: "1.2.3" }, - { - deployTo: [environment], - waitForDeployment: true, - } - ); - }); - - describe("deploy to single environment with multiple packages", () => { + describe("create with packages", () => { let tempOutDir: string; - const packages: PackageIdentity[] = [new PackageIdentity("Hello", "1.0.0"), new PackageIdentity("GoodBye", "2.0.0")]; + const packages: string[] = ["Hello:1.0.0", "GoodBye:2.0.0"]; beforeAll(async () => { tempOutDir = await mkdtemp(path.join(tmpdir(), "octopus_")); @@ -342,7 +146,7 @@ describe("create a release", () => { zip.addFile("test.txt", Buffer.from("inner content of the file", "utf8")); for (const p of packages) { - const packagePath = path.join(tempOutDir, `${p.id}.${p.version}.zip`); + const packagePath = path.join(tempOutDir, `${p.replace(":", ".")}.zip`); zip.writeZip(packagePath); } }); @@ -380,9 +184,9 @@ describe("create a release", () => { Channels: [], TenantTags: [], Packages: packages.map((p) => ({ - Name: p.id, + Name: p.split(":")[0], FeedId: feedId, - PackageId: p.id, + PackageId: p.split(":")[0], AcquisitionLocation: "Server", Properties: { Extract: "False", SelectionMode: "immediate", Purpose: "" }, Id: "", @@ -421,30 +225,15 @@ describe("create a release", () => { await rm(tempOutDir, { recursive: true }); }); - test("using packagesFolder", async () => { - await createRelease( - repository, - project, - { packagesFolder: tempOutDir }, - { - deployTo: [environment], - waitForDeployment: true, - } - ); - }); - test("using packages", async () => { - await createRelease( - repository, - project, - { - packages, - }, - { - deployTo: [environment], - waitForDeployment: true, - } - ); + var command = { + spaceId: space.Id, + projectName: project.Name, + packages: packages, + } as CreateReleaseCommandV1; + var response = await createRelease(repository, command); + expect(response.releaseId).toBeTruthy(); + expect(response.releaseVersion).toBeTruthy(); }); }); diff --git a/src/operations/createRelease/create-release.ts b/src/operations/createRelease/create-release.ts index 8393b89..7e716ee 100644 --- a/src/operations/createRelease/create-release.ts +++ b/src/operations/createRelease/create-release.ts @@ -1,295 +1,36 @@ -import { - ChannelResource, - HasVersionControlledPersistenceSettings, - PersistenceSettingsType, - ProjectResource, - ReleaseResource, -} from "@octopusdeploy/message-contracts"; -import { processConfiguration } from "../../clientConfiguration"; import { OctopusSpaceRepository } from "../../repository"; -import { DeploymentBase } from "../deployRelease/deployment-base"; -import { DeploymentOptions } from "../deployRelease/deployment-options"; -import { throwIfUndefined } from "../throw-if-undefined"; -import { ChannelVersionRuleTester } from "./channel-version-rule-tester"; -import { PackageVersionResolver } from "./package-version-resolver"; -import { ReleaseOptions } from "./release-options"; -import { ReleasePlan } from "./release-plan"; -import { ReleasePlanBuilder } from "./release-plan-builder"; -function releaseOptionsDefaults(): ReleaseOptions { - return { - ignoreChannelRules: false, - ignoreExisting: false, - packages: [], - whatIf: false, - }; -} - -export async function createRelease( - repository: OctopusSpaceRepository, - project: ProjectResource, - releaseOptions?: Partial, - deploymentOptions?: Partial -): Promise { - const proj = await throwIfUndefined( - async (nameOrId) => await repository.projects.find(nameOrId), - async (id) => repository.projects.get(id), - "Projects", - "project", - project.Name - ); - - const configuration = processConfiguration(); - console.log(`Creating a release...`); - await new CreateRelease(repository, configuration.apiUri, proj, releaseOptions, deploymentOptions).execute(); - console.log(`Release created successfully.`); +export interface CreateReleaseCommandV1 { + spaceId: string; + projectName: string; + packageVersion: string; + gitCommit?: string; + gitRef?: string; + releaseVersion?: string; + channelName?: string; + packages?: string[]; + releaseNotes?: string; + ignoreIfAlreadyExists: boolean; + ignoreChannelRules: boolean; + packagePrerelease?: string; } -class CreateRelease extends DeploymentBase { - private gitReference: string | undefined; - private releasePlanBuilder: ReleasePlanBuilder; - private plan: ReleasePlan | undefined; - private versionNumber: string | undefined; - private readonly packageVersionResolver: PackageVersionResolver; - private readonly releaseOptions: ReleaseOptions; - - constructor( - repository: OctopusSpaceRepository, - serverUrl: string, - private readonly project: ProjectResource, - releaseOptions?: Partial, - deploymentOptions?: Partial - ) { - super(repository, serverUrl, deploymentOptions); - - this.releaseOptions = { - ...releaseOptionsDefaults(), - ...releaseOptions, - }; - - this.packageVersionResolver = new PackageVersionResolver(); - this.releasePlanBuilder = new ReleasePlanBuilder(repository.client, this.packageVersionResolver, new ChannelVersionRuleTester(repository.client)); - } - - private async releaseNotesFallBackToDeploymentSettings() { - if (this.releaseOptions.releaseNotes) return; - } - - async execute(): Promise { - this.validateProjectPersistenceRequirements(); - - if (this.releaseOptions.defaultPackageVersion != undefined) { - this.packageVersionResolver.setDefault(this.releaseOptions.defaultPackageVersion); - } - - if (this.releaseOptions.packagesFolder != undefined) { - await this.packageVersionResolver.addFolder(this.releaseOptions.packagesFolder); - } - - for (const pkg of this.releaseOptions.packages) { - await this.packageVersionResolver.addPackage(pkg.id, pkg.version); - } - - const plan = await this.buildReleasePlan(); - if (!plan) return; - - if (this.releaseOptions.releaseNumber) { - this.versionNumber = this.releaseOptions.releaseNumber; - console.debug(`Using version number provided on command-line: ${this.versionNumber}`); - } else if (plan.releaseTemplate.NextVersionIncrement) { - this.versionNumber = plan.releaseTemplate.NextVersionIncrement; - console.debug(`Using version number from release template: ${this.versionNumber}`); - } else if (plan.releaseTemplate.VersioningPackageStepName) { - this.versionNumber = plan.getActionVersionNumber( - plan.releaseTemplate.VersioningPackageStepName, - plan.releaseTemplate.VersioningPackageReferenceName - ); - console.debug(`Using version number from package step: ${this.versionNumber}`); - } else { - throw new Error("A version number was not specified and could not be automatically selected."); - } - - if (plan.isViableReleasePlan()) { - console.info(`Release plan for ${this.project.Name} ${this.versionNumber}`); - } else { - console.warn(`Release plan for ${this.project.Name} ${this.versionNumber}`); - } - - if (plan.hasUnresolvedSteps()) - throw new Error( - "Package versions could not be resolved for one or more of the package packageSteps in this release. See the errors above for details. Either ensure the latest version of the package can be automatically resolved, or set the version to use specifically by using the --package argument." - ); - if (!plan.channelHasAnyEnabledSteps()) { - throw new Error(`Channel ${plan.channel?.Name} has no available steps`); - } - - if (plan.hasStepsViolatingChannelVersionRules()) { - if (this.releaseOptions.ignoreChannelRules) - console.warn( - `At least one step violates the package version rules for the Channel '${plan.channel?.Name}'. Forcing the release to be created ignoring these rules...` - ); - else - throw new Error( - `At least one step violates the package version rules for the Channel '${plan.channel?.Name}'. Either correct the package versions for this release, let Octopus select the best channel by omitting the --channel argument, select a different channel using --channel=MyChannel argument, or ignore these version rules altogether by using the --ignoreChannelRules argument.` - ); - } - - if (this.releaseOptions.ignoreExisting) { - console.debug(`Checking for existing release for ${this.project.Name} ${this.versionNumber} because you specified --ignoreExisting...`); - try { - const found = await this.repository.projects.getReleaseByVersion(this.project, this.versionNumber); - if (found !== undefined) { - console.info( - `A release of ${this.project.Name} with the number ${this.versionNumber} already exists, and you specified --ignoreExisting, so we won't even attempt to create the release.` - ); - return; - } - } catch { - // Expected - console.debug("No release exists - the coast is clear!"); - } - } - - if (this.releaseOptions.whatIf) { - // We were just doing a dry run - bail out here - if (this.deploymentOptions.deployTo.length > 0) - console.info(`[WhatIf] This release would have been created using the release plan and deployed to ${this.deploymentOptions.deployTo}`); - else console.info("[WhatIf] This release would have been created using the release plan"); - } else { - // Actually create the release! - console.debug("Creating release..."); - - await this.releaseNotesFallBackToDeploymentSettings(); - - const releaseResource: Partial = { - ChannelId: plan.channel?.Id, - ProjectId: this.project.Id, - SelectedPackages: plan.getSelections(), - Version: this.versionNumber, - }; - - releaseResource.VersionControlReference = - this.project.PersistenceSettings.Type === PersistenceSettingsType.VersionControlled - ? { - GitRef: this.releaseOptions.gitRef, - GitCommit: this.releaseOptions.gitCommit, - } - : undefined; - - const release = await this.repository.releases.create(releaseResource as ReleaseResource, this.releaseOptions.ignoreChannelRules); - - console.info(`Release ${release.Version} created successfully.`); - if (release.VersionControlReference?.GitCommit) - console.info( - `Release created from commit ${release.VersionControlReference.GitCommit} of git reference ${release.VersionControlReference.GitRef}.` - ); - - await this.deployRelease(this.project, release); - } - } - - private async buildReleasePlan() { - if (this.releaseOptions.channel) { - console.info(`Building release plan for channel '${this.releaseOptions.channel.Name}'...`); - - const matchingChannel = await this.getMatchingChannel(this.releaseOptions.channel.Name); - - return await this.releasePlanBuilder.build( - this.repository, - this.project, - matchingChannel, - this.releaseOptions.packagePrerelease, - this.releaseOptions.gitRef, - this.releaseOptions.gitCommit - ); - } - - console.debug("Automatically selecting the best channel for this release..."); - return await this.autoSelectBestReleasePlanOrThrow(); - } - - async getChannel() { - let branch: string | undefined = undefined; - - if (HasVersionControlledPersistenceSettings(this.project.PersistenceSettings)) { - branch = this.releaseOptions.gitCommit ?? this.releaseOptions.gitRef ?? this.project.PersistenceSettings.DefaultBranch; - } - return await this.repository.projects.getChannels(this.project, branch, 0, this.repository.projects.takeAll); - } - - private async autoSelectBestReleasePlanOrThrow() { - // Build a release plan for each channel to determine which channel is the best match for the provided options - const channels = await this.getChannel(); - const candidateChannels = channels.Items; - const releasePlans: ReleasePlan[] = []; - for (const channel of candidateChannels) { - console.info(`Building a release plan for channel, "${channel.Name}"...`); - - this.plan = await this.releasePlanBuilder.build( - this.repository, - this.project, - channel, - this.releaseOptions.packagePrerelease, - this.releaseOptions.gitRef, - this.releaseOptions.gitCommit - ); - - releasePlans.push(this.plan); - if (!this.plan.channelHasAnyEnabledSteps()) console.warn(`Channel, "${channel.Name}" does not have any enabled package steps.`); - } - - const viablePlans = releasePlans.filter((p) => p.isViableReleasePlan()); - if (viablePlans.length === 0) - throw new Error( - "There are no viable release plans in any channels using the provided arguments. The following release plans were considered:" + - `Sorry not implemented yet!` - ); - - if (viablePlans.length === 1) { - const selectedPlan = viablePlans[0]; - console.info(`Selected the release plan for channel, "${selectedPlan.channel?.Name}".`); - return selectedPlan; - } - - if (viablePlans.length > 1 && viablePlans.some((p) => p.channel?.IsDefault)) { - const selectedPlan = viablePlans.find((p) => p.channel?.IsDefault); - console.info( - `Selected the release plan for channel "${selectedPlan?.channel?.Name}" - there were multiple matching Channels (${viablePlans - .map((p) => p.channel?.Name) - .reduce((previousValue, currentValue) => `${previousValue},${currentValue}`, "")}) so we selected the default channel.` - ); - return selectedPlan; - } +export interface CreateReleaseResponseV1 { + releaseId: string; + releaseVersion: string; +} - throw new Error( - `There are ${viablePlans.length} viable release plans using the provided arguments so we cannot auto-select one. The viable release plans are:` + - `Sorry not implemented yet!` + - "The unviable release plans are:" + - `Sorry not implemented yet!` - ); - } +export async function createRelease(repository: OctopusSpaceRepository, command: CreateReleaseCommandV1): Promise { + console.log(`Creating a release...`); - private async getMatchingChannel(channelNameOrId: string): Promise { - return throwIfUndefined( - async (nameOrId) => this.repository.channels.find(nameOrId), - async (id) => this.repository.channels.get(id), - "Channels", - "channel", - channelNameOrId - ); - } + // WARNING: server's API currently expects there to be a SpaceIdOrName value, which was intended to allow use of names/slugs, but doesn't + // work properly due to limitations in the middleware. For now, we'll just set it to the SpaceId + var response = await repository.client.do(`~/api/{spaceId}/releases/create/v1`, { + spaceIdOrName: command.spaceId, + ...command, + }); - validateProjectPersistenceRequirements() { - const wasGitRefProvided = this.releaseOptions.gitRef; - if (!wasGitRefProvided && this.project.PersistenceSettings.Type === PersistenceSettingsType.VersionControlled) { - this.gitReference = this.project.PersistenceSettings.DefaultBranch; - console.info(`No gitRef parameter provided. Using Project Default Branch: ${this.project.PersistenceSettings.DefaultBranch}`); - } + console.log(`Release created successfully.`); - if (!this.project.IsVersionControlled && wasGitRefProvided) - throw new Error( - "Since the provided project is not a version controlled project," + - " the --gitCommit and --gitRef arguments are not supported for this command." - ); - } + return response; } diff --git a/src/operations/createRelease/index.ts b/src/operations/createRelease/index.ts index 38eaf31..aae947d 100644 --- a/src/operations/createRelease/index.ts +++ b/src/operations/createRelease/index.ts @@ -1,7 +1 @@ export * from "./create-release"; -export * from "./package-identity"; -export * from "./package-version-resolver"; -export * from "./release-options"; -export * from "./release-plan"; -export * from "./release-plan-builder"; -export * from "./release-plan-item"; diff --git a/src/operations/createRelease/package-version-resolver.ts b/src/operations/createRelease/package-version-resolver.ts deleted file mode 100644 index 74c64ae..0000000 --- a/src/operations/createRelease/package-version-resolver.ts +++ /dev/null @@ -1,144 +0,0 @@ -import glob from "glob"; -import path from "path"; -import { SemVer, valid } from "semver"; -import { PackageIdentity } from "./package-identity"; - -const WildCard = "*"; - -function packageKey(stepNameOrPackageId: string, referenceName: string) { - return `${stepNameOrPackageId}:${referenceName}`.toLowerCase(); -} - -export interface IPackageVersionResolver { - addFolder(folderPath: string): void; - - add(stepNameOrPackageId: string, packageReferenceName: string | undefined, packageVersion: string): void; - - setDefault(packageVersion: string): void; - - resolveVersion(stepName: string, packageId: string, packageReferenceName: string | undefined): string | undefined; -} - -export class PackageVersionResolver implements IPackageVersionResolver { - static readonly SupportedZipFilePatterns = ["*.zip", "*.tgz", "*.tar.gz", "*.tar.Z", "*.tar.bz2", "*.tar.bz", "*.tbz", "*.tar", "*.nupkg"]; - - readonly stepNameToVersion = new Map(); - defaultVersion: string | undefined; - - async addFolder(folderPath: string) { - const retrievePackages = async (pattern: string): Promise => { - return new Promise((resolve, reject) => { - glob(`${folderPath}/**/${pattern}`, {}, (err, matches) => { - if (err) { - reject(err); - return; - } - resolve(matches); - }); - }); - }; - console.debug(`Using package versions from '${folderPath}' folder`); - const files = await retrievePackages(`*(${PackageVersionResolver.SupportedZipFilePatterns.map((v) => v).join("|")})`); - for (const file of files) { - console.debug(`Package file: ${file}`); - const packageIdentity = this.tryParseIdAndVersion(file); - if (packageIdentity) { - this.add(packageIdentity.id, undefined, packageIdentity.version); - } - } - } - - add3(stepNameOrPackageIdAndVersion: string) { - const split = stepNameOrPackageIdAndVersion.split(/[:=/]+/); - if (split.length < 2) - throw new Error(`The package argument '${stepNameOrPackageIdAndVersion}' does not use expected format of : {Step Name}:{Version}`); - - const stepNameOrPackageId = split[0]; - const packageReferenceName = split.length > 2 ? split[1] : WildCard; - const version = split.length > 2 ? split[2] : split[1]; - - if (!stepNameOrPackageId || !version) - throw new Error(`The package argument '${stepNameOrPackageIdAndVersion}' does not use expected format of : {Step Name}:{Version}`); - - this.add(stepNameOrPackageId, packageReferenceName, version); - } - - addPackage(stepNameOrPackageId: string, packageVersion: string) { - this.add(stepNameOrPackageId, "", packageVersion); - } - - add(stepNameOrPackageId: string, packageReferenceName: string | undefined, packageVersion: string) { - // Double wild card == default value - if (stepNameOrPackageId === WildCard && packageReferenceName === WildCard) { - this.setDefault(packageVersion); - return; - } - - const key = packageKey(stepNameOrPackageId, packageReferenceName ?? WildCard); - const current = this.stepNameToVersion.get(key); - if (current !== undefined) { - const newVersion = new SemVer(packageVersion); - const currentVersion = new SemVer(current); - if (newVersion.compare(currentVersion) < 0) return; - } - - this.stepNameToVersion.set(key, packageVersion); - } - - setDefault(packageVersion: string) { - if (valid(packageVersion) === null) { - throw new Error("Invalid package version"); - } - this.defaultVersion = packageVersion; - } - - tryParseIdAndVersion(filename: string): PackageIdentity | undefined { - let idAndVersion = path.basename(filename, path.extname(filename)); - const tarExtension = path.extname(idAndVersion); - if ( - tarExtension.localeCompare(".tar", undefined, { - sensitivity: "accent", - }) === 0 - ) { - idAndVersion = path.basename(idAndVersion, tarExtension); - } - - const packageIdPattern = /(?(\w+([_.-]\w+)*?))/; - const semanticVersionPattern = new RegExp( - "(?(\\d+(.\\d+){0,3}" + // Major Minor Patch - "(-[0-9A-Za-z-]+(.[0-9A-Za-z-]+)*)?)" + // Pre-release identifiers - "(\\+[0-9A-Za-z-]+(.[0-9A-Za-z-]+)*)?)" - ); // Build Metadata - - const match = new RegExp(`^${packageIdPattern.source}\\.${semanticVersionPattern.source}$`).exec(idAndVersion); - const packageIdMatch = match?.groups?.packageId; - const versionMatch = match?.groups?.semanticVersion; - - if (!packageIdMatch || !versionMatch) { - return; - } - - const packageId = packageIdMatch; - - if (!valid(versionMatch)) { - return; - } - - return new PackageIdentity(packageId, versionMatch); - } - - resolveVersion(stepName: string, packageId: string, packageReferenceName: string | undefined) { - const identifiers = [stepName, packageId]; - return ( - identifiers - .map((id) => packageKey(id, packageReferenceName ?? "")) - .map((key) => this.stepNameToVersion.get(key) ?? undefined) - .find((version) => version !== undefined) ?? - identifiers - .flatMap((id) => [packageKey(WildCard, packageReferenceName ?? ""), packageKey(id, WildCard)]) - .map((key) => this.stepNameToVersion.get(key) ?? undefined) - .find((version) => version !== undefined) ?? - this.defaultVersion - ); - } -} diff --git a/src/operations/createRelease/release-options.ts b/src/operations/createRelease/release-options.ts deleted file mode 100644 index 4bcd6bd..0000000 --- a/src/operations/createRelease/release-options.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ChannelResource } from "@octopusdeploy/message-contracts"; -import { PackageIdentity } from "./package-identity"; - -export interface ReleaseOptions { - channel?: ChannelResource; - gitRef?: string | undefined; - gitCommit?: string | undefined; - ignoreChannelRules: boolean; - ignoreExisting: boolean; - packagePrerelease?: string | undefined; - defaultPackageVersion?: string | undefined; - packages: PackageIdentity[]; - packagesFolder?: string | undefined; - releaseNotes?: string | undefined; - releaseNotesFile?: string | undefined; - releaseNumber?: string | undefined; - whatIf: boolean; -} diff --git a/src/operations/createRelease/release-plan-builder.ts b/src/operations/createRelease/release-plan-builder.ts deleted file mode 100644 index 55aba9b..0000000 --- a/src/operations/createRelease/release-plan-builder.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - ChannelResource, - DeploymentProcessResource, - FeedResource, - PackageResource, - ProjectResource, - ReleaseTemplateResource, -} from "@octopusdeploy/message-contracts"; -import { Client, OctopusSpaceRepository } from "../../"; -import { CouldNotFindError } from "../could-not-find-error"; -import { IChannelVersionRuleTester } from "./channel-version-rule-tester"; -import { IPackageVersionResolver } from "./package-version-resolver"; -import { ReleasePlan } from "./release-plan"; -import { ReleasePlanItem } from "./release-plan-item"; - -const GitReferenceMissingForVersionControlledProjectErrorMessage = - "Attempting to create a release for a version controlled project, but no git reference has been provided. Use the gitRef parameter to supply a git reference."; - -export class ReleasePlanBuilder { - static gitReferenceSuppliedForDatabaseProjectErrorMessage(gitObjectName: string) { - return `Attempting to create a release from version control because the git ${gitObjectName} was provided. The selected project is not a version controlled project.`; - } - - constructor(readonly client: Client, readonly versionResolver: IPackageVersionResolver, readonly channelVersionRuleTester: IChannelVersionRuleTester) {} - - private static async loadFeedsForSteps(repository: OctopusSpaceRepository, project: ProjectResource, steps: ReleasePlanItem[]) { - // PackageFeedId can be an id or a name - const allRelevantFeedIdOrName = steps.map((step) => step.packageFeedId).filter((feedId): feedId is string => feedId !== undefined); - - const allRelevantFeeds = await repository.feeds.list({ - ids: allRelevantFeedIdOrName, - take: repository.feeds.takeAll, - }); - - return new Map(project.IsVersionControlled ? allRelevantFeeds.Items.map((p) => [p.Name, p]) : allRelevantFeeds.Items.map((p) => [p.Id, p])); - } - - private buildChannelVersionFilters(stepName: string, packageReferenceName: string, channel: ChannelResource | undefined) { - const filters: { [p: string]: string } = {}; - - if (channel === undefined) return filters; - - const rule = channel.Rules.find((r) => - r.ActionPackages.some( - (pkg) => - pkg.DeploymentAction.localeCompare(stepName, undefined, { - sensitivity: "accent", - }) === 0 && - pkg.PackageReference?.localeCompare(packageReferenceName, undefined, { - sensitivity: "accent", - }) === 0 - ) - ); - - if (rule === null || rule === undefined) return filters; - - if (!rule.VersionRange) filters["versionRange"] = rule.VersionRange; - - if (!rule.Tag) filters["preReleaseTag"] = rule.Tag; - - return filters; - } - - async build( - repository: OctopusSpaceRepository, - project: ProjectResource, - channel: ChannelResource, - versionPreReleaseTag: string | undefined, - gitReference: string | undefined, - gitCommit: string | undefined - ) { - return !gitReference - ? await this.buildReleaseFromDatabase(repository, project, channel, versionPreReleaseTag) - : await this.buildReleaseFromVersionControl(repository, project, channel, versionPreReleaseTag, gitReference, gitCommit); - } - - async buildReleaseFromDatabase( - repository: OctopusSpaceRepository, - project: ProjectResource, - channel: ChannelResource, - versionPreReleaseTag: string | undefined - ) { - if (project.IsVersionControlled) throw new Error(GitReferenceMissingForVersionControlledProjectErrorMessage); - - console.debug("Finding deployment process..."); - const deploymentProcess = await repository.deploymentProcesses.get(project.DeploymentProcessId, undefined); - if (deploymentProcess === undefined) throw new CouldNotFindError(`a deployment process for project "${project.Name}"`); - - console.debug("Finding release template..."); - const releaseTemplate = await repository.deploymentProcesses.getTemplate(deploymentProcess, channel); - if (releaseTemplate === undefined) - throw new CouldNotFindError( - channel ? `a release template for project "${project.Name}" and channel "${channel.Name}"` : `A release template for project "${project.Name}"` - ); - - return await this.buildInternal(repository, project, channel, versionPreReleaseTag, releaseTemplate, deploymentProcess); - } - - async buildReleaseFromVersionControl( - repository: OctopusSpaceRepository, - project: ProjectResource, - channel: ChannelResource, - versionPreReleaseTag: string | undefined, - gitReference: string | undefined, - gitCommit: string | undefined - ) { - const gitObject = !gitCommit ? gitReference : gitCommit; - const gitObjectName = !gitCommit ? `reference ${gitReference}` : `commit ${gitCommit}`; - - if (!project.IsVersionControlled) throw new Error(ReleasePlanBuilder.gitReferenceSuppliedForDatabaseProjectErrorMessage(gitObjectName)); - - console.debug(`Finding deployment process at git ${gitObjectName}...`); - const deploymentProcess = await repository.deploymentProcesses.get(project.DeploymentProcessId, gitObject); - if (deploymentProcess === undefined) throw new CouldNotFindError(`a deployment process for project "${project.Name}" and git ${gitObjectName}.`); - - console.debug(`Finding release template at git ${gitObjectName}...`); - const releaseTemplate = await repository.deploymentProcesses.getTemplate(deploymentProcess, channel); - if (releaseTemplate === undefined) - throw new CouldNotFindError(`a release template for project "${project.Name}", channel "${channel.Name}" and git ${gitObjectName}.`); - - return await this.buildInternal(repository, project, channel, versionPreReleaseTag, releaseTemplate, deploymentProcess); - } - - private async buildInternal( - repository: OctopusSpaceRepository, - project: ProjectResource, - channel: ChannelResource | undefined, - versionPreReleaseTag: string | undefined, - releaseTemplate: ReleaseTemplateResource, - deploymentProcess: DeploymentProcessResource - ) { - const plan = new ReleasePlan(project, channel, releaseTemplate, deploymentProcess, this.versionResolver); - - if (plan.unresolvedSteps.length > 0) { - console.debug("The package version for some steps was not specified. Attempting to resolve those automatically..."); - - const allRelevantFeeds = await ReleasePlanBuilder.loadFeedsForSteps(repository, project, plan.unresolvedSteps); - - for (const unresolved of plan.unresolvedSteps) { - if (!unresolved.isResolvable) { - console.error( - `The version number for step, "${unresolved.actionName}" cannot be automatically resolved because the feed or package ID is dynamic.` - ); - continue; - } - - if (versionPreReleaseTag) - console.debug(`Finding latest package with pre-release "${versionPreReleaseTag}" for step, "${unresolved.actionName}"...`); - else console.debug(`Finding latest package for step, "${unresolved.actionName}"...`); - - if (!allRelevantFeeds.has(unresolved.packageFeedId as string)) { - throw new Error(`Could not find a feed with ID "${unresolved.packageFeedId}", which is used by step: "${unresolved.actionName}".`); - } - const feed = allRelevantFeeds.get(unresolved.packageFeedId as string) as FeedResource; - const filters = this.buildChannelVersionFilters(unresolved.actionName, unresolved.packageReferenceName as string, channel); - filters["packageId"] = unresolved.packageId as string; - if (versionPreReleaseTag) filters["preReleaseTag"] = versionPreReleaseTag; - - const packages = await this.client.get(feed.Links.SearchTemplate, filters); - const latestPackage = packages[0]; - - if (packages.length === 0) { - console.info(`Could not find any packages with ID "${unresolved.packageId}" that match the channel filter, in the feed, "${feed.Name}".`); - } else { - console.debug(`Selected "${latestPackage.PackageId}" version "${latestPackage.Version}" for "${unresolved.actionName}".`); - unresolved.setVersionFromLatest(latestPackage.Version); - } - } - } - - // Test each step in this plan satisfies the channel version rules - if (channel !== undefined) - for (const step of plan.packageSteps) { - // Note the rule can be null, meaning: anything goes - const rule = channel.Rules.find((r) => - r.ActionPackages.some( - (pkg) => - pkg.DeploymentAction.localeCompare(step.actionName, undefined, { - sensitivity: "accent", - }) === 0 && - pkg.PackageReference?.localeCompare(step.packageReferenceName as string, undefined, { - sensitivity: "accent", - }) === 0 - ) - ); - const result = await this.channelVersionRuleTester.test(rule, step.version, step.packageFeedId); - step.setChannelVersionRuleTestResult(result); - } - - return plan; - } -} diff --git a/src/operations/createRelease/release-plan-item.ts b/src/operations/createRelease/release-plan-item.ts deleted file mode 100644 index 51d1b70..0000000 --- a/src/operations/createRelease/release-plan-item.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ChannelVersionRuleTestResult } from "./channel-version-rule-tester"; - -export class ReleasePlanItem { - version: string | undefined; - versionSource: string; - channelVersionRuleTestResult: ChannelVersionRuleTestResult | undefined; - isDisabled = false; - - constructor( - readonly actionName: string, - readonly packageReferenceName: string | undefined, - readonly packageId: string | undefined, - readonly packageFeedId: string | undefined, - readonly isResolvable: boolean, - userSpecifiedVersion: string | undefined - ) { - this.version = userSpecifiedVersion; - this.versionSource = !this.version ? "Cannot resolve" : "User specified"; - } - - setVersionFromLatest(version: string) { - this.version = version; - this.versionSource = "Latest available"; - } - - setChannelVersionRuleTestResult(result: ChannelVersionRuleTestResult) { - this.channelVersionRuleTestResult = result; - } -} diff --git a/src/operations/createRelease/release-plan.ts b/src/operations/createRelease/release-plan.ts deleted file mode 100644 index 30a0eb4..0000000 --- a/src/operations/createRelease/release-plan.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ChannelResource, DeploymentProcessResource, ProjectResource, ReleaseTemplateResource, SelectedPackage } from "@octopusdeploy/message-contracts"; -import { IPackageVersionResolver } from "./package-version-resolver"; -import { ReleasePlanItem } from "./release-plan-item"; - -export class ReleasePlan { - readonly packageSteps: ReleasePlanItem[]; - readonly scriptSteps: ReleasePlanItem[]; - - constructor( - readonly project: ProjectResource, - readonly channel: ChannelResource | undefined, - readonly releaseTemplate: ReleaseTemplateResource, - deploymentProcess: DeploymentProcessResource, - private versionResolver: IPackageVersionResolver - ) { - this.scriptSteps = deploymentProcess.Steps.flatMap((s) => s.Actions) - .map((a) => ({ - stepName: a.Name, - packageId: a.Properties["Octopus.Action.Package.PackageId"] ?? "", - feedId: a.Properties["Octopus.Action.Package.FeedId"] ?? "", - isDisabled: a.IsDisabled, - channels: a.Channels, - })) - .filter((x) => !x.packageId && !x.isDisabled) // only consider enabled script steps - .filter((a) => a.channels.length === 0 || a.channels.find((id) => id === channel?.Id)) // only include actions without channel scope or with a matching channel scope - .map((x) => { - const releasePlanItem = new ReleasePlanItem(x.stepName, undefined, undefined, undefined, true, undefined); - - releasePlanItem.isDisabled = x.isDisabled; - return releasePlanItem; - }); - - this.packageSteps = releaseTemplate.Packages.map( - (p) => - new ReleasePlanItem( - p.ActionName, - p.PackageReferenceName, - p.PackageId, - p.FeedId, - p.IsResolvable, - versionResolver.resolveVersion(p.ActionName, p.PackageId, p.PackageReferenceName) - ) - ); - } - - get unresolvedSteps() { - return this.packageSteps.filter((s) => !s.version); - } - - channelHasAnyEnabledSteps() { - return ReleasePlan.anyEnabled(this.packageSteps) || ReleasePlan.anyEnabled(this.scriptSteps); - } - - private static anyEnabled(items: ReleasePlanItem[]) { - return items.some((x) => !x.isDisabled); - } - - isViableReleasePlan() { - return !this.hasUnresolvedSteps() && !this.hasStepsViolatingChannelVersionRules() && this.channelHasAnyEnabledSteps(); - } - - hasUnresolvedSteps() { - return this.unresolvedSteps.length > 0; - } - - hasStepsViolatingChannelVersionRules() { - return this.channel !== undefined && this.packageSteps.some((s) => s.channelVersionRuleTestResult?.isSatisfied !== true); - } - - formatAsTable() { - return undefined; - } - - getActionVersionNumber(packageStepName: string, packageReferenceName: string | undefined) { - const step = this.packageSteps.find( - (s) => - s.actionName.localeCompare(packageStepName, undefined, { - sensitivity: "accent", - }) === 0 && - (!s.packageReferenceName || s.packageReferenceName.localeCompare(packageReferenceName ?? "", undefined, { sensitivity: "accent" }) === 0) - ); - if (step === undefined) - throw new Error(`The step '${packageStepName}' is configured to provide the package version number but doesn't exist in the release plan.`); - if (!step.version) - throw new Error(`The step '${packageStepName}' provides the release version number but no package version could be determined from it.`); - return step.version; - } - - getSelections(): SelectedPackage[] { - return this.packageSteps.map((x) => ({ - ActionName: x.actionName, - PackageReferenceName: x.packageReferenceName, - Version: x.version as string, - })); - } -} diff --git a/src/operations/deployRelease/deploy-release.ts b/src/operations/deployRelease/deploy-release.ts deleted file mode 100644 index 92dcae9..0000000 --- a/src/operations/deployRelease/deploy-release.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ChannelResource, EnvironmentResource, ProjectResource, ReleaseResource, ResourceCollection } from "@octopusdeploy/message-contracts"; -import { SemVer } from "semver"; -import { processConfiguration } from "../../clientConfiguration"; -import { OctopusSpaceRepository } from "../../repository"; -import { CouldNotFindError } from "../could-not-find-error"; -import { throwIfUndefined } from "../throw-if-undefined"; -import { DeploymentBase } from "./deployment-base"; -import { DeploymentOptions } from "./deployment-options"; - -export async function deployRelease( - repository: OctopusSpaceRepository, - project: ProjectResource, - version: string | "latest", - deployTo: EnvironmentResource[], - channel?: ChannelResource, - updateVariables?: boolean | undefined, - deploymentOptions?: Partial -): Promise { - const proj = await throwIfUndefined( - async (nameOrId) => await repository.projects.find(nameOrId), - async (id) => repository.projects.get(id), - "Projects", - "project", - project.Id - ); - - const configuration = processConfiguration(); - await new DeployRelease(repository, configuration.apiUri, proj, deployTo, deploymentOptions).execute(version, channel, updateVariables); -} - -class DeployRelease extends DeploymentBase { - constructor( - repository: OctopusSpaceRepository, - serverUrl: string, - private readonly project: ProjectResource, - deployTo: EnvironmentResource[], - deploymentOptions?: Partial - ) { - super(repository, serverUrl, { - ...deploymentOptions, - ...{ deployTo: deployTo }, - }); - } - - async execute(version: string, channel?: ChannelResource, updateVariables: boolean | undefined = false) { - let channelResource: ChannelResource | undefined = undefined; - if (channel) { - channelResource = await throwIfUndefined( - async (nameOrId) => await this.repository.channels.find(nameOrId), - async (id) => this.repository.channels.get(id), - "Channels", - "channel", - channel.Name - ); - } - - const releaseToPromote = await this.getReleaseByVersion(version, this.project, channelResource); - - if (updateVariables) { - console.debug("Updating the release variable snapshot with variables from the project"); - await this.repository.releases.snapshotVariables(releaseToPromote); - } - await this.deployRelease(this.project, releaseToPromote); - } - - async getReleaseByVersion(versionNumber: string, project: ProjectResource, channel: ChannelResource | undefined) { - let releaseToPromote: ReleaseResource | undefined = undefined; - - if (versionNumber === "latest") { - const message = channel === null ? "latest release for project" : `latest release in channel '${channel?.Name}'`; - - console.debug(`Finding ${message}...`); - - const releases = await this.repository.projects.getReleases(project); - const compareFn = (r1: ReleaseResource, r2: ReleaseResource) => { - const r1Version = new SemVer(r1.Version); - const r2Version = new SemVer(r2.Version); - - return r1Version.compare(r2Version); - }; - - if (releases.TotalResults > 0) { - if (channel === undefined) { - releaseToPromote = releases.Items.sort(compareFn)[0]; - } else { - await this.paginate(releases, this.repository, (page) => { - releaseToPromote = page.Items.sort(compareFn).find((r) => r.ChannelId === channel.Id); - - // If we haven't found one yet, keep paginating - return releaseToPromote === undefined; - }); - } - } else { - console.debug(`Finding release ${versionNumber}`); - releaseToPromote = await this.repository.projects.getReleaseByVersion(project, versionNumber); - } - } - - if (releaseToPromote === undefined) throw new CouldNotFindError(`the ${project.Name}`); - - return releaseToPromote; - } - - async paginate( - source: ResourceCollection, - repository: OctopusSpaceRepository, - getNextPage: (items: ResourceCollection) => boolean - ) { - while (getNextPage(source) && source.Items.length > 0 && source.Links["Page.Next"]) - source = await repository.client.get>(source.Links["Page.Next"]); - } -} diff --git a/src/operations/deployRelease/deployment-base.ts b/src/operations/deployRelease/deployment-base.ts deleted file mode 100644 index 85f75a2..0000000 --- a/src/operations/deployRelease/deployment-base.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { - CreateDeploymentResource, - DeploymentPromotionTarget, - DeploymentResource, - DeploymentTemplateResource, - ProjectResource, - ReleaseResource, - TenantResource, -} from "@octopusdeploy/message-contracts"; -import { ControlType, VariableValue } from "@octopusdeploy/message-contracts/dist/form"; -import moment from "moment"; -import { OctopusSpaceRepository } from "../../repository"; -import { CouldNotFindError } from "../could-not-find-error"; -import { throwIfUndefined } from "../throw-if-undefined"; -import { DeploymentOptions } from "./deployment-options"; -import { ExecutionResourceWaiter } from "./execution-resource-waiter"; - -function deploymentOptionsDefaults(): DeploymentOptions { - return { - cancelOnTimeout: false, - deployTo: [], - deploymentCheckSleepCycle: 10000, // 10 seconds - deploymentTimeout: 600000, // 10 minutes - excludeMachines: [], - force: false, - forcePackageDownload: false, - noRawLog: false, - progress: true, - skipStepNames: [], - specificMachines: [], - tenantTags: [], - tenants: [], - variable: [], - waitForDeployment: false, - }; -} - -export abstract class DeploymentBase { - private deployments: DeploymentResource[] = []; - private promotionTargets: DeploymentPromotionTarget[] = []; - protected readonly deploymentOptions: DeploymentOptions; - - protected constructor( - protected readonly repository: OctopusSpaceRepository, - private readonly serverUrl: string, - deploymentConfiguration?: Partial - ) { - this.deploymentOptions = { - ...deploymentOptionsDefaults(), - ...deploymentConfiguration, - }; - } - - async deployRelease(project: ProjectResource, release: ReleaseResource) { - this.deployments = - this.deploymentOptions.tenants.length > 0 || this.deploymentOptions.tenantTags.length > 0 - ? await this.deployTenantedRelease(project, release) - : await this.deployToEnvironments(project, release); - - if (this.deployments.length > 0 && this.deploymentOptions.waitForDeployment) { - const waiter = new ExecutionResourceWaiter(this.repository, this.serverUrl); - await waiter.waitForDeploymentToComplete( - this.deployments, - project, - release, - this.deploymentOptions.progress, - this.deploymentOptions.noRawLog, - this.deploymentOptions.rawLogFile, - this.deploymentOptions.cancelOnTimeout, - this.deploymentOptions.deploymentCheckSleepCycle, - this.deploymentOptions.deploymentTimeout - ); - } - } - - private async getTenants(project: ProjectResource, environmentName: string, release: ReleaseResource, releaseTemplate: DeploymentTemplateResource) { - if (this.deploymentOptions.tenants.length === 0 && this.deploymentOptions.tenantTags.length === 0) return []; - - const deployableTenants: TenantResource[] = []; - - if (this.deploymentOptions.tenants.some((t) => t.Id === "*")) { - const tenantPromotions = releaseTemplate.TenantPromotions.filter((tp) => - tp.PromoteTo.some( - (promo) => - promo.Name.localeCompare(environmentName, undefined, { - sensitivity: "accent", - }) === 0 - ) - ).map((tp) => tp.Id); - const tenants = await this.repository.tenants.all({ - ids: tenantPromotions, - }); - deployableTenants.push(...tenants); - - console.info(`Found ${deployableTenants.length} tenant(s) who can deploy ${project.Name} ${release.Version} to ${environmentName}`); - } else { - if (this.deploymentOptions.tenants.length > 0) { - const tenantsByNameOrId = await this.repository.tenants.find(this.deploymentOptions.tenants.map((t) => t.Id)); - deployableTenants.push(...tenantsByNameOrId); - - let unDeployableTenants = deployableTenants.filter((dt) => !dt.ProjectEnvironments.hasOwnProperty(project.Id)).map((dt) => dt.Name); - if (unDeployableTenants.length > 0) - throw new Error( - `Release '${release.Version}' of project '${project.Name}' cannot be deployed for tenant${ - unDeployableTenants.length === 1 ? "" : "s" - } ${unDeployableTenants.join(" or ")}. This may be because either a) ${ - unDeployableTenants.length === 1 ? "it is" : "they are" - } not connected to this project, or b) you do not have permission to deploy ${ - unDeployableTenants.length === 1 ? "it" : "them" - } to this project.` - ); - - unDeployableTenants = deployableTenants - .filter((dt) => { - const tenantPromo = releaseTemplate.TenantPromotions.find((tp) => tp.Id === dt.Id); - return ( - tenantPromo === undefined || - !tenantPromo?.PromoteTo.some( - (tdt) => - tdt.Name.localeCompare(environmentName, undefined, { - sensitivity: "accent", - }) === 0 - ) - ); - }) - .map((dt) => dt.Name); - if (unDeployableTenants.length > 0) - throw new Error( - `Release '${release.Version}' of project '${project.Name}' cannot be deployed for tenant${ - unDeployableTenants.length === 1 ? "" : "s" - } ${unDeployableTenants.join(" or ")} to environment '${environmentName}'. This may be because a) the tenant${ - unDeployableTenants.length === 1 ? "" : "s" - } ${ - unDeployableTenants.length === 1 ? "is" : "are" - } not connected to this environment, a) the environment does not exist or is misspelled, b) The lifecycle has not reached this phase, possibly due to previous deployment failure, c) you don't have permission to deploy to this environment, d) the environment is not in the list of environments defined by the lifecycle, or e) ${ - unDeployableTenants.length === 1 ? "it is" : "they are" - } unable to deploy to this channel.` - ); - } - - if (this.deploymentOptions.tenantTags.length > 0) { - const tenantsByTag = await this.repository.tenants.list({ - tags: this.deploymentOptions.tenantTags.toString(), - take: this.repository.tenants.takeAll, - }); - const deployableByTag = tenantsByTag.Items.filter((dt) => { - const tenantPromo = releaseTemplate.TenantPromotions.find((tp) => tp.Id === dt.Id); - return ( - tenantPromo !== undefined && - tenantPromo.PromoteTo.some( - (tdt) => - tdt.Name.localeCompare(environmentName, undefined, { - sensitivity: "accent", - }) === 0 - ) - ); - }).filter((tenant) => !deployableTenants.some((deployable) => deployable.Id === tenant.Id)); - deployableTenants.push(...deployableByTag); - } - } - - if (deployableTenants.length === 0) - throw new Error( - `No tenants are available to be deployed for release '${release.Version}' of project '${project.Name}' to environment '${environmentName}'. This may be because a) No tenants matched the tags provided b) The tenants that do match are not connected to this project or environment, c) The tenants that do match are not yet able to release to this lifecycle phase, or d) you do not have the appropriate deployment permissions.` - ); - - return deployableTenants; - } - - private async getSpecificMachines() { - const specificMachineIds = []; - if (this.deploymentOptions.specificMachines.length > 0) { - const machines = await this.repository.machines.all({ - ids: this.deploymentOptions.specificMachines, - }); - const missing = this.deploymentOptions.specificMachines.filter((id) => !machines.some((value) => value.Id === id)); - if (missing.length > 0) throw CouldNotFindError.createResource("machine", missing.toString()); - - specificMachineIds.push(...machines.map((m) => m.Id)); - } - - return specificMachineIds; - } - - private async getExcludedMachines() { - const excludedMachineIds = []; - if (this.deploymentOptions.excludeMachines.length > 0) { - const machines = await this.repository.machines.all({ - ids: this.deploymentOptions.excludeMachines, - }); - const missing = this.deploymentOptions.excludeMachines.filter((id) => !machines.some((value) => value.Id === id)); - if (missing.length > 0) throw CouldNotFindError.createResource("machine", missing.toString()); - - excludedMachineIds.push(...machines.map((m) => m.Id)); - } - - return excludedMachineIds; - } - - private logScheduledDeployment() { - if (this.deploymentOptions.deployAt) { - console.info(`Deployment will be scheduled to start at ${this.deploymentOptions.deployAt.toLocaleString()}`); - } - } - - private async createDeploymentTask( - project: ProjectResource, - release: ReleaseResource, - promotionTarget: DeploymentPromotionTarget, - specificMachineIds: string[], - excludedMachineIds: string[], - tenant: TenantResource | undefined = undefined - ) { - const preview = await this.repository.releases.getDeploymentPreview(promotionTarget); - - // Validate skipped steps - const skip: string[] = []; - for (const step of this.deploymentOptions.skipStepNames) { - const stepToExecute = preview.StepsToExecute.find((s) => s.ActionName === step); - if (stepToExecute === undefined) { - console.warn( - `No step/action named '${step}' could be found when deploying to environment '${promotionTarget.Name}', so the step cannot be skipped.` - ); - } else { - console.debug(`Skipping step: ${stepToExecute.ActionName}`); - skip.push(stepToExecute.ActionId); - } - } - - // Validate form values supplied - if (preview.Form !== null && preview.Form.Elements !== null && preview.Form.Values !== null) - for (const element of preview.Form.Elements) { - if (element.Control.Type !== ControlType.VariableValue) continue; - - const variableInput = element.Control as VariableValue; - const value = this.deploymentOptions.variable.reduce((previousValue, currentValue) => { - if (previousValue !== undefined) { - return previousValue; - } - - const variableName = currentValue.name; - const variableValue = currentValue.value; - - if (variableName === variableInput.Label) { - return variableValue.toString(); - } - if (variableName === variableInput.Name) { - return variableValue.toString(); - } - - return undefined; - }, undefined); - - if (value === undefined && element.IsValueRequired) throw new Error(`Please provide a variable for the prompted value ${variableInput.Label}`); - - preview.Form.Values[element.Name] = value as string; - } - - // Log step with no machines - for (const previewStep of preview.StepsToExecute) { - if (previewStep.HasNoApplicableMachines) console.warn(`Warning: there are no applicable machines roles used by step ${previewStep.ActionName}`); - } - - const deployment = await this.repository.deployments.create({ - ProjectId: project.Id, - TenantId: tenant?.Id, - EnvironmentId: promotionTarget.Id, - SkipActions: skip, - ReleaseId: release.Id, - ForcePackageDownload: this.deploymentOptions.forcePackageDownload, - UseGuidedFailure: preview.UseGuidedFailureModeByDefault, - SpecificMachineIds: specificMachineIds, - ExcludedMachineIds: excludedMachineIds, - ForcePackageRedeployment: this.deploymentOptions.force, - FormValues: preview.Form.Values, - QueueTime: this.deploymentOptions.deployAt ? moment(this.deploymentOptions.deployAt) : undefined, - QueueTimeExpiry: this.deploymentOptions.noDeployAfter ? moment(this.deploymentOptions.noDeployAfter) : undefined, - } as CreateDeploymentResource); - - console.info( - `Deploying ${project.Name} ${release.Version} to: ${promotionTarget.Name} ${tenant === undefined ? "" : `for ${tenant.Name} `}(Guided Failure: ${ - deployment.UseGuidedFailure ? "Enabled" : "Not Enabled" - })` - ); - - return deployment; - } - - private async deployTenantedRelease(project: ProjectResource, release: ReleaseResource): Promise { - if (this.deploymentOptions.deployTo.length !== 1) return []; - - const environment = await throwIfUndefined( - async (nameOrId) => (await this.repository.environments.find([nameOrId])).find((v) => v), - async (id) => this.repository.environments.get(id), - "Environments", - "Environment", - this.deploymentOptions.deployTo[0].Name - ); - - const releaseTemplate = await this.repository.releases.getDeploymentTemplate(release); - - const deploymentTenants = await this.getTenants(project, environment.Name, release, releaseTemplate); - const specificMachineIds = await this.getSpecificMachines(); - const excludedMachineIds = await this.getExcludedMachines(); - - this.logScheduledDeployment(); - - const createTasks = deploymentTenants.map(async (tenant) => { - const promotion = releaseTemplate.TenantPromotions.find((t) => t.Id === tenant.Id)?.PromoteTo.find( - (tt) => - tt.Name.localeCompare(environment.Name, undefined, { - sensitivity: "accent", - }) === 0 - ); - - this.promotionTargets.push(promotion as DeploymentPromotionTarget); - - return this.createDeploymentTask(project, release, promotion as DeploymentPromotionTarget, specificMachineIds, excludedMachineIds, tenant); - }); - - return await Promise.all(createTasks); - } - - private async deployToEnvironments(project: ProjectResource, release: ReleaseResource): Promise { - if (this.deploymentOptions.deployTo.length === 0) return []; - - const releaseTemplate = await this.repository.releases.getDeploymentTemplate(release); - - const deployToEnvironments = await this.repository.environments.find(this.deploymentOptions.deployTo.map((e) => e.Name)); - - const promotingEnvironments = deployToEnvironments.map((environment) => ({ - name: environment.Name, - promotion: releaseTemplate.PromoteTo.find((p) => p.Name === environment.Name), - })); - - const unknownEnvironments = promotingEnvironments.filter((p) => p.promotion === undefined); - if (unknownEnvironments.length > 0) - throw new Error( - `Release '${release.Version}' of project '${project.Name}' cannot be deployed to ${ - unknownEnvironments.length === 1 - ? `environment '${unknownEnvironments[0].name}' because the environment is` - : `environments ${unknownEnvironments.map((e) => `'${e.name}'`)} because the environments are` - } not in the list of environments that this release can be deployed to. This may be because a) the environment does not exist or is misspelled, b) The lifecycle has not reached this phase, possibly due to previous deployment failure, c) you don't have permission to deploy to this environment, or d) the environment is not in the list of environments defined by the lifecycle.` - ); - - this.logScheduledDeployment(); - const specificMachineIds = await this.getSpecificMachines(); - const excludedMachineIds = await this.getExcludedMachines(); - - const createTasks = promotingEnvironments.map(async (promotion) => { - this.promotionTargets.push(promotion.promotion as DeploymentPromotionTarget); - - return this.createDeploymentTask(project, release, promotion.promotion as DeploymentPromotionTarget, specificMachineIds, excludedMachineIds); - }); - - return await Promise.all(createTasks); - } -} diff --git a/src/operations/deployRelease/deployment-options.ts b/src/operations/deployRelease/deployment-options.ts deleted file mode 100644 index 567175d..0000000 --- a/src/operations/deployRelease/deployment-options.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { EnvironmentResource, TenantResource } from "@octopusdeploy/message-contracts"; - -export interface DeploymentOptions { - cancelOnTimeout: boolean; - deployAt?: Date | undefined; - deployTo: EnvironmentResource[]; - deploymentCheckSleepCycle: number; - deploymentTimeout: number; - excludeMachines: string[]; - force: boolean; - forcePackageDownload: boolean; - guidedFailure?: string | undefined; - noDeployAfter?: Date | undefined; - noRawLog: boolean; - progress: boolean; - rawLogFile?: string | undefined; - skipStepNames: string[]; - specificMachines: string[]; - tenants: TenantResource[]; - tenantTags: string[]; - variable: Variable[]; - waitForDeployment: boolean; -} - -export interface Variable { - name: string; - value: string | number | boolean; -} diff --git a/src/operations/deployRelease/execution-resource-waiter.ts b/src/operations/deployRelease/execution-resource-waiter.ts deleted file mode 100644 index 8a0bd21..0000000 --- a/src/operations/deployRelease/execution-resource-waiter.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { DeploymentResource, EnvironmentResource, ProjectResource, ReleaseResource, TaskResource } from "@octopusdeploy/message-contracts"; -import { IExecutionResource } from "@octopusdeploy/message-contracts/dist/deploymentResource"; -import { promises as fs} from "fs"; -import { OctopusSpaceRepository } from "../../index"; - -export class ExecutionResourceWaiter { - constructor(private readonly repository: OctopusSpaceRepository, private readonly serverBaseUrl: string) {} - - async waitForDeploymentToComplete( - resources: DeploymentResource[], - project: ProjectResource, - release: ReleaseResource, - showProgress: boolean, - noRawLog: boolean, - rawLogFile: string | undefined, - cancelOnTimeout: boolean, - deploymentStatusCheckSleepCycle: number, - deploymentTimeout: number - ) { - const guidedFailureWarning = async (guidedFailureDeployment: IExecutionResource) => { - const environment = await this.repository.client.get(this.repository.client.getLink("Environments")); - console.warn( - ` - ${environment.Name}: ${this.getPortalUrl( - `/app#/projects/${project.Slug}/releases/${release.Version}/deployments/${guidedFailureDeployment.Id}` - )}` - ); - }; - - await this.waitForExecutionToComplete( - resources, - showProgress, - noRawLog, - rawLogFile, - cancelOnTimeout, - deploymentStatusCheckSleepCycle, - deploymentTimeout, - guidedFailureWarning, - "deployment" - ); - } - - private async waitForCompletion(deploymentTasks: TaskResource[], deploymentStatusCheckSleepCycle: number, deploymentTimeout: number) { - const sleep = async (ms: number) => new Promise((r) => setTimeout(r, ms)); - const timeout = new Promise((r) => setTimeout(r, deploymentTimeout)); - let stop = false; - // eslint-disable-next-line github/no-then - timeout.then(() => { - stop = true; - }); - for (const deploymentTask of deploymentTasks) { - while (!stop) { - const task = await this.repository.tasks.get(deploymentTask.Id); - - if (task.IsCompleted) { - break; - } - - await sleep(deploymentStatusCheckSleepCycle); - } - } - } - - private async waitForExecutionToComplete( - resources: IExecutionResource[], - showProgress: boolean, - noRawLog: boolean, - rawLogFile: string | undefined, - cancelOnTimeout: boolean, - deploymentStatusCheckSleepCycle: number, - deploymentTimeout: number, - guidedFailureWarningGenerator: (resource: IExecutionResource) => Promise, - alias: string - ) { - const getTasks = resources.map(async (dep) => this.repository.tasks.get(dep.TaskId)); - const deploymentTasks = await Promise.all(getTasks); - if (showProgress && resources.length > 1) console.info(`Only progress of the first task (${deploymentTasks[0].Name}) will be shown`); - - try { - console.info(`Waiting for ${deploymentTasks.length} ${alias}(s) to complete...`); - await this.waitForCompletion(deploymentTasks, deploymentStatusCheckSleepCycle, deploymentTimeout); - let failed = false; - for (const deploymentTask of deploymentTasks) { - const updated = await this.repository.tasks.get(deploymentTask.Id); - if (updated.FinishedSuccessfully) { - console.info(`${updated.Description}: ${updated.State}`); - } else { - console.error(`${updated.Description}: ${updated.State}, ${updated.ErrorMessage}`); - - failed = true; - - if (noRawLog) continue; - - try { - const raw = await this.repository.tasks.getRaw(updated); - if (rawLogFile) await fs.writeFile(rawLogFile, raw); - else console.error(raw); - } catch (er: unknown) { - if (er instanceof Error) { - console.error("Could not retrieve raw log", er); - } - } - } - } - - if (failed) throw new Error(`One or more ${alias} tasks failed.`); - - console.info("Done!"); - } catch (er: unknown) { - if (er instanceof Error) { - console.error("Failed!", er); - } - } - } - - private async cancelExecutionOnTimeoutIfRequested(deploymentTasks: TaskResource[], cancelOnTimeout: boolean, alias: string) { - if (!cancelOnTimeout) return; - - const tasks = deploymentTasks.map(async (task) => { - console.warn(`Cancelling ${alias} task '{${task.Description}}'`); - try { - await this.repository.tasks.cancel(task); - } catch (er) { - if (er instanceof Error) { - console.error(`Failed to cancel ${alias} task '{${task.Description}}': {${er.message}}`); - } - } - }); - - return Promise.all(tasks); - } - - private async printTaskOutput(taskResources: TaskResource[]) { - const task = taskResources[0]; - return this.printTask(task); - } - - private printTask(task: TaskResource) { - console.info(task.Name); - } - - private getPortalUrl(path: string) { - if (!path.startsWith("/")) path = `/${path}`; - const uri = new URL(this.serverBaseUrl + path); - return uri.toString(); - } -} diff --git a/src/operations/deployRelease/index.ts b/src/operations/deployRelease/index.ts deleted file mode 100644 index 129092f..0000000 --- a/src/operations/deployRelease/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./deploy-release"; -export * from "./deployment-base"; -export * from "./deployment-options"; -export * from "./execution-resource-waiter"; diff --git a/src/operations/executions/createExecutionBaseV1.ts b/src/operations/executions/createExecutionBaseV1.ts new file mode 100644 index 0000000..068fdc0 --- /dev/null +++ b/src/operations/executions/createExecutionBaseV1.ts @@ -0,0 +1,12 @@ +export interface CreateExecutionBaseV1 { + spaceId: string; + projectName: string; + forcePackageDownload?: boolean; + specificMachineNames?: string[]; + excludedMachineNames?: string[]; + skipStepNames?: string[]; + useGuidedFailure?: boolean; + runAt?: Date | undefined; + noRunAfter?: Date | undefined; + variables?: Map; +} diff --git a/src/operations/executions/deployRelease/createDeploymentTenantedCommandV1.ts b/src/operations/executions/deployRelease/createDeploymentTenantedCommandV1.ts new file mode 100644 index 0000000..a95c854 --- /dev/null +++ b/src/operations/executions/deployRelease/createDeploymentTenantedCommandV1.ts @@ -0,0 +1,15 @@ +import { CreateExecutionBaseV1 } from "../createExecutionBaseV1"; +import { DeploymentServerTask } from "./deploymentServerTask"; + +export interface CreateDeploymentTenantedCommandV1 extends CreateExecutionBaseV1 { + releaseVersion: string; + environmentName: string; + tenants: string[]; + tenantTags: string[]; + forcePackageRedeployment?: boolean; + updateVariableSnapshot?: boolean; +} + +export interface CreateDeploymentTenantedResponseV1 { + deploymentServerTasks: DeploymentServerTask[]; +} diff --git a/src/operations/executions/deployRelease/createDeploymentUntenantedCommandV1.ts b/src/operations/executions/deployRelease/createDeploymentUntenantedCommandV1.ts new file mode 100644 index 0000000..9807fdf --- /dev/null +++ b/src/operations/executions/deployRelease/createDeploymentUntenantedCommandV1.ts @@ -0,0 +1,13 @@ +import { CreateExecutionBaseV1 } from "../createExecutionBaseV1"; +import { DeploymentServerTask } from "./deploymentServerTask"; + +export interface CreateDeploymentUntenantedCommandV1 extends CreateExecutionBaseV1 { + releaseVersion: string; + environmentNames: string[]; + forcePackageRedeployment?: boolean; + updateVariableSnapshot?: boolean; +} + +export interface CreateDeploymentUntenantedResponseV1 { + deploymentServerTasks: DeploymentServerTask[]; +} diff --git a/src/operations/deployRelease/deploy-release.test.ts b/src/operations/executions/deployRelease/deploy-release.test.ts similarity index 79% rename from src/operations/deployRelease/deploy-release.test.ts rename to src/operations/executions/deployRelease/deploy-release.test.ts index 6f9e42b..e10f8b8 100644 --- a/src/operations/deployRelease/deploy-release.test.ts +++ b/src/operations/executions/deployRelease/deploy-release.test.ts @@ -15,10 +15,12 @@ import { import { PackageRequirement } from "@octopusdeploy/message-contracts/dist/deploymentStepResource"; import { RunConditionForAction } from "@octopusdeploy/message-contracts/dist/runConditionForAction"; import { randomUUID } from "crypto"; -import { Client } from "../../client"; -import { OctopusSpaceRepository, Repository } from "../../repository"; -import { createRelease } from "../createRelease/create-release"; -import { deployRelease } from "./deploy-release"; +import { Client } from "../../../client"; +import { OctopusSpaceRepository, Repository } from "../../../repository"; +import { createRelease, CreateReleaseCommandV1 } from "../../createRelease/create-release"; +import { ExecutionWaiter } from "../execution-waiter"; +import { CreateDeploymentUntenantedCommandV1 } from "./createDeploymentUntenantedCommandV1"; +import { deployReleaseUntenanted } from "./deploy-release"; describe("deploy a release", () => { let client: Client; @@ -41,7 +43,7 @@ describe("deploy a release", () => { beforeEach(async () => { const spaceName = randomUUID().substring(0, 20); console.log(`Creating space, "${spaceName}"...`); - space = await systemRepository.spaces.create(NewSpace(spaceName, undefined, [user])); + space = await systemRepository.spaces.create(NewSpace(spaceName, [], [user])); console.log(`Space "${spaceName}" created successfully.`); repository = await systemRepository.forSpace(space); @@ -86,7 +88,7 @@ describe("deploy a release", () => { Packages: [], Condition: RunConditionForAction.Success, Properties: { - "Octopus.Action.RunOnServer": "false", + "Octopus.Action.RunOnServer": "true", "Octopus.Action.Script.ScriptSource": "Inline", "Octopus.Action.Script.Syntax": "Bash", "Octopus.Action.Script.ScriptBody": "echo 'hello'", @@ -121,8 +123,23 @@ describe("deploy a release", () => { }); test("deploy to single environment", async () => { - await createRelease(repository, project); - await deployRelease(repository, project, "latest", [environment], undefined, false, { waitForDeployment: true }); + var releaseCommand = { + spaceId: space.Id, + projectName: project.Name, + } as CreateReleaseCommandV1; + var releaseResponse = await createRelease(repository, releaseCommand); + + var deployCommand = { + spaceId: space.Id, + projectName: project.Name, + releaseVersion: releaseResponse.releaseVersion, + environmentNames: [environment.Name], + } as CreateDeploymentUntenantedCommandV1; + var response = await deployReleaseUntenanted(repository, deployCommand); + var taskIds = response.deploymentServerTasks.map((x) => x.serverTaskId); + var e = new ExecutionWaiter(repository); + + await e.waitForExecutionToComplete(taskIds, false, true, undefined, 1000, 600000, "task"); }); afterEach(async () => { diff --git a/src/operations/executions/deployRelease/deploy-release.ts b/src/operations/executions/deployRelease/deploy-release.ts new file mode 100644 index 0000000..6b753ce --- /dev/null +++ b/src/operations/executions/deployRelease/deploy-release.ts @@ -0,0 +1,39 @@ +import { OctopusSpaceRepository } from "../../../repository"; +import { CreateDeploymentTenantedCommandV1, CreateDeploymentTenantedResponseV1 } from "./createDeploymentTenantedCommandV1"; +import { CreateDeploymentUntenantedCommandV1, CreateDeploymentUntenantedResponseV1 } from "./createDeploymentUntenantedCommandV1"; + +export async function deployReleaseUntenanted( + repository: OctopusSpaceRepository, + command: CreateDeploymentUntenantedCommandV1 +): Promise { + console.log(`Deploying a release...`); + + // WARNING: server's API currently expects there to be a SpaceIdOrName value, which was intended to allow use of names/slugs, but doesn't + // work properly due to limitations in the middleware. For now, we'll just set it to the SpaceId + var response = await repository.client.do(`~/api/{spaceId}/deployments/create/untenanted/v1`, { + spaceIdOrName: command.spaceId, + ...command, + }); + + console.log(`Deployment created successfully.`); + + return response; +} + +export async function deployReleaseTenanted( + repository: OctopusSpaceRepository, + command: CreateDeploymentTenantedCommandV1 +): Promise { + console.log(`Deploying a tenanted release...`); + + // WARNING: server's API currently expects there to be a SpaceIdOrName value, which was intended to allow use of names/slugs, but doesn't + // work properly due to limitations in the middleware. For now, we'll just set it to the SpaceId + var response = await repository.client.do(`~/api/{spaceId}/deployments/create/tenanted/v1`, { + spaceIdOrName: command.spaceId, + ...command, + }); + + console.log(`Tenanted Deployment(s) created successfully.`); + + return response; +} diff --git a/src/operations/executions/deployRelease/deploymentServerTask.ts b/src/operations/executions/deployRelease/deploymentServerTask.ts new file mode 100644 index 0000000..ba90c13 --- /dev/null +++ b/src/operations/executions/deployRelease/deploymentServerTask.ts @@ -0,0 +1,4 @@ +export interface DeploymentServerTask { + deploymentId: string; + serverTaskId: string; +} diff --git a/src/operations/executions/deployRelease/index.ts b/src/operations/executions/deployRelease/index.ts new file mode 100644 index 0000000..0f6a791 --- /dev/null +++ b/src/operations/executions/deployRelease/index.ts @@ -0,0 +1,4 @@ +export * from "./createDeploymentTenantedCommandV1"; +export * from "./createDeploymentUntenantedCommandV1"; +export * from "./deploy-release"; +export * from "./deploymentServerTask"; diff --git a/src/operations/executions/execution-waiter.ts b/src/operations/executions/execution-waiter.ts new file mode 100644 index 0000000..f7789e4 --- /dev/null +++ b/src/operations/executions/execution-waiter.ts @@ -0,0 +1,78 @@ +import { TaskResource } from "@octopusdeploy/message-contracts"; +import { promises as fs } from "fs"; +import { OctopusSpaceRepository } from "../../index"; + +export class ExecutionWaiter { + constructor(private readonly repository: OctopusSpaceRepository) {} + + async waitForExecutionToComplete( + serverTaskIds: string[], + showProgress: boolean, + noRawLog: boolean, + rawLogFile: string | undefined, + statusCheckSleepCycle: number, + timeout: number, + alias: string + ) { + const getTasks = serverTaskIds.map(async (taskId) => this.repository.tasks.get(taskId)); + const executionTasks = await Promise.all(getTasks); + if (showProgress && serverTaskIds.length > 1) console.info(`Only progress of the first task (${executionTasks[0].Name}) will be shown`); + + try { + console.info(`Waiting for ${executionTasks.length} ${alias}(s) to complete...`); + await this.waitForCompletion(executionTasks, statusCheckSleepCycle, timeout); + let failed = false; + for (const executionTask of executionTasks) { + const updated = await this.repository.tasks.get(executionTask.Id); + if (updated.FinishedSuccessfully) { + console.info(`${updated.Description}: ${updated.State}`); + } else { + console.error(`${updated.Description}: ${updated.State}, ${updated.ErrorMessage}`); + + failed = true; + + if (noRawLog) continue; + + try { + const raw = await this.repository.tasks.getRaw(updated); + if (rawLogFile) await fs.writeFile(rawLogFile, raw); + else console.error(raw); + } catch (er: unknown) { + if (er instanceof Error) { + console.error("Could not retrieve raw log", er); + } + } + } + } + + if (failed) throw new Error(`One or more ${alias} tasks failed.`); + + console.info("Done!"); + } catch (er: unknown) { + if (er instanceof Error) { + console.error("Failed!", er); + } + } + } + + private async waitForCompletion(serverTasks: TaskResource[], statusCheckSleepCycle: number, timeout: number) { + const sleep = async (ms: number) => new Promise((r) => setTimeout(r, ms)); + const t = new Promise((r) => setTimeout(r, timeout)); + let stop = false; + // eslint-disable-next-line github/no-then + t.then(() => { + stop = true; + }); + for (const deploymentTask of serverTasks) { + while (!stop) { + const task = await this.repository.tasks.get(deploymentTask.Id); + + if (task.IsCompleted) { + break; + } + + await sleep(statusCheckSleepCycle); + } + } + } +} diff --git a/src/operations/executions/index.ts b/src/operations/executions/index.ts new file mode 100644 index 0000000..f1f8d87 --- /dev/null +++ b/src/operations/executions/index.ts @@ -0,0 +1,4 @@ +export * from "./deployRelease"; +export * from "./runRunbook"; +export * from "./createExecutionBaseV1"; +export * from "./execution-waiter"; diff --git a/src/operations/executions/runRunbook/createRunbookRunCommandV1.ts b/src/operations/executions/runRunbook/createRunbookRunCommandV1.ts new file mode 100644 index 0000000..0fc7c35 --- /dev/null +++ b/src/operations/executions/runRunbook/createRunbookRunCommandV1.ts @@ -0,0 +1,14 @@ +import { CreateExecutionBaseV1 } from "../createExecutionBaseV1"; +import { RunbookRunServerTask } from "./runbookRunServerTask"; + +export interface CreateRunbookRunCommandV1 extends CreateExecutionBaseV1 { + runbookName: string; + environmentNames: string[]; + tenants?: string[]; + tenantTags?: string[]; + snapshot?: string; +} + +export interface CreateRunbookRunResponseV1 { + runbookRunServerTasks: RunbookRunServerTask[]; +} diff --git a/src/operations/executions/runRunbook/index.ts b/src/operations/executions/runRunbook/index.ts new file mode 100644 index 0000000..b8e2c99 --- /dev/null +++ b/src/operations/executions/runRunbook/index.ts @@ -0,0 +1,3 @@ +export * from "./createRunbookRunCommandV1"; +export * from "./run-runbook"; +export * from "./runbookRunServerTask"; diff --git a/src/operations/executions/runRunbook/run-runbook.ts b/src/operations/executions/runRunbook/run-runbook.ts new file mode 100644 index 0000000..94e20e7 --- /dev/null +++ b/src/operations/executions/runRunbook/run-runbook.ts @@ -0,0 +1,17 @@ +import { OctopusSpaceRepository } from "../../../repository"; +import { CreateRunbookRunCommandV1, CreateRunbookRunResponseV1 } from "./createRunbookRunCommandV1"; + +export async function runRunbook(repository: OctopusSpaceRepository, command: CreateRunbookRunCommandV1): Promise { + console.log(`Running a runbook...`); + + // WARNING: server's API currently expects there to be a SpaceIdOrName value, which was intended to allow use of names/slugs, but doesn't + // work properly due to limitations in the middleware. For now, we'll just set it to the SpaceId + var response = await repository.client.do("~/api/{spaceId}/runbook-runs/create/v1", { + spaceIdOrName: command.spaceId, + ...command, + }); + + console.log(`Runbook executed successfully.`); + + return response; +} diff --git a/src/operations/executions/runRunbook/runbookRunServerTask.ts b/src/operations/executions/runRunbook/runbookRunServerTask.ts new file mode 100644 index 0000000..6926540 --- /dev/null +++ b/src/operations/executions/runRunbook/runbookRunServerTask.ts @@ -0,0 +1,4 @@ +export interface RunbookRunServerTask { + runbookRunId: string; + serverTaskId: string; +} diff --git a/src/operations/index.ts b/src/operations/index.ts index 8f08786..b61992b 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -1,7 +1,7 @@ export * from "./could-not-find-error"; export * from "./createRelease"; -export * from "./deployRelease"; -export * from "./promoteRelease"; +export * from "./executions/deployRelease"; +export * from "./executions/runRunbook"; export * from "./pushBuildInformation"; export * from "./pushPackage"; export * from "./throw-if-undefined"; diff --git a/src/operations/promoteRelease/index.ts b/src/operations/promoteRelease/index.ts deleted file mode 100644 index bd5e1c6..0000000 --- a/src/operations/promoteRelease/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./promote-release"; diff --git a/src/operations/promoteRelease/promote-release.test.ts b/src/operations/promoteRelease/promote-release.test.ts deleted file mode 100644 index e06ed23..0000000 --- a/src/operations/promoteRelease/promote-release.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - CommunicationStyle, - DeploymentTargetResource, - EnvironmentResource, - NewDeploymentTarget, - NewEndpoint, - NewProject, - NewSpace, - ProjectResource, - RunCondition, - SpaceResource, - StartTrigger, - TenantedDeploymentMode, - UserResource, -} from "@octopusdeploy/message-contracts"; -import { PackageRequirement } from "@octopusdeploy/message-contracts/dist/deploymentStepResource"; -import { RunConditionForAction } from "@octopusdeploy/message-contracts/dist/runConditionForAction"; -import { randomUUID } from "crypto"; -import { Client } from "../../client"; -import { OctopusSpaceRepository, Repository } from "../../repository"; -import { createRelease } from "../createRelease/create-release"; -import { deployRelease } from "../deployRelease/deploy-release"; -import { promoteRelease } from "./promote-release"; - -describe("promote a release", () => { - let client: Client; - let environment1: EnvironmentResource; - let environment2: EnvironmentResource; - let machine: DeploymentTargetResource; - let project: ProjectResource; - let repository: OctopusSpaceRepository; - let space: SpaceResource; - let systemRepository: Repository; - let user: UserResource; - - jest.setTimeout(100000); - - beforeAll(async () => { - client = await Client.create(); - console.log(`Client connected to API endpoint successfully.`); - systemRepository = new Repository(client); - user = await systemRepository.users.getCurrent(); - }); - - beforeEach(async () => { - const spaceName = randomUUID().substring(0, 20); - console.log(`Creating space, "${spaceName}"...`); - space = await systemRepository.spaces.create(NewSpace(spaceName, undefined, [user])); - console.log(`Space "${spaceName}" created successfully.`); - - repository = await systemRepository.forSpace(space); - - const projectGroup = (await repository.projectGroups.list({ take: 1 })).Items[0]; - const lifecycle = (await repository.lifecycles.list({ take: 1 })).Items[0]; - - const projectName = randomUUID(); - console.log(`Creating project, "${projectName}"...`); - project = await repository.projects.create(NewProject(projectName, projectGroup, lifecycle)); - console.log(`Project "${projectName}" created successfully.`); - - const deploymentProcess = await repository.deploymentProcesses.get(project.DeploymentProcessId, undefined); - deploymentProcess.Steps = [ - { - Condition: RunCondition.Success, - Links: {}, - PackageRequirement: PackageRequirement.LetOctopusDecide, - StartTrigger: StartTrigger.StartAfterPrevious, - Id: "", - Name: randomUUID(), - Properties: { "Octopus.Action.TargetRoles": "deploy" }, - Actions: [ - { - Id: "", - Name: "Run a Script", - ActionType: "Octopus.Script", - Notes: null, - IsDisabled: false, - CanBeUsedForProjectVersioning: false, - IsRequired: false, - WorkerPoolId: null, - Container: { - Image: null, - FeedId: null, - }, - WorkerPoolVariable: "", - Environments: [], - ExcludedEnvironments: [], - Channels: [], - TenantTags: [], - Packages: [], - Condition: RunConditionForAction.Success, - Properties: { - "Octopus.Action.RunOnServer": "false", - "Octopus.Action.Script.ScriptSource": "Inline", - "Octopus.Action.Script.Syntax": "Bash", - "Octopus.Action.Script.ScriptBody": "echo 'hello'", - }, - Links: {}, - }, - ], - }, - ]; - - console.log(`Updating deployment process, "${deploymentProcess.Id}"...`); - await repository.deploymentProcesses.saveToProject(project, deploymentProcess); - console.log(`Deployment process, "${deploymentProcess.Id}" updated successfully.`); - - const environment1Name = randomUUID(); - console.log(`Creating environment, "${environment1Name}"...`); - environment1 = await repository.environments.create({ Name: environment1Name }); - console.log(`Environment "${environment1.Name}" created successfully.`); - - const environment2Name = randomUUID(); - console.log(`Creating environment, "${environment2Name}"...`); - environment2 = await repository.environments.create({ Name: environment2Name }); - console.log(`Environment "${environment2.Name}" created successfully.`); - - const machineName = randomUUID(); - console.log(`Creating machine, "${machineName}"...`); - machine = await repository.machines.create( - NewDeploymentTarget( - machineName, - NewEndpoint(machineName, CommunicationStyle.None), - [environment1, environment2], - ["deploy"], - TenantedDeploymentMode.TenantedOrUntenanted - ) - ); - console.log(`Machine "${machine.Name}" created successfully.`); - }); - - test("promote to single environment", async () => { - await createRelease(repository, project); - await deployRelease(repository, project, "latest", [environment1], undefined, false, { waitForDeployment: true }); - await promoteRelease(repository, project, environment1, [environment2], true, true, { waitForDeployment: true }); - }); - - afterEach(async () => { - if (space === undefined || space === null) return; - - console.log(`Deleting space, ${space.Name}...`); - space.TaskQueueStopped = true; - await systemRepository.spaces.modify(space); - await systemRepository.spaces.del(space); - console.log(`Space '${space.Name}' deleted successfully.`); - }); -}); diff --git a/src/operations/promoteRelease/promote-release.ts b/src/operations/promoteRelease/promote-release.ts deleted file mode 100644 index 55587a0..0000000 --- a/src/operations/promoteRelease/promote-release.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { DashboardItemResource, EnvironmentResource, ProjectResource, TaskState } from "@octopusdeploy/message-contracts"; -import { SemVer } from "semver"; -import { processConfiguration } from "../../clientConfiguration"; -import { DashboardItemsOptions } from "../../repositories/dashboardRepository"; -import { OctopusSpaceRepository } from "../../repository"; -import { CouldNotFindError } from "../could-not-find-error"; -import { DeploymentBase } from "../deployRelease/deployment-base"; -import { DeploymentOptions } from "../deployRelease/deployment-options"; -import { throwIfUndefined } from "../throw-if-undefined"; - -export async function promoteRelease( - repository: OctopusSpaceRepository, - project: ProjectResource, - from: EnvironmentResource, - deployTo: EnvironmentResource[], - lastSuccessful?: boolean | undefined, - updateVariables?: boolean | undefined, - deploymentOptions?: Partial -): Promise { - const proj = await throwIfUndefined( - async (nameOrId) => repository.projects.find(nameOrId), - async (id) => repository.projects.get(id), - "Projects", - "project", - project.Id - ); - - const configuration = processConfiguration(); - await new PromoteRelease(repository, configuration.apiUri, proj, deployTo, deploymentOptions).execute(from.Name, lastSuccessful, updateVariables); -} - -class PromoteRelease extends DeploymentBase { - constructor( - repository: OctopusSpaceRepository, - serverUrl: string, - private readonly project: ProjectResource, - deployTo: EnvironmentResource[], - deploymentOptions?: Partial - ) { - super(repository, serverUrl, { - ...deploymentOptions, - ...{ deployTo: deployTo }, - }); - } - - async execute(from: string, useLatestSuccessfulRelease: boolean | undefined = false, updateVariables: boolean | undefined = false) { - const environment = await throwIfUndefined( - async (nameOrId) => { - const results = await this.repository.environments.find([nameOrId]); - return results.length > 0 ? results[0] : undefined; - }, - async (id) => this.repository.environments.get(id), - "Environments", - "environment", - from - ); - const dashboardItemsOptions = useLatestSuccessfulRelease - ? DashboardItemsOptions.IncludeCurrentAndPreviousSuccessfulDeployment - : DashboardItemsOptions.IncludeCurrentDeploymentOnly; - const dashboard = await this.repository.dashboards.getDynamicDashboard([this.project.Id], [environment.Id], dashboardItemsOptions); - - const compareFn = (r1: DashboardItemResource, r2: DashboardItemResource) => { - const r1Version = new SemVer(r1.ReleaseVersion); - const r2Version = new SemVer(r2.ReleaseVersion); - - return r1Version.compare(r2Version); - }; - - const dashboardItems = dashboard.Items.filter((e) => e.EnvironmentId === environment.Id && e.ProjectId === this.project.Id).sort(compareFn); - - const dashboardItem = useLatestSuccessfulRelease ? dashboardItems.filter((x) => x.State === TaskState.Success).at(0) : dashboardItems.at(0); - - if (dashboardItem === undefined) { - const deploymentType = useLatestSuccessfulRelease ? "successful " : ""; - - throw new CouldNotFindError( - `latest ${deploymentType}deployment of the project for this environment. Please check that a ${deploymentType} deployment for this project/environment exists on the dashboard.` - ); - } - - console.debug(`Finding release details for release ${dashboardItem.ReleaseVersion}`); - - const release = await this.repository.projects.getReleaseByVersion(this.project, dashboardItem.ReleaseVersion); - - if (updateVariables) { - console.debug("Updating the release variable snapshot with variables from the project"); - await this.repository.releases.snapshotVariables(release); - } - await this.deployRelease(this.project, release); - } -} diff --git a/src/operations/createRelease/package-identity.ts b/src/operations/pushBuildInformation/package-identity.ts similarity index 100% rename from src/operations/createRelease/package-identity.ts rename to src/operations/pushBuildInformation/package-identity.ts diff --git a/src/operations/pushBuildInformation/push-build-information.test.ts b/src/operations/pushBuildInformation/push-build-information.test.ts index 61f2732..7169cb5 100644 --- a/src/operations/pushBuildInformation/push-build-information.test.ts +++ b/src/operations/pushBuildInformation/push-build-information.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "os"; import path from "path"; import { Client } from "../../client"; import { OctopusSpaceRepository, Repository } from "../../repository"; -import { PackageIdentity } from "../createRelease/package-identity"; +import { PackageIdentity } from "./package-identity"; import { pushBuildInformation } from "./push-build-information"; describe("push build information", () => { @@ -41,7 +41,7 @@ describe("push build information", () => { beforeEach(async () => { const spaceName = randomUUID().substring(0, 20); console.log(`Creating space, "${spaceName}"...`); - space = await systemRepository.spaces.create(NewSpace(spaceName, undefined, [user])); + space = await systemRepository.spaces.create(NewSpace(spaceName, [], [user])); repository = await systemRepository.forSpace(space); }); diff --git a/src/operations/pushBuildInformation/push-build-information.ts b/src/operations/pushBuildInformation/push-build-information.ts index 2e57133..467c1ee 100644 --- a/src/operations/pushBuildInformation/push-build-information.ts +++ b/src/operations/pushBuildInformation/push-build-information.ts @@ -1,7 +1,7 @@ import { OctopusPackageVersionBuildInformationMappedResource, SpaceResource } from "@octopusdeploy/message-contracts"; import { OverwriteMode } from "../../repositories/packageRepository"; import { connect } from "../connect"; -import { PackageIdentity } from "../createRelease/package-identity"; +import { PackageIdentity } from "./package-identity"; export interface IOctopusBuildInformation { buildEnvironment: string; diff --git a/src/operations/pushPackage/push-package.test.ts b/src/operations/pushPackage/push-package.test.ts index 4e06d90..c3a571f 100644 --- a/src/operations/pushPackage/push-package.test.ts +++ b/src/operations/pushPackage/push-package.test.ts @@ -7,7 +7,6 @@ import path from "path"; import { Client } from "../../client"; import { OverwriteMode } from "../../repositories/packageRepository"; import { OctopusSpaceRepository, Repository } from "../../repository"; -import { PackageIdentity } from "../createRelease/package-identity"; import { pushPackage } from "./push-package"; describe("push package", () => { @@ -20,7 +19,7 @@ describe("push package", () => { jest.setTimeout(100000); let tempOutDir: string; - const packages: PackageIdentity[] = [new PackageIdentity("Hello", "1.0.0"), new PackageIdentity("GoodBye", "2.0.0")]; + const packages: string[] = ["Hello:1.0.0", "GoodBye:2.0.0"]; beforeAll(async () => { tempOutDir = await mkdtemp(path.join(tmpdir(), "octopus_")); @@ -29,7 +28,7 @@ describe("push package", () => { zip.addFile("test.txt", Buffer.from("inner content of the file", "utf8")); for (const p of packages) { - const packagePath = path.join(tempOutDir, `${p.id}.${p.version}.zip`); + const packagePath = path.join(tempOutDir, `${p.replace(":", ".")}.zip`); zip.writeZip(packagePath); } @@ -42,7 +41,7 @@ describe("push package", () => { beforeEach(async () => { const spaceName = randomUUID().substring(0, 20); console.log(`Creating space, "${spaceName}"...`); - space = await systemRepository.spaces.create(NewSpace(spaceName, undefined, [user])); + space = await systemRepository.spaces.create(NewSpace(spaceName, [], [user])); repository = await systemRepository.forSpace(space); });