Skip to content

Commit baed510

Browse files
Add progress logging to await task (#343)
* Add showprogress flag to input parameters * Add show progress logic * Fix last task not being logged. * Update read me with more test + build instructions
1 parent 48190c7 commit baed510

File tree

5 files changed

+184
-48
lines changed

5 files changed

+184
-48
lines changed

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,15 @@ This will generate the full extension content required to create the extension V
5252

5353
In order to package and test the extension on a local TFS instance, without publishing to the marketplace, you can run the following at a PowerShell command prompt.
5454

55-
`./pack.ps1 -environment localtest -version "x.x.x"`
55+
`./pack.ps1 -environment localtest -version "x.x.x" -setupTaskDependencies`
56+
57+
Alternatively you can run the build + packaging scripts:
58+
59+
Windows:
60+
`./localBuild.cmd`
61+
62+
MacOS:
63+
`source localbuild.sh`
5664

5765
### Releasing
5866

@@ -71,6 +79,7 @@ If you're doing updates, enhancements, or bug fixes, the fastest development flo
7179
It's highly recommended to set up two Virtual Machines running Windows Server. This is generally done locally, and it's best to give your VM at least 8 gigs of memory and 4 CPU cores, otherwise the TFS/ADO installation can fail or take hours.
7280

7381
1. Microsoft TFS Server 2017 Update 1 - This is the first version of TFS that supported extensions, so it's very good for regression testing.
82+
- Note that the `.exe` installers of older versions may fail to install due to broken dependency downloads. It's recommended to use the `.iso` downloads
7483
2. Microsoft Azure DevOps Server vLatest - This is the on-prem version of Microsoft's hosted Azure DevOps services/tooling. It's generally faster/easier to test this locally than continually publishing to the Azure DevOps Marketplace.
7584

7685
To install locally, build and package the application as per the instructions above. Then install the extension by uploading it. Instructions to do this are available in Microsoft's [TFS/ADO docs](https://docs.microsoft.com/en-us/vsts/marketplace/get-tfs-extensions?view=tfs-2018#install-extensions-for-disconnected-tfs).
@@ -86,6 +95,13 @@ To install locally, build and package the application as per the instructions ab
8695
* If you design a build pipeline with the current live extension, you can't upgrade it to a local version. You need to install the `localtest` extension first and use it in your builds. Then you can upgrade it and you will get your latest changes.
8796
* We need to maintain backwards compatibility, and we need to ensure any existing builds will not break after we publish an update. Therefore regression testing is critical. The recommended approach for regression testing is to build the current live extension for `localtest` and create build pipelines covering the areas you're changing. Then update the extension and re-run all your builds to ensure everything is still green/working.
8897
* Building on the previous point, there is no way to roll back an extension so testing is difficult as well. The recommended approach to this is to snapshot your local test VMs when you have a working build, so you can update the extension and revert back to the snapshot as needed.
98+
* Older versions of Azure DevOps/Team Foundation Server has limits on the size of extensions to upload which may prevent you from installing the extension.
99+
- For Azure Devops 2019. You can increase the limit by running the following SQL script on the Azure Devops "Configuration" database. (``)
100+
```
101+
DECLARE @keyvalues dbo.typ_keyvaluepairstringtablenullable;
102+
INSERT @keyvalues VALUES ('#\Configuration\Service\Gallery\LargeExtensionUpload\MaxPackageSizeMB', '50')
103+
exec prc_UpdateRegistry 1, @keyvalues;
104+
```
89105
* During manual testing against our test environment on Azure DevOps, the `devops@...` account has access for publishing to test and production environments (if you try to do this from your personal account, publishing will fail). When you create your security tokens, do this from the devops account and either setup an expiry and/or remove the token when you are finished with it.
90106
* At the time of writing, we have a [build pipeline on ADO](https://octopus-deploy.visualstudio.com/VstsExtension/_build?definitionId=5&_a=summary), which builds and pushes packages to our [deployhq](https://deploy.octopushq.com/app#/Spaces-1/projects/azure-devops-extension/deployments) project, where we can then release to Test and Production.
91107
* If the deployHQ task fails due to a timeout (which is common), trying again on the octopusHQ task *will not fix it*. You need to squirrel into the VSTS task (see the task log in octopusHQ, it will have a link to the VSTS task in the log, which will have a link to the `Web`, click that and it'll take you to the problem), see that it's failing on all the steps related to v4 of our octo tool, *run the same task again (as new)* (don't re-run the existing task that's failed), wait for that to succeed, then re-run our task in deployHQ and it will then succeed. We want to allocate time to investigate why this is so awkward, but for now, we're documenting here for discoverability.

source/tasks/AwaitTask/AwaitTaskV6/input-parameters.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface InputParameters {
88
tasks: WaitExecutionResult[];
99
pollingInterval: number;
1010
timeout: number;
11+
showProgress: boolean;
1112
}
1213

1314
export function getInputParameters(logger: Logger, task: TaskWrapper): InputParameters {
@@ -21,10 +22,14 @@ export function getInputParameters(logger: Logger, task: TaskWrapper): InputPara
2122
throw new Error("Failed to successfully build parameters: step name is required.");
2223
}
2324

24-
const tasks = task.getOutputVariable(step, "server_tasks");
25-
if (tasks === undefined) {
25+
const taskJson = task.getOutputVariable(step, "server_tasks");
26+
if (taskJson === undefined) {
2627
throw new Error(`Failed to successfully build parameters: cannot find '${step}.server_tasks' variable from execution step`);
2728
}
29+
const tasks = JSON.parse(taskJson);
30+
if (!Array.isArray(tasks)) {
31+
throw new Error(`Failed to successfully build parameters: '${step}.server_tasks' variable from execution step is not an array`);
32+
}
2833

2934
let pollingInterval = 10;
3035
const pollingIntervalField = task.getInput("PollingInterval");
@@ -38,10 +43,16 @@ export function getInputParameters(logger: Logger, task: TaskWrapper): InputPara
3843
timeoutSeconds = +timeoutField;
3944
}
4045

46+
const showProgress = task.getBoolean("ShowProgress") ?? false;
47+
if (showProgress && tasks.length > 1) {
48+
throw new Error("Failed to successfully build parameters: ShowProgress can only be enabled when waiting for a single task");
49+
}
50+
4151
const parameters: InputParameters = {
4252
space: task.getInput("Space") || "",
4353
step: step,
44-
tasks: JSON.parse(tasks),
54+
tasks: tasks,
55+
showProgress: showProgress,
4556
pollingInterval: pollingInterval,
4657
timeout: timeoutSeconds,
4758
};

source/tasks/AwaitTask/AwaitTaskV6/task.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@
5757
"defaultValue": "600",
5858
"required": false,
5959
"helpMarkDown": "Duration, in seconds, to allow for completion before timing out. (Default: 600s)"
60+
},
61+
{
62+
"name": "ShowProgress",
63+
"type": "boolean",
64+
"label": "Show Progress",
65+
"defaultValue": "false",
66+
"required": false,
67+
"helpMarkDown": "Log Octopus task outputs to Azure DevOps output. (Default: false)"
6068
}
6169
],
6270
"outputVariables": [
Lines changed: 137 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Client, Logger, ServerTaskWaiter, SpaceRepository, TaskState } from "@octopusdeploy/api-client";
1+
import { ActivityElement, ActivityLogEntryCategory, ActivityStatus, Client, Logger, ServerTaskWaiter, SpaceRepository, SpaceServerTaskRepository, TaskState } from "@octopusdeploy/api-client";
22
import { OctoServerConnectionDetails } from "tasks/Utils/connection";
33
import { TaskWrapper } from "tasks/Utils/taskInput";
4-
import { getInputParameters } from "./input-parameters";
4+
import { getInputParameters, InputParameters } from "./input-parameters";
55
import { ExecutionResult } from "../../Utils/executionResult";
66
import { getClient } from "../../Utils/client";
77

@@ -10,46 +10,14 @@ export interface WaitExecutionResult extends ExecutionResult {
1010
}
1111

1212
export class Waiter {
13-
constructor(readonly connection: OctoServerConnectionDetails, readonly task: TaskWrapper, readonly logger: Logger) {}
13+
constructor(readonly connection: OctoServerConnectionDetails, readonly task: TaskWrapper, readonly logger: Logger) { }
1414

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

18-
const client = await getClient(this.connection, this.logger, "task", "wait", 6)
18+
const client = await getClient(this.connection, this.logger, "task", "wait", 6);
1919

20-
const waiter = new ServerTaskWaiter(client, inputParameters.space);
21-
22-
const taskIds: string[] = [];
23-
const waitExecutionResults: WaitExecutionResult[] = [];
24-
const lookup: Map<string, WaitExecutionResult> = new Map<string, WaitExecutionResult>();
25-
inputParameters.tasks.map((t) => {
26-
lookup.set(t.serverTaskId, t);
27-
taskIds.push(t.serverTaskId);
28-
});
29-
30-
await waiter.waitForServerTasksToComplete(taskIds, inputParameters.pollingInterval * 1000, inputParameters.timeout * 1000, (t) => {
31-
let context = "";
32-
const taskResult = lookup.get(t.Id);
33-
if (taskResult) {
34-
if (taskResult?.environmentName) {
35-
context = ` to environment '${taskResult.environmentName}'`;
36-
}
37-
if (taskResult?.tenantName) {
38-
context += ` for tenant '${taskResult?.tenantName}'`;
39-
}
40-
41-
if (t.IsCompleted) {
42-
this.logger.info?.(`${taskResult.type}${context} ${t.State === TaskState.Success ? "completed successfully" : "did not complete successfully"}`);
43-
} else {
44-
this.logger.info?.(`${taskResult.type}${context} is '${t.State}'`);
45-
}
46-
47-
if (t.IsCompleted) {
48-
taskResult.successful = t.IsCompleted && t.State == TaskState.Success;
49-
waitExecutionResults.push(taskResult);
50-
}
51-
}
52-
});
20+
const waitExecutionResults = inputParameters.showProgress ? await this.waitWithProgress(client, inputParameters) : await this.waitWithoutProgress(client, inputParameters);
5321

5422
const spaceId = await this.getSpaceId(client, inputParameters.space);
5523
let failedDeploymentsCount = 0;
@@ -86,4 +54,136 @@ export class Waiter {
8654
getContext(result: WaitExecutionResult): string {
8755
return result.tenantName ? result.tenantName.replace(" ", "_") : result.environmentName.replace(" ", "_");
8856
}
57+
58+
async waitWithoutProgress(client: Client, inputParameters: InputParameters): Promise<WaitExecutionResult[]> {
59+
const waiter = new ServerTaskWaiter(client, inputParameters.space);
60+
const taskIds = inputParameters.tasks.map((t) => t.serverTaskId);
61+
const lookup = new Map(inputParameters.tasks.map((t) => [t.serverTaskId, t]));
62+
63+
const results: WaitExecutionResult[] = [];
64+
65+
await waiter.waitForServerTasksToComplete(taskIds, inputParameters.pollingInterval * 1000, inputParameters.timeout * 1000, (t) => {
66+
const taskResult = lookup.get(t.Id);
67+
if (!taskResult) return;
68+
const context = this.getProgressContext(taskResult);
69+
70+
if (!t.IsCompleted) {
71+
this.logger.info?.(`${taskResult.type}${context} is '${t.State}'`);
72+
return;
73+
}
74+
75+
this.logger.info?.(`${taskResult.type}${context} ${t.State === TaskState.Success ? "completed successfully" : "did not complete successfully"}`);
76+
taskResult.successful = t.State == TaskState.Success;
77+
results.push(taskResult);
78+
});
79+
return results;
80+
}
81+
82+
async waitWithProgress(client: Client, inputParameters: InputParameters): Promise<WaitExecutionResult[]> {
83+
const waiter = new ServerTaskWaiter(client, inputParameters.space);
84+
const taskIds = inputParameters.tasks.map((t) => t.serverTaskId);
85+
const taskLookup = new Map(inputParameters.tasks.map((t) => [t.serverTaskId, t]));
86+
87+
const taskRepository = new SpaceServerTaskRepository(client, inputParameters.space);
88+
const loggedChildTaskIds: string[] = [];
89+
const lastTaskUpdate: { [taskId: string]: string } = {};
90+
91+
const results: WaitExecutionResult[] = [];
92+
93+
const promises: Promise<void>[] = [];
94+
await waiter.waitForServerTasksToComplete(taskIds, inputParameters.pollingInterval * 1000, inputParameters.timeout * 1000, (t) => {
95+
const taskResult = taskLookup.get(t.Id);
96+
if (!taskResult) return;
97+
98+
const taskUpdate = `${taskResult.type}${this.getProgressContext(taskResult)} is '${t.State}'`;
99+
if (loggedChildTaskIds.length == 0 && lastTaskUpdate[taskResult.serverTaskId] !== taskUpdate) {
100+
// Log top level updates until we have details, don't log them again
101+
this.logger.info?.(taskUpdate);
102+
lastTaskUpdate[taskResult.serverTaskId] = taskUpdate;
103+
}
104+
105+
// Log details of the task
106+
const promise = this.printTaskDetails(taskRepository, taskResult, loggedChildTaskIds, results);
107+
promises.push(promise);
108+
});
109+
110+
await Promise.all(promises);
111+
return results;
112+
}
113+
114+
async printTaskDetails(repository: SpaceServerTaskRepository, task: WaitExecutionResult, loggedChildTaskIds: string[], results: WaitExecutionResult[]): Promise<void> {
115+
try {
116+
this.logger.debug?.(`Fetching details on ${task.serverTaskId}`);
117+
const taskDetails = await repository.getDetails(task.serverTaskId);
118+
this.logger.debug?.(`Fetched details on ${task.serverTaskId}: ${JSON.stringify(taskDetails)}`);
119+
120+
const activities = taskDetails.ActivityLogs.flatMap((parentActivity) => parentActivity.Children.filter(isComplete).filter((activity) => !loggedChildTaskIds.includes(activity.Id)));
121+
122+
for (const activity of activities) {
123+
this.logWithStatus(`\t${activity.Status}: ${activity.Name}`, activity.Status);
124+
125+
if (activity.Started && activity.Ended) {
126+
const startTime = new Date(activity.Started);
127+
const endTime = new Date(activity.Ended);
128+
const duration = (endTime.getTime() - startTime.getTime()) / 1000;
129+
this.logger.info?.(`\t\t\t---------------------------------`);
130+
this.logger.info?.(`\t\t\tStarted: \t${activity.Started}\n\t\t\tEnded: \t${activity.Ended}\n\t\t\tDuration:\t${duration.toFixed(1)}s`);
131+
this.logger.info?.(`\t\t\t---------------------------------`);
132+
}
133+
134+
activity.Children.filter(isComplete)
135+
.flatMap((child) => child.LogElements)
136+
.forEach((log) => {
137+
this.logWithCategory(`\t\t${log.OccurredAt}: ${log.MessageText}`, log.Category);
138+
log.Detail && this.logger.debug?.(log.Detail);
139+
});
140+
141+
loggedChildTaskIds.push(activity.Id);
142+
}
143+
if (!taskDetails.Task.IsCompleted) return;
144+
145+
const message = taskDetails.Task.State === TaskState.Success ? "completed successfully" : "did not complete successfully";
146+
this.logger.info?.(`${task.type}${this.getProgressContext(task)} ${message}`);
147+
task.successful = taskDetails.Task.State === TaskState.Success;
148+
results.push(task);
149+
} catch (e) {
150+
const error = e instanceof Error ? e : undefined;
151+
this.logger.error?.(`Failed to fetch details on ${task}: ${e}`, error);
152+
}
153+
}
154+
155+
getProgressContext(task: WaitExecutionResult): string {
156+
return `${task.environmentName ? ` to environment '${task.environmentName}'` : ""}${task.tenantName ? ` for tenant '${task.tenantName}'` : ""}`;
157+
}
158+
159+
logWithCategory(message: string, category?: ActivityLogEntryCategory) {
160+
switch (category) {
161+
case "Error":
162+
case "Fatal":
163+
this.logger.error?.(message, undefined);
164+
break;
165+
case "Warning":
166+
this.logger.warn?.(message);
167+
break;
168+
default:
169+
this.logger.info?.(message);
170+
}
171+
}
172+
173+
logWithStatus(message: string, status?: ActivityStatus) {
174+
switch (status) {
175+
case "Failed":
176+
this.logger.error?.(message, undefined);
177+
break;
178+
case "SuccessWithWarning":
179+
this.logger.warn?.(message);
180+
break;
181+
default:
182+
this.logger.info?.(message);
183+
}
184+
}
185+
}
186+
187+
function isComplete(element: ActivityElement) {
188+
return element.Status != "Pending" && element.Status != "Running";
89189
}

source/vsts.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,14 @@ From version 6, the deploy release step is split into two seperate functions for
296296

297297
#### 📥 Inputs
298298

299-
| Name | Description |
300-
| :------------------------- | :------------------------------------------------------------------------------- |
301-
| `OctoConnectedServiceName` | **Required.** Name of the Octopus Server connection. |
302-
| `Space` | **Required.** The Octopus space the release is in. |
303-
| `Step` | **Required** The name of the step that queued the deployment/runbook run. |
304-
| `PollingInterval` | How frequently, in seconds, to check the status. (Default: 10s) |
305-
| `TimeoutAfter` | Duration, in seconds, to allow for completion before timing out. (Default: 600s) |
299+
| Name | Description |
300+
| :---------------------------- | :-------------------------------------------------------------------------------- |
301+
| `OctoConnectedServiceName` | **Required.** Name of the Octopus Server connection. |
302+
| `Space` | **Required.** The Octopus space the release is in. |
303+
| `Step` | **Required** The name of the step that queued the deployment/runbook run. |
304+
| `PollingInterval` | How frequently, in seconds, to check the status. (Default: 10s) |
305+
| `TimeoutAfter` | Duration, in seconds, to allow for completion before timing out. (Default: 600s) |
306+
| `ShowProgress` | Log Octopus task outputs to Azure DevOps output. (Default: false) |
306307

307308
The `Step` input parameter needs to be set to the `name` of the deployment step that generated the server tasks to be waited. In the classic-pipeline mode, you need to set the reference name on the `server_tasks` output variable and use that value for `Step`.
308309

0 commit comments

Comments
 (0)