From 77e32d91cd134ab1145023d5866514d0fe7b6dbd Mon Sep 17 00:00:00 2001 From: Ogheneobukome Ejaife Date: Tue, 1 Jul 2025 11:30:50 -0400 Subject: [PATCH] refactor(resolveKubectlVersion): switch to k8s CDN for security patch retrieval Replaced GitHub API Octo client with k8s CDN to fetch the latest security patch for improved reliability. Separated the API call logic from resolveKubectlVersion to enhance testability and readability. --- src/run.test.ts | 93 ++++++++++++++++++++++++++++++------------------- src/run.ts | 57 +++++++++++++++--------------- 2 files changed, 86 insertions(+), 64 deletions(-) diff --git a/src/run.test.ts b/src/run.test.ts index 3d49295..adfe64d 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1,14 +1,4 @@ -jest.mock('@octokit/rest', () => { - const listTags = jest.fn() - return { - Octokit: jest.fn().mockImplementation(() => ({ - repos: {listTags} - })) - } -}) - import * as run from './run' -import {octo} from './run' import { getkubectlDownloadURL, getKubectlArch, @@ -22,10 +12,6 @@ import * as core from '@actions/core' import * as util from 'util' describe('Testing all functions in run file.', () => { - beforeEach(() => { - jest.resetAllMocks() - }) - test('getExecutableExtension() - return .exe when os is Windows', () => { jest.spyOn(os, 'type').mockReturnValue('Windows_NT') expect(getExecutableExtension()).toBe('.exe') @@ -178,31 +164,66 @@ describe('Testing all functions in run file.', () => { ) expect(toolCache.downloadTool).not.toHaveBeenCalled() }) - test('resolveKubectlVersion() - major.minor expanded to latest patch', async () => { - // mock GitHub tags list - jest.spyOn(octo.repos, 'listTags').mockResolvedValueOnce({ - data: [{name: 'v1.27.13'}, {name: 'v1.27.15'}, {name: 'v1.26.6'}] - } as any) - const tag = await run.resolveKubectlVersion('1.27', octo) - expect(tag).toBe('v1.27.15') + test('getLatestPatchVersion() - download and return latest patch version', async () => { + jest + .spyOn(toolCache, 'downloadTool') + .mockReturnValue(Promise.resolve('pathToTool')) + jest.spyOn(fs, 'readFileSync').mockReturnValue('v1.27.15') + + const result = await run.getLatestPatchVersion('1', '27') + + expect(result).toBe('v1.27.15') + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://cdn.dl.k8s.io/release/stable-1.27.txt' + ) + expect(fs.readFileSync).toHaveBeenCalledWith('pathToTool', 'utf8') }) - test('resolveKubectlVersion() - returns matching full version unchanged', async () => { - // When a full version (already with patch) is provided, assume it is returned as is. - // Even if the GitHub tag list contains the same version. - jest.spyOn(octo.repos, 'listTags').mockResolvedValueOnce({ - data: [{name: 'v1.27.15'}, {name: 'v1.27.13'}] - } as any) - const tag = await run.resolveKubectlVersion('v1.27.15', octo) - expect(tag).toBe('v1.27.15') + test('getLatestPatchVersion() - throw error when patch version is empty', async () => { + jest + .spyOn(toolCache, 'downloadTool') + .mockReturnValue(Promise.resolve('pathToTool')) + jest.spyOn(fs, 'readFileSync').mockReturnValue('') + + await expect(run.getLatestPatchVersion('1', '27')).rejects.toThrow( + 'Failed to get latest patch version for 1.27' + ) }) - test('resolveKubectlVersion() - selects the only available matching version', async () => { - // When only one tag matches the provided major.minor, that tag is returned. - jest.spyOn(octo.repos, 'listTags').mockResolvedValueOnce({ - data: [{name: 'v1.28.0'}, {name: 'v1.27.99'}, {name: 'v1.26.5'}] - } as any) - const tag = await run.resolveKubectlVersion('1.27', octo) - expect(tag).toBe('v1.27.99') + + test('getLatestPatchVersion() - throw error when download fails', async () => { + jest + .spyOn(toolCache, 'downloadTool') + .mockRejectedValue(new Error('Network error')) + + await expect(run.getLatestPatchVersion('1', '27')).rejects.toThrow( + 'Failed to get latest patch version for 1.27' + ) + }) + test('resolveKubectlVersion() - expands major.minor to latest patch', async () => { + // Mock the getLatestPatchVersion call + jest.spyOn(run, 'getLatestPatchVersion').mockResolvedValue('v1.27.15') + + const result = await run.resolveKubectlVersion('1.27') + + expect(result).toBe('v1.27.15') + expect(run.getLatestPatchVersion).toHaveBeenCalledWith('1', '27') + }) + test('resolveKubectlVersion() - returns full version unchanged', async () => { + const result = await run.resolveKubectlVersion('v1.27.15') + expect(result).toBe('v1.27.15') + }) + + test('resolveKubectlVersion() - adds v prefix to full version', async () => { + const result = await run.resolveKubectlVersion('1.27.15') + expect(result).toBe('v1.27.15') + }) + test('resolveKubectlVersion() - expands v-prefixed major.minor to latest patch', async () => { + jest.spyOn(run, 'getLatestPatchVersion').mockResolvedValue('v1.27.15') + + const result = await run.resolveKubectlVersion('v1.27') + + expect(result).toBe('v1.27.15') + expect(run.getLatestPatchVersion).toHaveBeenCalledWith('1', '27') }) test('run() - download specified version and set output', async () => { jest.spyOn(core, 'getInput').mockReturnValue('v1.15.5') diff --git a/src/run.ts b/src/run.ts index 92ea198..8c21920 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,12 +1,10 @@ import * as path from 'path' import * as util from 'util' import * as fs from 'fs' -import {Octokit} from '@octokit/rest' import * as semver from 'semver' import * as toolCache from '@actions/tool-cache' +import * as runModule from './run' import * as core from '@actions/core' - -export const octo = new Octokit() import { getkubectlDownloadURL, getKubectlArch, @@ -23,7 +21,7 @@ export async function run() { if (version.toLocaleLowerCase() === 'latest') { version = await getStableKubectlVersion() } else { - version = await resolveKubectlVersion(version, octo) + version = await resolveKubectlVersion(version) } const cachedPath = await downloadKubectl(version) @@ -93,10 +91,32 @@ export async function downloadKubectl(version: string): Promise { fs.chmodSync(kubectlPath, '775') return kubectlPath } -export async function resolveKubectlVersion( - version: string, - octo: Octokit + +export async function getLatestPatchVersion( + major: string, + minor: string ): Promise { + const sourceURL = `https://cdn.dl.k8s.io/release/stable-${major}.${minor}.txt` + try { + const downloadPath = await toolCache.downloadTool(sourceURL) + const latestPatch = fs + .readFileSync(downloadPath, 'utf8') + .toString() + .trim() + if (!latestPatch) { + throw new Error(`No patch version found for ${major}.${minor}`) + } + return latestPatch + } catch (error) { + core.debug(error) + core.warning('GetLatestPatchVersionFailed') + throw new Error( + `Failed to get latest patch version for ${major}.${minor}` + ) + } +} + +export async function resolveKubectlVersion(version: string): Promise { const cleanedVersion = version.trim() /*------ detect "major.minor" only ----------------*/ @@ -109,25 +129,6 @@ export async function resolveKubectlVersion( } const {major, minor} = mmMatch.groups - /* -------------------- fetch recent tags from GitHub ----------------- */ - const resp = await octo.repos.listTags({ - owner: 'kubernetes', - repo: 'kubernetes', - per_page: 100 - }) - - /* -------------------- find newest patch within that line ------------ */ - const wantedPrefix = `${major}.${minor}.` - const newest = resp.data - .map((tag) => tag.name.replace(/^v/, '')) // strip leading v - .filter((v) => v.startsWith(wantedPrefix)) // keep only 1.27.* - .sort(semver.rcompare)[0] // newest first - - if (!newest) { - throw new Error( - `Could not find any ${wantedPrefix}* tag in kubernetes/kubernetes` - ) - } - - return `v${newest}` // always return with leading "v" + // Call the k8s CDN to get the latest patch version for the given major.minor + return await runModule.getLatestPatchVersion(major, minor) }