diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/README.md b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/README.md new file mode 100644 index 0000000..066e93f --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/README.md @@ -0,0 +1,60 @@ +Build Property Setter +===================================== + +This worker is triggered by the `AFTER_BUILD_INFO_SAVE` event of Artifactory. Its primary purpose is to remove the "latest=true" property from any artifacts in previous builds and set the same property on the artifacts corresponding to the current build that was saved. + +Functionality +------------- +- **Removing property:** Removes "latest=true" from artifacts in past builds. +- **Adding property:** Sets "latest=true" for artifacts in the current build. + +Worker Logic +------------ +1. **Removing latest from previous build's artifacts**: + - Find the previous build numbers for the same build name. + - Find the unique artifacts from the past builds. + - Use the delete property API to remove the property from these artifacts. +2. **Setting latest for artifacts in current build**: + - Fetch artifacts from the current build. + - Use the set property API to set `latest=true` for each of these artifacts. + +Payload +------- +The worker operates on the `AFTER_BUILD_INFO_SAVE` event payload provided by Artifactory. It uses the build name and build number to fetch the artifact details. + +Possible Responses +------------------ + +### Success +- **Structure:** + ```json + { + "data": { + "message": "Successfully set property for artifacts", + "executionStatus": 4 + }, + "executionStatus": "STATUS_SUCCESS" + } + ``` +- **Explanation:** + - Indicates that only the artifacts present in the build (if any) will now have the property `latest=true` set. + +### Failed +- **Structure:** + ```json + { + "data": { + "message": "Error occurred", + "executionStatus": 2 + }, + "executionStatus": "STATUS_FAIL" + } + ``` +- **Explanation:** + - Indicates there was an error in fetching details or setting the property. + +Recommendations +--------------- + +- **Testing**: + - Validate the worker functionality in a staging environment before deploying to production. \ No newline at end of file diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/manifest.json b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/manifest.json new file mode 100644 index 0000000..f54cec8 --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "build-property-setter", + "description": "Removes 'latest=true' from previous build artifacts and sets it for current build artifacts after build info is saved.", + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "example-repo-local" + ] + } + }, + "secrets": {}, + "sourceCodePath": "./worker.ts", + "action": "AFTER_BUILD_INFO_SAVE", + "enabled": false, + "debug": true +} \ No newline at end of file diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/package.json b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/package.json new file mode 100644 index 0000000..fb3865e --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/package.json @@ -0,0 +1,42 @@ +{ + "name": "build-property-setter", + "description": "Run a script on AFTER_BUILD_INFO_SAVE", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"build-property-setter\"", + "test": "jest" + }, + "license": "ISC", + "devDependencies": { + "jfrog-workers": "^0.4.0", + "@golevelup/ts-jest": "^0.4.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-jasmine2": "^29.7.0", + "ts-jest": "^29.1.2" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "js" + ], + "rootDir": ".", + "testEnvironment": "node", + "clearMocks": true, + "maxConcurrency": 1, + "testRegex": "\\.spec\\.ts$", + "moduleDirectories": [ + "node_modules" + ], + "collectCoverageFrom": [ + "**/*.ts" + ], + "coverageDirectory": "../coverage", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testRunner": "jest-jasmine2", + "verbose": true + } +} diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/tsconfig.json b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/tsconfig.json new file mode 100644 index 0000000..2d19407 --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "target": "es2017", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "allowJs": true + }, + "include": [ + "**/*.ts", + "node_modules/@types/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/types.ts b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/types.ts new file mode 100644 index 0000000..70c3160 --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/types.ts @@ -0,0 +1,58 @@ +interface DetailedBuildRun { + name: string; + number: string; + started: string; + buildAgent: string; + agent: string; + durationMillis: number; + principal: string; + artifactoryPrincipal: string; + url: string; + parentName: string; + parentNumber: string; + buildRepo: string; + modules: Module[]; + releaseStatus: string; + promotionStatuses: PromotionStatus[]; +} + +interface Module { + id: string; + artifacts: Artifact[]; + dependencies: Dependency[]; +} + +interface Artifact { + name: string; + type: string; + sha1: string; + sha256: string; + md5: string; + remotePath: string; + properties: string; +} + +interface Dependency { + id: string; + scopes: string; + requestedBy: string; +} + +interface PromotionStatus { + status: string; + comment: string; + repository: string; + timestamp: string; + user: string; + ciUser: string; +} + +export interface AfterBuildInfoSaveRequest { + /** Various immutable build run details */ + build: DetailedBuildRun; +} + +export interface ArtifactPathInfo { + repo: string; + path: string; +} diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/worker.spec.ts b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/worker.spec.ts new file mode 100644 index 0000000..c4923d0 --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/worker.spec.ts @@ -0,0 +1,220 @@ +import {PlatformContext, PlatformClients, PlatformHttpClient, Status} from 'jfrog-workers'; +import {createMock, DeepMocked} from '@golevelup/ts-jest'; +import {AfterBuildInfoSaveRequest} from './types'; +import runWorker from './worker'; + +describe("build-property-setter worker", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ + status: 200, + data: {buildsNumbers: [], buildInfo: {modules: []}} + }), + put: jest.fn().mockResolvedValue({status: 204}), + delete: jest.fn().mockResolvedValue({status: 204}) + }) + }) + }); + request = createMock({ + build: { + name: 'test-build', + number: '123' + } + }); + }); + + it('should handle first build scenario', async () => { + // Simulate no previous builds + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: {buildsNumbers: [], buildInfo: {modules: []}} + }); + + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ + message: expect.stringContaining('Successfully set property for artifacts'), + executionStatus: Status.STATUS_SUCCESS + }) + ); + }); + + it('should call delete API for past artifacts in past builds', async () => { + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildsNumbers: [{uri: '/12356'}], + buildInfo: {modules: []} + } + }); + + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildInfo: { + modules: [ + { + id: 'module1', + artifacts: [ + { + name: 'artifact1.jar', + sha1: 'abc', + md5: 'def', + type: 'jar', + remotePath: 'repo/path/artifact1.jar' + } + ] + } + ] + } + } + }); + + await runWorker(context, request); + + expect(context.clients.platformHttp.delete).toHaveBeenCalled(); + }); + + it('should call put API to set properties for artifacts in current build', async () => { + // Mock: No previous builds + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: {buildsNumbers: [], buildInfo: {modules: []}} + }); + // Mock: Current build details with one artifact + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildInfo: { + modules: [ + { + id: 'module1', + artifacts: [ + { + name: 'artifact2.jar', + sha1: 'xyz', + md5: 'uvw', + type: 'jar', + originalDeploymentRepo: 'my-repo', + path: 'some/path/artifact2.jar' + } + ] + } + ] + } + } + }); + + await runWorker(context, request); + + expect(context.clients.platformHttp.put).toHaveBeenCalledWith( + expect.stringContaining('/artifactory/api/storage/my-repo/some/path/artifact2.jar?properties=latest=true') + ); + }); + + it('should handle past build with module having no artifacts, but log a warning', async () => { + + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildsNumbers: [{uri: '/12356'}], + buildInfo: {modules: []} + } + }); + + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildInfo: { + modules: [ + {id: 'module1'} // no artifacts property + ] + } + } + }); + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { + }); + await runWorker(context, request); + expect(consoleWarnSpy).toHaveBeenCalledWith("A module was skipped as no artifact array was found"); + consoleWarnSpy.mockRestore(); + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ + message: expect.stringContaining('Successfully set property for artifacts'), + executionStatus: Status.STATUS_SUCCESS + }) + ); + }); + + it('should return fail status if set property API call fails', async () => { + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: {buildsNumbers: [], buildInfo: {modules: []}} + }); + // Mock: Current build details with one artifact + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildInfo: { + modules: [ + { + id: 'module1', + artifacts: [ + { + name: 'artifact2.jar', + sha1: 'xyz', + md5: 'uvw', + type: 'jar', + originalDeploymentRepo: 'my-repo', + path: 'some/path/artifact2.jar' + } + ] + } + ] + } + } + }); + // Mock: setProperty (put) fails + (context.clients.platformHttp.put as jest.Mock).mockResolvedValueOnce({ + status: 400 + }); + + const result = await runWorker(context, request); + + expect(result).toEqual( + expect.objectContaining({ + message: expect.stringContaining('Error occurred'), + executionStatus: Status.STATUS_FAIL + }) + ); + }); + + it('should return fail status if fetching artifact details for a past build fails', async () => { + + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + buildsNumbers: [{uri: '/12356'}], + buildInfo: {modules: []} + } + }); + + (context.clients.platformHttp.get as jest.Mock).mockResolvedValueOnce({ + status: 404, + data: {} + }); + + const result = await runWorker(context, request); + + expect(result).toEqual( + expect.objectContaining({ + message: expect.stringContaining('Error occurred'), + executionStatus: Status.STATUS_FAIL + }) + ); + }); +}); \ No newline at end of file diff --git a/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/worker.ts b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/worker.ts new file mode 100644 index 0000000..d25f34a --- /dev/null +++ b/samples/artifactory/AFTER_BUILD_INFO_SAVE/build-property-setter/worker.ts @@ -0,0 +1,229 @@ +import {PlatformContext, Status} from "jfrog-workers"; +import {AfterBuildInfoSaveRequest, ArtifactPathInfo} from "./types"; + +export default async function setBuildProperty( + context: PlatformContext, + data: AfterBuildInfoSaveRequest +): Promise<{ message: string; executionStatus: Status }> { + try { + const buildName = data.build.name; + const currentBuildNumber = data.build.number; + const previousBuildResponse = await context.clients.platformHttp.get( + `/artifactory/api/build/${buildName}` + ); + + const previousBuildNumbers = new Set(); + + for (const build of previousBuildResponse.data.buildsNumbers) { + const buildNumber = build.uri.replace(/^\/+/, ""); + if (buildNumber != currentBuildNumber) { + previousBuildNumbers.add(buildNumber); + } + } + + if (previousBuildNumbers.size >= 1) { + await processPropertyCleanupForPreviousBuilds( + context, + buildName, + previousBuildNumbers + ); + } + + await processCurrentBuildForSettingProperty( + context, + buildName, + currentBuildNumber + ); + } catch (error) { + console.error( + `Request failed with status code ${error.status || ""} caused by: ${error.message + }` + ); + return { + message: "Error occurred", + executionStatus: Status.STATUS_FAIL, + }; + } + return { + message: "Successfully set property for artifacts", + executionStatus: Status.STATUS_SUCCESS, + }; +} + +async function processPropertyCleanupForPreviousBuilds( + context: PlatformContext, + buildName: string, + previousBuildNumbers: Set +) { + console.info( + "Finding all artifacts with latest set from the build numbers: " + + Array.from(previousBuildNumbers) + ); + const artifactsForPropertyRemoval = new Map(); + + for (const buildNumber of previousBuildNumbers) { + await fetchAndExtractBuildDetailsForPropertyRemoval( + context, + buildName, + buildNumber, + artifactsForPropertyRemoval + ); + } + + for (const artifactInfo of artifactsForPropertyRemoval.values()) { + await deleteProperty(context, artifactInfo.repo, artifactInfo.path); + } +} + +async function processCurrentBuildForSettingProperty( + context: PlatformContext, + buildName: string, + currentBuildNumber: string +) { + console.info( + `Setting property latest to value true for artifacts in build number ${currentBuildNumber}` + ); + const artifactSet = new Set(); + + await fetchAndExtractBuildDetailsForPropertyUpdate( + context, + buildName, + currentBuildNumber, + artifactSet + ); + + for (const artifactInfo of artifactSet) { + await setProperty(context, artifactInfo.repo, artifactInfo.path); + } +} + +async function populateArtifactsForPropertyRemoval( + modules: any, + artifactsForPropertyRemoval: Map +) { + for (const module of modules) { + if (Array.isArray(module.artifacts)) { + for (const artifact of module.artifacts) { + const artifactFullPath = + artifact.originalDeploymentRepo + "/" + artifact.path; + if (!artifactsForPropertyRemoval.has(artifactFullPath)) { + const artifactPathInfo: ArtifactPathInfo = { + repo: artifact.originalDeploymentRepo, + path: artifact.path, + }; + artifactsForPropertyRemoval.set(artifactFullPath, artifactPathInfo); + } + } + } else { + console.warn("A module was skipped as no artifact array was found"); + } + } +} + +async function populateArtifactsForPropertyUpdate( + modules: any, + artifactsForPropertyUpdate: Set +) { + for (const module of modules) { + if (Array.isArray(module.artifacts)) { + for (const artifact of module.artifacts) { + const artifactPathInfo: ArtifactPathInfo = { + repo: artifact.originalDeploymentRepo, + path: artifact.path, + }; + artifactsForPropertyUpdate.add(artifactPathInfo); + } + } else { + console.warn(`A module was skipped as no artifact array was found`); + } + } +} + +async function fetchAndExtractBuildDetailsForPropertyRemoval( + context: PlatformContext, + buildName: string, + buildNumber: string, + artifactsForPropertyRemoval: Map +) { + const buildResponse = await context.clients.platformHttp.get( + `/artifactory/api/build/${buildName}/${buildNumber}` + ); + if (buildResponse.status === 200) { + const buildData = buildResponse.data; + const modules = buildData.buildInfo.modules; + if (Array.isArray(modules)) { + populateArtifactsForPropertyRemoval(modules, artifactsForPropertyRemoval); + } else { + console.warn( + "No modules found in the build data or modules is not an array" + ); + } + } else { + console.warn( + `Failed to retrieve build data, status code: ${buildResponse.status}` + ); + throw new Error(`build fetch failed for build number: ${buildNumber}`); + } +} + +async function fetchAndExtractBuildDetailsForPropertyUpdate( + context: PlatformContext, + buildName: string, + buildNumber: string, + artifactsForPropertyUpdate: Set +) { + const buildResponse = await context.clients.platformHttp.get( + `/artifactory/api/build/${buildName}/${buildNumber}` + ); + if (buildResponse.status === 200) { + const buildData = buildResponse.data; + const modules = buildData.buildInfo.modules; + if (Array.isArray(modules)) { + populateArtifactsForPropertyUpdate(modules, artifactsForPropertyUpdate); + } else { + console.warn( + "No modules found in the build data or modules is not an array" + ); + } + } else { + console.warn( + `Failed to retrieve build data, status code: ${buildResponse.status}` + ); + throw new Error(`build fetch failed for build number: ${buildNumber}`); + } +} + +async function deleteProperty( + context: PlatformContext, + repository: string, + path: string +) { + const updateResponse = await context.clients.platformHttp.delete( + `/artifactory/api/storage/${repository}/${path}?properties=latest` + ); + + if (updateResponse.status !== 204) { + console.error( + `Failed to delete properties for ${path}, status code: ${updateResponse.status}` + ); + throw new Error("failed to remove property from an artifact"); + } +} + +async function setProperty( + context: PlatformContext, + repository: string, + path: string +) { + const properties = `latest=true`; + const updateResponse = await context.clients.platformHttp.put( + `/artifactory/api/storage/${repository}/${path}?properties=${properties}` + ); + + if (updateResponse.status !== 204) { + console.error( + `Failed to set property for artifact: ${path} with status code: ${updateResponse.status}` + ); + throw new Error("failed to set property"); + } +}