Skip to content

Commit dec1909

Browse files
Refactor and add tests (#39)
1 parent 0bd3f62 commit dec1909

15 files changed

+9902
-484
lines changed

README.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,61 @@
11
# TeamCityCloudAgentUpdater
22

3-
When a new TeamCity agent image is built (e.g., via [packer](packer.io)), you need to tell TeamCity to start using it.
3+
When a new TeamCity agent image is built (e.g., via [packer](packer.io)), you need to tell TeamCity to start using it.
44

55
This is a simple NodeJS app that can:
6-
1. update TeamCity Cloud Agents images and disable any agents that are running that are based on the old image
7-
2. remove any agents that were disabled during update that are no longer running a build
6+
1. Update TeamCity Cloud Agents images and disable any agents that are running that are based on the old image
7+
2. Remove any agents that were disabled during update that are no longer running a build
88

99
## Usage
1010

1111
When you have a new agent, update the cloud profile:
12-
```
13-
node index.js [update-cloud-image] --token XXXXX --server https://teamcity.example.com --image ami-XXXXXXX --cloudprofile "AWS Agents" --agentprefix "Ubuntu" [--dryrun]
12+
```bash
13+
node index.js update-cloud-profile --token XXXXX --server https://teamcity.example.com --image ami-XXXXXXX --cloudprofile "AWS Agents" --agentprefix "Ubuntu" [--dryrun]
1414
```
1515

1616
To remove any agents that were disabled as part of the update, and are no longer running a build (run on a schedule):
17-
```
17+
```bash
1818
node index.js remove-disabled-agents --token XXXXX --server https://teamcity.example.com [--dryrun]
1919
```
2020

21-
The `--dryrun` flag allows you to check what actions the script would have taken with out any real modifications.
21+
The `--dryrun` flag allows you to check what actions the script would have taken without any real modifications.
22+
23+
## Requirements
24+
25+
- Node.js >= 22.0.0
26+
- npm >= 11.0.0
27+
- TeamCity `2019.1` or newer (for user access tokens)
28+
29+
## Development
30+
31+
### Installation
32+
```bash
33+
npm install
34+
```
35+
36+
### Testing
37+
```bash
38+
# Run all tests
39+
npm test
40+
41+
# Run tests in watch mode
42+
npm run test:watch
2243

23-
## Requiremens
44+
# Run tests with coverage
45+
npm run test:coverage
46+
```
2447

25-
This app uses features (user access tokens) that require TeamCity `2019.1` or newer.
48+
### Project Structure
49+
```
50+
.
51+
├── index.js # Entry point
52+
├── cli.js # CLI command definitions
53+
├── lib/
54+
│ ├── agents.js # Agent management operations
55+
│ ├── cloud-profiles.js # Cloud profile management
56+
│ └── utils.js # Utility functions
57+
└── *.test.js # Test files co-located with source
58+
```
2659

2760
## License
2861

build.ps1

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,94 @@ function Publish-ToGitHub($versionNumber, $commitId, $preRelease, $artifact, $gi
4242
Write-output "### Enabling TLS 1.2 support"
4343
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12, [System.Net.SecurityProtocolType]::Tls11, [System.Net.SecurityProtocolType]::Tls
4444

45+
Write-output "### Running npm operations in Docker container"
46+
47+
# Use Node 22 Alpine image (smaller and matches package.json requirement)
48+
$nodeImage = "node:22-alpine"
49+
$workDir = "/app"
50+
51+
# Mount current directory to container and run npm/test commands
52+
Write-output "### Installing dependencies in Docker"
53+
docker run --rm `
54+
-v "${PWD}:${workDir}" `
55+
-w $workDir `
56+
$nodeImage `
57+
sh -c "npm install -g npm@latest && npm clean-install"
58+
59+
if ($LASTEXITCODE -ne 0) {
60+
Write-Error "npm clean-install failed with exit code $LASTEXITCODE"
61+
exit $LASTEXITCODE
62+
}
63+
64+
Write-output "### Running tests in Docker"
65+
# Run tests with Jest's TeamCity reporter if in TeamCity, otherwise use default reporter
66+
if ($env:TEAMCITY_VERSION) {
67+
Write-output "##teamcity[testSuiteStarted name='Jest Tests']"
68+
69+
# Run tests with coverage (reporter auto-selected by jest.config.js)
70+
docker run --rm `
71+
-v "${PWD}:${workDir}" `
72+
-w $workDir `
73+
-e TEAMCITY_VERSION=$env:TEAMCITY_VERSION `
74+
$nodeImage `
75+
sh -c "npm install -g npm@latest && npx jest --coverage --ci"
76+
77+
$testExitCode = $LASTEXITCODE
78+
79+
# Report coverage to TeamCity if available
80+
if (Test-Path "coverage/lcov.info") {
81+
Write-output "### Reporting code coverage to TeamCity"
82+
83+
# Extract coverage metrics from lcov.info
84+
$lcovContent = Get-Content "coverage/lcov.info"
85+
86+
# LF = Lines Found (total lines)
87+
$totalLines = $lcovContent |
88+
Select-String -Pattern "^LF:" |
89+
ForEach-Object { [int]$_.Line.Split(':')[1] } |
90+
Measure-Object -Sum |
91+
Select-Object -ExpandProperty Sum
92+
93+
# LH = Lines Hit (covered lines)
94+
$coveredLines = $lcovContent |
95+
Select-String -Pattern "^LH:" |
96+
ForEach-Object { [int]$_.Line.Split(':')[1] } |
97+
Measure-Object -Sum |
98+
Select-Object -ExpandProperty Sum
99+
100+
# Calculate percentage
101+
if ($totalLines -gt 0) {
102+
$coveragePercentage = [math]::Round(($coveredLines / $totalLines) * 100, 2)
103+
Write-output "Code coverage: $coveredLines/$totalLines lines ($coveragePercentage%)"
104+
105+
# Report to TeamCity
106+
Write-output "##teamcity[buildStatisticValue key='CodeCoverageAbsLTotal' value='$totalLines']"
107+
Write-output "##teamcity[buildStatisticValue key='CodeCoverageAbsLCovered' value='$coveredLines']"
108+
Write-output "##teamcity[buildStatisticValue key='CodeCoverageL' value='$coveragePercentage']"
109+
}
110+
}
111+
112+
Write-output "##teamcity[testSuiteFinished name='Jest Tests']"
113+
114+
if ($testExitCode -ne 0) {
115+
Write-Error "Tests failed with exit code $testExitCode"
116+
exit $testExitCode
117+
}
118+
} else {
119+
# Run tests normally when not in TeamCity
120+
docker run --rm `
121+
-v "${PWD}:${workDir}" `
122+
-w $workDir `
123+
$nodeImage `
124+
sh -c "npm test"
125+
126+
if ($LASTEXITCODE -ne 0) {
127+
Write-Error "Tests failed with exit code $LASTEXITCODE"
128+
exit $LASTEXITCODE
129+
}
130+
}
131+
132+
Write-output "### Creating release archive"
45133
Compress-Archive -Path (get-childitem) -DestinationPath ".\TeamCityCloudAgentUpdater.$buildVersion.zip"
46134

47135
$commitId = git rev-parse HEAD

cli.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use strict';
2+
3+
const { Command } = require('commander');
4+
const lib = require('./lib/index');
5+
6+
function createProgram() {
7+
const program = new Command();
8+
9+
program
10+
.name('TeamCity Cloud Agent Updater')
11+
.description('Simple NodeJS app to update images for TeamCity Cloud Agents, via the 2017.1 API.')
12+
.version('1.0.0');
13+
14+
program
15+
.command('update-cloud-profile', { isDefault: true })
16+
.requiredOption('--token <string>', 'A valid TeamCity user access token (requires TC 2019.1)')
17+
.requiredOption('--server <string>', 'The url of the TeamCity server, eg "http://teamcity.example.com"')
18+
.requiredOption('--image <string>', 'The AMI id (for AWS), or full url to the VHD / resource id of the managed image (for Azure)')
19+
.requiredOption('--cloudprofile <string>', 'The name of the TeamCity Cloud Profile to modify')
20+
.requiredOption('--agentprefix <string>', 'The agent prefix used in the Cloud Profile image that should be updated')
21+
.option('--dryrun', 'Output what changes the app would make, but dont actually make the changes')
22+
.action((options) => lib.updateCloudImage(options.server, "Bearer " + options.token, options.cloudprofile, options.agentprefix, options.image, options.dryrun));
23+
24+
program
25+
.command('remove-disabled-agents')
26+
.requiredOption('--token <string>', 'A valid TeamCity user access token (requires TC 2019.1)')
27+
.requiredOption('--server <string>', 'The url of the TeamCity server, eg "http://teamcity.example.com"')
28+
.option('--dryrun', 'Output what changes the app would make, but dont actually make the changes')
29+
.action((options) => lib.removeDisabledAgents(options.server, "Bearer " + options.token, options.dryrun));
30+
31+
return program;
32+
}
33+
34+
function run(argv) {
35+
const program = createProgram();
36+
return program.parse(argv);
37+
}
38+
39+
module.exports = {
40+
createProgram,
41+
run
42+
};

cli.test.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const { spawn } = require('child_process');
2+
const path = require('path');
3+
const cli = require('./cli');
4+
5+
describe('CLI Commands', () => {
6+
const indexPath = path.join(__dirname, 'index.js');
7+
8+
const runCommand = (args) => {
9+
return new Promise((resolve, reject) => {
10+
const process = spawn('node', [indexPath, ...args]);
11+
let stdout = '';
12+
let stderr = '';
13+
14+
process.stdout.on('data', (data) => {
15+
stdout += data.toString();
16+
});
17+
18+
process.stderr.on('data', (data) => {
19+
stderr += data.toString();
20+
});
21+
22+
process.on('close', (code) => {
23+
resolve({ code, stdout, stderr });
24+
});
25+
26+
process.on('error', (error) => {
27+
reject(error);
28+
});
29+
});
30+
};
31+
32+
describe('update-cloud-profile command', () => {
33+
it('should show error when required options are missing', async () => {
34+
const result = await runCommand(['update-cloud-profile']);
35+
expect(result.code).not.toBe(0);
36+
expect(result.stderr).toContain('required option');
37+
});
38+
39+
it('should show help with --help flag', async () => {
40+
const result = await runCommand(['update-cloud-profile', '--help']);
41+
expect(result.code).toBe(0);
42+
expect(result.stdout).toContain('--token');
43+
expect(result.stdout).toContain('--server');
44+
expect(result.stdout).toContain('--image');
45+
expect(result.stdout).toContain('--cloudprofile');
46+
expect(result.stdout).toContain('--agentprefix');
47+
expect(result.stdout).toContain('--dryrun');
48+
});
49+
50+
it('should validate required parameters', async () => {
51+
const result = await runCommand([
52+
'update-cloud-profile',
53+
'--token', 'test-token'
54+
]);
55+
expect(result.code).not.toBe(0);
56+
expect(result.stderr).toContain('required option');
57+
});
58+
});
59+
60+
describe('remove-disabled-agents command', () => {
61+
it('should show error when required options are missing', async () => {
62+
const result = await runCommand(['remove-disabled-agents']);
63+
expect(result.code).not.toBe(0);
64+
expect(result.stderr).toContain('required option');
65+
});
66+
67+
it('should show help with --help flag', async () => {
68+
const result = await runCommand(['remove-disabled-agents', '--help']);
69+
expect(result.code).toBe(0);
70+
expect(result.stdout).toContain('--token');
71+
expect(result.stdout).toContain('--server');
72+
expect(result.stdout).toContain('--dryrun');
73+
});
74+
75+
it('should validate required parameters', async () => {
76+
const result = await runCommand([
77+
'remove-disabled-agents',
78+
'--token', 'test-token'
79+
]);
80+
expect(result.code).not.toBe(0);
81+
expect(result.stderr).toContain('required option');
82+
});
83+
});
84+
85+
describe('General CLI', () => {
86+
it('should show version with --version flag', async () => {
87+
const result = await runCommand(['--version']);
88+
expect(result.code).toBe(0);
89+
expect(result.stdout).toContain('1.0.0');
90+
});
91+
92+
it('should show help with --help flag', async () => {
93+
const result = await runCommand(['--help']);
94+
expect(result.code).toBe(0);
95+
expect(result.stdout).toContain('TeamCity Cloud Agent Updater');
96+
expect(result.stdout).toContain('update-cloud-profile');
97+
expect(result.stdout).toContain('remove-disabled-agents');
98+
});
99+
100+
it('should use update-cloud-profile as default command', async () => {
101+
const result = await runCommand([]);
102+
expect(result.code).not.toBe(0);
103+
// Should show error for missing required options from update-cloud-profile
104+
expect(result.stderr).toContain('required option');
105+
});
106+
});
107+
});

0 commit comments

Comments
 (0)