Skip to content

Commit

Permalink
feat: deploy tenant release v6 (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
benPearce1 authored Jan 16, 2023
1 parent 2802390 commit a2c2130
Show file tree
Hide file tree
Showing 11 changed files with 482 additions and 5 deletions.
22 changes: 18 additions & 4 deletions source/extension-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@
"name": "tasks/Deploy"
}
},
{
"id": "octopus-deploy-tenanted",
"type": "ms.vss-distributed-task.task",
"targets": [
"ms.vss-distributed-task.tasks"
],
"properties": {
"name": "tasks/DeployTenant"
}
},
{
"id": "octopus-pack",
"type": "ms.vss-distributed-task.task",
Expand All @@ -163,15 +173,19 @@
{
"id": "octopus-pack-zip",
"type": "ms.vss-distributed-task.task",
"targets": ["ms.vss-distributed-task.tasks"],
"targets": [
"ms.vss-distributed-task.tasks"
],
"properties": {
"name": "tasks/PackZip"
}
},
{
"id": "octopus-pack-nuget",
"type": "ms.vss-distributed-task.task",
"targets": ["ms.vss-distributed-task.tasks"],
"targets": [
"ms.vss-distributed-task.tasks"
],
"properties": {
"name": "tasks/PackNuGet"
}
Expand Down Expand Up @@ -202,7 +216,7 @@
"targets": [
"ms.vss-distributed-task.tasks"
],
"properties":{
"properties": {
"name": "tasks/OctoInstaller"
}
},
Expand Down Expand Up @@ -375,4 +389,4 @@
}
}
]
}
}
63 changes: 63 additions & 0 deletions source/tasks/DeployTenant/TenantedDeployV6/createDeployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Client, CreateDeploymentTenantedCommandV1, DeploymentRepository, Logger, TenantRepository } from "@octopusdeploy/api-client";
import { InputParameters } from "./input-parameters";
import os from "os";
import { TaskWrapper } from "tasks/Utils/taskInput";

export interface DeploymentResult {
serverTaskId: string;
tenantName: string;
}

export async function createDeploymentFromInputs(client: Client, parameters: InputParameters, task: TaskWrapper, logger: Logger): Promise<DeploymentResult[]> {
logger.info?.("🐙 Deploying a release in Octopus Deploy...");
const command: CreateDeploymentTenantedCommandV1 = {
spaceName: parameters.space,
ProjectName: parameters.project,
ReleaseVersion: parameters.releaseNumber,
EnvironmentName: parameters.environment,
Tenants: parameters.tenants,
TenantTags: parameters.tenantTags,
UseGuidedFailure: parameters.useGuidedFailure,
Variables: parameters.variables,
};

try {
const deploymentRepository = new DeploymentRepository(client, parameters.space);
const response = await deploymentRepository.createTenanted(command);

client.info(`🎉 ${response.DeploymentServerTasks.length} Deployment${response.DeploymentServerTasks.length > 1 ? "s" : ""} queued successfully!`);

if (response.DeploymentServerTasks.length === 0) {
throw new Error("Expected at least one deployment to be queued.");
}
if (response.DeploymentServerTasks[0].ServerTaskId === null || response.DeploymentServerTasks[0].ServerTaskId === undefined) {
throw new Error("Server task id was not deserialized correctly.");
}

const deploymentIds = response.DeploymentServerTasks.map((x) => x.DeploymentId);

const deployments = await deploymentRepository.list({ ids: deploymentIds, take: deploymentIds.length });

const tenantIds = deployments.Items.map((d) => d.TenantId || "");
const tenantRepository = new TenantRepository(client, parameters.space);
const tenants = await tenantRepository.list({ ids: tenantIds, take: tenantIds.length });

const results = response.DeploymentServerTasks.map((x) => {
return {
serverTaskId: x.ServerTaskId,
tenantName: tenants.Items.filter((e) => e.Id === deployments.Items.filter((d) => d.TaskId === x.ServerTaskId)[0].TenantId)[0].Name,
};
});

task.setOutputVariable("server_tasks", JSON.stringify(results));

return results;
} catch (error: unknown) {
if (error instanceof Error) {
task.setFailure(`"Failed to execute command. ${error.message}${os.EOL}${error.stack}`, true);
} else {
task.setFailure(`"Failed to execute command. ${error}`, true);
}
throw error;
}
}
35 changes: 35 additions & 0 deletions source/tasks/DeployTenant/TenantedDeployV6/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Client, ClientConfiguration, Logger } from "@octopusdeploy/api-client";
import { OctoServerConnectionDetails } from "../../Utils/connection";
import { createDeploymentFromInputs } from "./createDeployment";
import { getInputParameters } from "./input-parameters";
import os from "os";
import { TaskWrapper } from "tasks/Utils/taskInput";

export class Deploy {
constructor(readonly connection: OctoServerConnectionDetails, readonly task: TaskWrapper, readonly logger: Logger) {}

public async run() {
try {
const inputParameters = getInputParameters(this.logger, this.task);

const config: ClientConfiguration = {
userAgentApp: "AzureDevOps deploy-release-tenanted",
instanceURL: this.connection.url,
apiKey: this.connection.apiKey,
logging: this.logger,
};
const client = await Client.create(config);

createDeploymentFromInputs(client, inputParameters, this.task, this.logger);

this.task.setSuccess("Deployment succeeded.");
} catch (error: unknown) {
if (error instanceof Error) {
this.task.setFailure(`"Failed to successfully deploy release. ${error.message}${os.EOL}${error.stack}`, true);
} else {
this.task.setFailure(`"Failed to successfully deploy release. ${error}`, true);
}
throw error;
}
}
}
Binary file added source/tasks/DeployTenant/TenantedDeployV6/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions source/tasks/DeployTenant/TenantedDeployV6/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions source/tasks/DeployTenant/TenantedDeployV6/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getDefaultOctopusConnectionDetailsOrThrow } from "../../Utils/connection";
import { Deploy } from "./deploy";
import { ConcreteTaskWrapper, TaskWrapper } from "tasks/Utils/taskInput";
import { Logger } from "@octopusdeploy/api-client";
import * as tasks from "azure-pipelines-task-lib/task";

const connection = getDefaultOctopusConnectionDetailsOrThrow();

const logger: Logger = {
debug: (message) => {
tasks.debug(message);
},
info: (message) => console.log(message),
warn: (message) => tasks.warning(message),
error: (message, err) => {
if (err !== undefined) {
tasks.error(err.message);
} else {
tasks.error(message);
}
},
};

const task: TaskWrapper = new ConcreteTaskWrapper();

new Deploy(connection, task, logger).run();
113 changes: 113 additions & 0 deletions source/tasks/DeployTenant/TenantedDeployV6/input-parameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Logger } from "@octopusdeploy/api-client";
import { TaskWrapper } from "tasks/Utils/taskInput";
import { getInputParameters } from "./input-parameters";
import * as tasks from "azure-pipelines-task-lib/task";

export class MockTaskWrapper implements TaskWrapper {
lastResult?: tasks.TaskResult | undefined = undefined;
lastResultMessage: string | undefined = undefined;
lastResultDone: boolean | undefined = undefined;

stringValues: Map<string, string> = new Map<string, string>();
boolValues: Map<string, boolean> = new Map<string, boolean>();
outputVariables: Map<string, string> = new Map<string, string>();

addVariableString(name: string, value: string) {
this.stringValues.set(name, value);
}

addVariableBoolean(name: string, value: boolean) {
this.boolValues.set(name, value);
}

getInput(name: string, _required?: boolean | undefined): string | undefined {
return this.stringValues.get(name);
}

getBoolean(name: string, _required?: boolean | undefined): boolean | undefined {
return this.boolValues.get(name);
}

setSuccess(message: string, done?: boolean | undefined): void {
this.lastResult = tasks.TaskResult.Succeeded;
this.lastResultMessage = message;
this.lastResultDone = done;
}
setFailure(message: string, done?: boolean | undefined): void {
this.lastResult = tasks.TaskResult.Failed;
this.lastResultMessage = message;
this.lastResultDone = done;
}

setOutputVariable(name: string, value: string): void {
this.outputVariables.set(name, value);
}
}

describe("getInputParameters", () => {
let logger: Logger;
let task: MockTaskWrapper;
beforeEach(() => {
logger = {};
task = new MockTaskWrapper();
});

test("all regular fields supplied", () => {
task.addVariableString("Space", "Default");
task.addVariableString("Variables", "var1: value1\nvar2: value2");
task.addVariableString("Environment", "dev");
task.addVariableString("Project", "Awesome project");
task.addVariableString("ReleaseNumber", "1.0.0");
task.addVariableString("DeployForTenants", "Tenant 1\nTenant 2");
task.addVariableString("DeployForTenantTags", "tag set 1/tag 1\ntag set 1/tag 2");

const inputParameters = getInputParameters(logger, task);
expect(inputParameters.environment).toBe("dev");
expect(inputParameters.project).toBe("Awesome project");
expect(inputParameters.releaseNumber).toBe("1.0.0");
expect(inputParameters.space).toBe("Default");
expect(inputParameters.variables).toStrictEqual({ var1: "value1", var2: "value2" });
expect(inputParameters.tenants).toStrictEqual(["Tenant 1", "Tenant 2"]);
expect(inputParameters.tenantTags).toStrictEqual(["tag set 1/tag 1", "tag set 1/tag 2"]);

expect(task.lastResult).toBeUndefined();
expect(task.lastResultMessage).toBeUndefined();
expect(task.lastResultDone).toBeUndefined();
});

test("variables in additional fields", () => {
task.addVariableString("Space", "Default");
task.addVariableString("Variables", "var1: value1\nvar2: value2");
task.addVariableString("AdditionalArguments", "-v var3=value3 --variable var4=value4");
task.addVariableString("DeployForTenants", "Tenant 1");

const inputParameters = getInputParameters(logger, task);
expect(inputParameters.variables).toStrictEqual({ var1: "value1", var2: "value2", var3: "value3", var4: "value4" });
});

test("missing space", () => {
const t = () => {
getInputParameters(logger, task);
};
expect(t).toThrowError("Failed to successfully build parameters: space name is required.");
});

test("duplicate variable name, variables field takes precedence", () => {
task.addVariableString("Space", "Default");
task.addVariableString("Variables", "var1: value1\nvar2: value2");
task.addVariableString("AdditionalArguments", "-v var1=value3");
task.addVariableString("DeployForTenants", "Tenant 1");
const inputParameters = getInputParameters(logger, task);
expect(inputParameters.variables).toStrictEqual({ var1: "value1", var2: "value2" });
});

test("validate tenants and tags", () => {
task.addVariableString("Space", "Default");

const t = () => {
getInputParameters(logger, task);
};

expect(t).toThrowError("Failed to successfully build parameters.\nMust provide at least one tenant or tenant tag.");
});
});
88 changes: 88 additions & 0 deletions source/tasks/DeployTenant/TenantedDeployV6/input-parameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import commandLineArgs from "command-line-args";
import shlex from "shlex";
import { getLineSeparatedItems } from "../../Utils/inputs";
import { Logger, PromptedVariableValues } from "@octopusdeploy/api-client";
import { TaskWrapper } from "tasks/Utils/taskInput";

export interface InputParameters {
// Optional: You should prefer the OCTOPUS_SPACE environment variable
space: string;
// Required
project: string;
releaseNumber: string;
environment: string;
tenants: string[];
tenantTags: string[];

// Optional
useGuidedFailure?: boolean;
variables?: PromptedVariableValues;
}

export function getInputParameters(logger: Logger, task: TaskWrapper): InputParameters {
const space = task.getInput("Space");
if (!space) {
throw new Error("Failed to successfully build parameters: space name is required.");
}

const variablesMap: PromptedVariableValues | undefined = {};

const additionalArguments = task.getInput("AdditionalArguments");
logger.debug?.("AdditionalArguments:" + additionalArguments);
if (additionalArguments) {
const optionDefs = [{ name: "variable", alias: "v", type: String, multiple: true }];
const splitArgs = shlex.split(additionalArguments);
const options = commandLineArgs(optionDefs, { argv: splitArgs });
logger.debug?.(JSON.stringify(options));
for (const variable of options.variable) {
const variableMap = variable.split("=").map((x: string) => x.trim());
variablesMap[variableMap[0]] = variableMap[1];
}
}

const variablesField = task.getInput("Variables");
logger.debug?.("Variables: " + variablesField);
if (variablesField) {
const variables = getLineSeparatedItems(variablesField).map((p) => p.trim()) || undefined;
if (variables) {
for (const variable of variables) {
const variableMap = variable.split(":").map((x) => x.trim());
variablesMap[variableMap[0]] = variableMap[1];
}
}
}

const tenantsField = task.getInput("DeployForTenants");
logger.debug?.("Tenants: " + tenantsField);
const tagsField = task.getInput("DeployForTenantTags");
logger.debug?.("Tenant Tags: " + tagsField);
const tags = getLineSeparatedItems(tagsField || "")?.map((t: string) => t.trim()) || [];

const parameters: InputParameters = {
space: task.getInput("Space") || "",
project: task.getInput("Project", true) || "",
releaseNumber: task.getInput("ReleaseNumber", true) || "",
environment: task.getInput("Environment", true) || "",
useGuidedFailure: task.getBoolean("UseGuidedFailure") || undefined,
variables: variablesMap || undefined,
tenants: getLineSeparatedItems(tenantsField || "")?.map((t: string) => t.trim()) || [],
tenantTags: tags,
};

const errors: string[] = [];
if (parameters.space === "") {
errors.push("The Octopus space name is required.");
}

if (parameters.tenantTags.length === 0 && parameters.tenants.length === 0) {
errors.push("Must provide at least one tenant or tenant tag.");
}

if (errors.length > 0) {
throw new Error("Failed to successfully build parameters.\n" + errors.join("\n"));
}

logger.debug?.(JSON.stringify(parameters));

return parameters;
}
Loading

0 comments on commit a2c2130

Please sign in to comment.