This commit is contained in:
benjamin 2026-05-20 16:47:32 +00:00 committed by GitHub
commit 09fd320b52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 590 additions and 123 deletions

View file

@ -54,3 +54,32 @@ jobs:
- name: Validate kubectl setup old version
run: python test/validate-kubectl.py 'v1.15.1'
- name: Fetch known SHA256 for v1.30.0 linux/amd64
id: sha
run: |
value=$(curl -fsSL https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl.sha256)
echo "value=$value" >> "$GITHUB_OUTPUT"
- name: Setup kubectl with valid checksum
uses: ./
with:
version: 'v1.30.0'
checksum: ${{ steps.sha.outputs.value }}
- name: Validate kubectl setup with checksum
run: python test/validate-kubectl.py 'v1.30.0'
- name: Setup kubectl with bad checksum (expect failure)
id: badsum
continue-on-error: true
uses: ./
with:
version: 'v1.29.0'
checksum: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
- name: Assert bad-checksum step failed
if: steps.badsum.outcome != 'failure'
run: |
echo "Expected bad-checksum step to fail, but outcome was: ${{ steps.badsum.outcome }}"
exit 1

View file

@ -5,6 +5,14 @@ inputs:
description: 'Version of kubectl'
required: true
default: 'latest'
downloadBaseURL:
description: 'Base URL to download kubectl from (https only). Use for private mirrors.'
required: false
default: 'https://dl.k8s.io'
checksum:
description: 'Expected SHA256 of the kubectl binary. Recommended when overriding downloadBaseURL.'
required: false
default: ''
outputs:
kubectl-path:
description: 'Path to the cached kubectl binary'

View file

@ -1,8 +1,55 @@
import * as os from 'os'
import * as util from 'util'
import * as fs from 'fs'
import * as core from '@actions/core'
import * as toolCache from '@actions/tool-cache'
export const DEFAULT_KUBECTL_BASE_URL = 'https://dl.k8s.io'
export function validateBaseURL(input: string): URL {
let url: URL
try {
url = new URL(input)
} catch {
throw new Error(`Invalid downloadBaseURL: "${input}" is not a valid URL.`)
}
if (url.protocol !== 'https:') {
throw new Error(
`downloadBaseURL must use https://, got "${url.protocol}" in "${input}".`
)
}
if (url.username || url.password) {
throw new Error(
'downloadBaseURL must not contain userinfo (user:pass@host).'
)
}
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
const isLoopback =
host === 'localhost' || host === '127.0.0.1' || host === '::1'
const isLinkLocal = host.startsWith('169.254.')
const isPrivateV4 =
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)
if (isLoopback || isLinkLocal || isPrivateV4) {
throw new Error(
`downloadBaseURL host "${host}" is loopback/link-local/private and is blocked by default.`
)
}
return url
}
export function validateVersion(version: string): void {
if (!/^v?\d+\.\d+\.\d+$/.test(version)) {
throw new Error(
`Invalid kubectl version: "${version}". Expected a value like "v1.30.0".`
)
}
}
export function getKubectlArch(): string {
const arch = os.arch()
if (arch === 'x64') {
@ -11,26 +58,47 @@ export function getKubectlArch(): string {
return arch
}
export function getkubectlDownloadURL(version: string, arch: string): string {
export function getkubectlDownloadURL(
version: string,
arch: string,
baseURL: string = DEFAULT_KUBECTL_BASE_URL
): string {
validateVersion(version)
const url = validateBaseURL(baseURL)
let osDir: string
let file: string
switch (os.type()) {
case 'Linux':
return `https://dl.k8s.io/release/${version}/bin/linux/${arch}/kubectl`
osDir = 'linux'
file = 'kubectl'
break
case 'Darwin':
return `https://dl.k8s.io/release/${version}/bin/darwin/${arch}/kubectl`
osDir = 'darwin'
file = 'kubectl'
break
case 'Windows_NT':
default:
return `https://dl.k8s.io/release/${version}/bin/windows/${arch}/kubectl.exe`
osDir = 'windows'
file = 'kubectl.exe'
break
}
const basePath = url.pathname.replace(/\/+$/, '')
url.pathname = `${basePath}/release/${version}/bin/${osDir}/${arch}/${file}`
return url.toString()
}
export async function getLatestPatchVersion(
major: string,
minor: string
minor: string,
baseURL: string = DEFAULT_KUBECTL_BASE_URL
): Promise<string> {
const version = `${major}.${minor}`
const sourceURL = `https://dl.k8s.io/release/stable-${version}.txt`
const url = validateBaseURL(baseURL)
const basePath = url.pathname.replace(/\/+$/, '')
url.pathname = `${basePath}/release/stable-${version}.txt`
const sourceURL = url.toString()
try {
const downloadPath = await toolCache.downloadTool(sourceURL)
const latestPatch = fs

View file

@ -1,6 +1,7 @@
import {vi, describe, test, expect, beforeEach} from 'vitest'
import * as path from 'path'
import * as util from 'util'
import {Readable} from 'stream'
vi.mock('os')
vi.mock('fs')
@ -14,32 +15,67 @@ vi.mock('@actions/tool-cache', async (importOriginal) => {
}
})
vi.mock('@actions/core')
vi.mock('@actions/http-client', () => {
const get = vi.fn()
return {
HttpClient: vi.fn().mockImplementation(function () {
return {get}
})
}
})
const os = await import('os')
const fs = await import('fs')
const toolCache = await import('@actions/tool-cache')
const core = await import('@actions/core')
const httpClient = await import('@actions/http-client')
const run = await import('./run.js')
const {
DEFAULT_KUBECTL_BASE_URL,
getkubectlDownloadURL,
getKubectlArch,
getExecutableExtension,
getLatestPatchVersion
getLatestPatchVersion,
validateBaseURL,
validateVersion
} = await import('./helpers.js')
function mockInputs(inputs: Record<string, string>) {
vi.mocked(core.getInput).mockImplementation(
(name: string) => inputs[name] ?? ''
)
}
function fakeHttpResponse(opts: {
status: number
body?: string
location?: string
}) {
const body = Readable.from([Buffer.from(opts.body ?? '')])
const message: any = body
message.statusCode = opts.status
message.headers = opts.location ? {location: opts.location} : {}
return {message, readBody: async () => opts.body ?? ''}
}
function mockHttpGet(response: ReturnType<typeof fakeHttpResponse>) {
;(httpClient.HttpClient as any).mockImplementation(function () {
return {get: vi.fn().mockResolvedValue(response)}
})
}
describe('Testing all functions in run file.', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('getExecutableExtension() - return .exe when os is Windows', () => {
vi.mocked(os.type).mockReturnValue('Windows_NT')
expect(getExecutableExtension()).toBe('.exe')
expect(os.type).toHaveBeenCalled()
})
test('getExecutableExtension() - return empty string for non-windows OS', () => {
vi.mocked(os.type).mockReturnValue('Darwin')
expect(getExecutableExtension()).toBe('')
expect(os.type).toHaveBeenCalled()
})
test.each([
['arm', 'arm'],
@ -50,65 +86,153 @@ describe('Testing all functions in run file.', () => {
(osArch, kubectlArch) => {
vi.mocked(os.arch).mockReturnValue(osArch as NodeJS.Architecture)
expect(getKubectlArch()).toBe(kubectlArch)
expect(os.arch).toHaveBeenCalled()
}
)
test.each([['arm'], ['arm64'], ['amd64']])(
'getkubectlDownloadURL() - return the URL to download %s kubectl for Linux',
'getkubectlDownloadURL() - default base URL, Linux %s',
(arch) => {
vi.mocked(os.type).mockReturnValue('Linux')
const kubectlLinuxUrl = util.format(
const expected = util.format(
'https://dl.k8s.io/release/v1.15.0/bin/linux/%s/kubectl',
arch
)
expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(kubectlLinuxUrl)
expect(os.type).toHaveBeenCalled()
expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(expected)
}
)
test.each([['arm'], ['arm64'], ['amd64']])(
'getkubectlDownloadURL() - return the URL to download %s kubectl for Darwin',
'getkubectlDownloadURL() - default base URL, Darwin %s',
(arch) => {
vi.mocked(os.type).mockReturnValue('Darwin')
const kubectlDarwinUrl = util.format(
const expected = util.format(
'https://dl.k8s.io/release/v1.15.0/bin/darwin/%s/kubectl',
arch
)
expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(kubectlDarwinUrl)
expect(os.type).toHaveBeenCalled()
expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(expected)
}
)
test.each([['arm'], ['arm64'], ['amd64']])(
'getkubectlDownloadURL() - return the URL to download %s kubectl for Windows',
'getkubectlDownloadURL() - default base URL, Windows %s',
(arch) => {
vi.mocked(os.type).mockReturnValue('Windows_NT')
const kubectlWindowsUrl = util.format(
const expected = util.format(
'https://dl.k8s.io/release/v1.15.0/bin/windows/%s/kubectl.exe',
arch
)
expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(kubectlWindowsUrl)
expect(os.type).toHaveBeenCalled()
expect(getkubectlDownloadURL('v1.15.0', arch)).toBe(expected)
}
)
test('getStableKubectlVersion() - download stable version file, read version and return it', async () => {
test('getkubectlDownloadURL() - custom base URL', () => {
vi.mocked(os.type).mockReturnValue('Linux')
expect(
getkubectlDownloadURL('v1.15.0', 'amd64', 'https://mirror.example.com')
).toBe(
'https://mirror.example.com/release/v1.15.0/bin/linux/amd64/kubectl'
)
})
test('getkubectlDownloadURL() - strips trailing slash from base URL', () => {
vi.mocked(os.type).mockReturnValue('Darwin')
expect(
getkubectlDownloadURL(
'v1.15.0',
'arm64',
'https://mirror.example.com/'
)
).toBe(
'https://mirror.example.com/release/v1.15.0/bin/darwin/arm64/kubectl'
)
})
test('getkubectlDownloadURL() - base URL with path prefix', () => {
vi.mocked(os.type).mockReturnValue('Linux')
expect(
getkubectlDownloadURL(
'v1.30.0',
'amd64',
'https://mirror.example.com/k8s'
)
).toBe(
'https://mirror.example.com/k8s/release/v1.30.0/bin/linux/amd64/kubectl'
)
})
test.each([
['https://mirror.example.com'],
['https://mirror.example.com:8443'], // non-standard port
['https://172.32.0.1'] // outside RFC1918 172.16/12 range
])('validateBaseURL() - accepts %s', (input) => {
expect(() => validateBaseURL(input)).not.toThrow()
})
test.each([
['not a url', /not a valid URL/],
['http://mirror.example.com', /must use https/],
['https://user:pass@mirror.example.com', /userinfo/],
['https://localhost', /loopback\/link-local\/private/],
['https://127.0.0.1', /loopback\/link-local\/private/],
['https://[::1]', /loopback\/link-local\/private/], // caught a real bug
['https://169.254.169.254', /loopback\/link-local\/private/], // IMDS
['https://10.0.0.5', /loopback\/link-local\/private/], // RFC1918
['https://192.168.1.1', /loopback\/link-local\/private/], // RFC1918
['https://172.16.0.1', /loopback\/link-local\/private/], // RFC1918 low edge
['https://172.31.255.254', /loopback\/link-local\/private/] // RFC1918 high edge
])('validateBaseURL() - rejects %s', (input, expected) => {
expect(() => validateBaseURL(input)).toThrow(expected)
})
test.each([['v1.30.0'], ['1.30.0']])(
'validateVersion() - accepts %s',
(input) => {
expect(() => validateVersion(input)).not.toThrow()
}
)
test.each([
[''],
['v1.30.0\n'], // trailing newline
['v1.2.3.4'], // extra segment
[' v1.30.0'] // leading whitespace
])('validateVersion() - rejects %s', (input) => {
expect(() => validateVersion(input)).toThrow(/Invalid kubectl version/)
})
test('getStableKubectlVersion() - default base URL', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4')
expect(await run.getStableKubectlVersion()).toBe('v1.20.4')
expect(toolCache.downloadTool).toHaveBeenCalled()
expect(fs.readFileSync).toHaveBeenCalledWith('pathToTool', 'utf8')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://dl.k8s.io/release/stable.txt'
)
})
test('getStableKubectlVersion() - return default v1.15.0 if version read is empty', async () => {
test('getStableKubectlVersion() - honours custom base URL (air-gap fix)', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4')
expect(
await run.getStableKubectlVersion('https://mirror.example.com')
).toBe('v1.20.4')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://mirror.example.com/release/stable.txt'
)
})
test('getStableKubectlVersion() - path-prefixed mirror composes correctly', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4')
await run.getStableKubectlVersion('https://mirror.example.com/k8s/')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://mirror.example.com/k8s/release/stable.txt'
)
})
test('getStableKubectlVersion() - falls back to v1.15.0 on empty file', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('')
expect(await run.getStableKubectlVersion()).toBe('v1.15.0')
expect(toolCache.downloadTool).toHaveBeenCalled()
expect(fs.readFileSync).toHaveBeenCalledWith('pathToTool', 'utf8')
})
test('getStableKubectlVersion() - return default v1.15.0 if unable to download file', async () => {
test('getStableKubectlVersion() - falls back to v1.15.0 on download failure', async () => {
vi.mocked(toolCache.downloadTool).mockRejectedValue('Unable to download.')
expect(await run.getStableKubectlVersion()).toBe('v1.15.0')
expect(toolCache.downloadTool).toHaveBeenCalled()
})
test('downloadKubectl() - download kubectl, add it to toolCache and return path to it', async () => {
test('downloadKubectl() - download and cache, default base URL', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool')
@ -117,32 +241,28 @@ describe('Testing all functions in run file.', () => {
expect(await run.downloadKubectl('v1.15.0')).toBe(
path.join('pathToCachedTool', 'kubectl.exe')
)
expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.15.0')
expect(toolCache.downloadTool).toHaveBeenCalled()
expect(toolCache.cacheFile).toHaveBeenCalled()
expect(os.type).toHaveBeenCalled()
expect(fs.chmodSync).toHaveBeenCalledWith(
path.join('pathToCachedTool', 'kubectl.exe'),
'775'
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://dl.k8s.io/release/v1.15.0/bin/windows/amd64/kubectl.exe'
)
})
test('downloadKubectl() - throw DownloadKubectlFailed error when unable to download kubectl', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockRejectedValue(
'Unable to download kubectl.'
test('downloadKubectl() - rejects invalid version (path traversal)', async () => {
await expect(run.downloadKubectl('../etc')).rejects.toThrow(
/Invalid kubectl version/
)
})
test('downloadKubectl() - throws DownloadKubectlFailed on generic error', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockRejectedValue('boom')
await expect(run.downloadKubectl('v1.15.0')).rejects.toThrow(
'DownloadKubectlFailed'
)
expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.15.0')
expect(toolCache.downloadTool).toHaveBeenCalled()
})
test('downloadKubectl() - throw kubectl not found error when receive 404 response', async () => {
test('downloadKubectl() - 404 maps to "not found" message', async () => {
const kubectlVersion = 'v1.15.0'
const arch = 'arm128'
const arch = 'arm64'
vi.mocked(os.arch).mockReturnValue(arch as NodeJS.Architecture)
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockImplementation((_) => {
vi.mocked(toolCache.downloadTool).mockImplementation(() => {
throw new toolCache.HTTPError(404)
})
await expect(run.downloadKubectl(kubectlVersion)).rejects.toThrow(
@ -152,114 +272,255 @@ describe('Testing all functions in run file.', () => {
arch
)
)
expect(os.arch).toHaveBeenCalled()
expect(toolCache.find).toHaveBeenCalledWith('kubectl', kubectlVersion)
expect(toolCache.downloadTool).toHaveBeenCalled()
})
test('downloadKubectl() - return path to existing cache of kubectl', async () => {
vi.mocked(core.getInput).mockImplementation(() => 'v1.15.5')
test('downloadKubectl() - returns existing cache without redownloading', async () => {
vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Windows_NT')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
expect(await run.downloadKubectl('v1.15.0')).toBe(
path.join('pathToCachedTool', 'kubectl.exe')
)
expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.15.0')
expect(os.type).toHaveBeenCalled()
expect(fs.chmodSync).toHaveBeenCalledWith(
path.join('pathToCachedTool', 'kubectl.exe'),
'775'
expect(toolCache.downloadTool).not.toHaveBeenCalled()
})
test('downloadKubectl() - rejects malformed checksum input', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never)
await expect(
run.downloadKubectl('v1.15.0', DEFAULT_KUBECTL_BASE_URL, 'not-a-hash')
).rejects.toThrow(/Invalid checksum input/)
})
test('downloadKubectl() - rejects checksum mismatch', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never)
const wrong = 'a'.repeat(64)
await expect(
run.downloadKubectl('v1.15.0', DEFAULT_KUBECTL_BASE_URL, wrong)
).rejects.toThrow(/Checksum mismatch/)
})
test('downloadKubectl() - accepts matching checksum', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
// sha256('hi') = 8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never)
const correct =
'8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4'
await expect(
run.downloadKubectl('v1.15.0', DEFAULT_KUBECTL_BASE_URL, correct)
).resolves.toBe(path.join('pathToCachedTool', 'kubectl'))
})
test.each([[301], [302], [307], [308]])(
'downloadKubectl() - custom mirror: rejects %i redirect (SSRF guard)',
async (status) => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
mockHttpGet(
fakeHttpResponse({
status,
location: 'http://169.254.169.254/latest/meta-data/'
})
)
await expect(
run.downloadKubectl('v1.15.0', 'https://mirror.example.com')
).rejects.toThrow('DownloadKubectlFailed')
expect(toolCache.downloadTool).not.toHaveBeenCalled()
}
)
test('downloadKubectl() - custom mirror: non-200/non-404 (500) fails', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
mockHttpGet(fakeHttpResponse({status: 500}))
await expect(
run.downloadKubectl('v1.15.0', 'https://mirror.example.com')
).rejects.toThrow('DownloadKubectlFailed')
expect(toolCache.downloadTool).not.toHaveBeenCalled()
})
test('downloadKubectl() - custom mirror: 404 maps to "not found"', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(os.type).mockReturnValue('Linux')
const arch = 'amd64'
vi.mocked(os.arch).mockReturnValue('x64')
mockHttpGet(fakeHttpResponse({status: 404}))
await expect(
run.downloadKubectl('v1.15.0', 'https://mirror.example.com')
).rejects.toThrow(
util.format("Kubectl '%s' for '%s' arch not found.", 'v1.15.0', arch)
)
expect(toolCache.downloadTool).not.toHaveBeenCalled()
})
test('getLatestPatchVersion() - download and return latest patch version', async () => {
test('downloadKubectl() - custom mirror: 200 streams body to temp file and caches', async () => {
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(os.tmpdir).mockReturnValue('/tmp')
vi.mocked(fs.chmodSync).mockImplementation(() => {})
vi.mocked(fs.writeFileSync).mockImplementation(() => {})
mockHttpGet(fakeHttpResponse({status: 200, body: 'kubectl-bytes'}))
await expect(
run.downloadKubectl('v1.15.0', 'https://mirror.example.com')
).resolves.toBe(path.join('pathToCachedTool', 'kubectl'))
expect(toolCache.downloadTool).not.toHaveBeenCalled()
expect(toolCache.cacheFile).toHaveBeenCalled()
expect(fs.writeFileSync).toHaveBeenCalled()
})
test('getLatestPatchVersion() - default base URL', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15')
const result = await getLatestPatchVersion('1', '27')
expect(result).toBe('v1.27.15')
expect(await getLatestPatchVersion('1', '27')).toBe('v1.27.15')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://dl.k8s.io/release/stable-1.27.txt'
)
})
test('getLatestPatchVersion() - throw error when patch version is empty', async () => {
test('getLatestPatchVersion() - honours custom base URL', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15')
expect(
await getLatestPatchVersion('1', '27', 'https://mirror.example.com')
).toBe('v1.27.15')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://mirror.example.com/release/stable-1.27.txt'
)
})
test('getLatestPatchVersion() - path-prefixed mirror composes correctly', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15')
await getLatestPatchVersion('1', '27', 'https://mirror.example.com/k8s')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://mirror.example.com/k8s/release/stable-1.27.txt'
)
})
test('getLatestPatchVersion() - throws on empty file', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('')
await expect(getLatestPatchVersion('1', '27')).rejects.toThrow(
'Failed to get latest patch version for 1.27'
)
})
test('getLatestPatchVersion() - throws on download failure', async () => {
vi.mocked(toolCache.downloadTool).mockRejectedValue(new Error('Network'))
await expect(getLatestPatchVersion('1', '27')).rejects.toThrow(
'Failed to get latest patch version for 1.27'
)
})
test('getLatestPatchVersion() - throw error when download fails', async () => {
vi.mocked(toolCache.downloadTool).mockRejectedValue(
new Error('Network error')
)
await expect(getLatestPatchVersion('1', '27')).rejects.toThrow(
'Failed to get latest patch version for 1.27'
)
})
test('resolveKubectlVersion() - expands major.minor to latest patch', async () => {
test('resolveKubectlVersion() - expands major.minor', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15')
const result = await run.resolveKubectlVersion('1.27')
expect(result).toBe('v1.27.15')
expect(await run.resolveKubectlVersion('1.27')).toBe('v1.27.15')
})
test('resolveKubectlVersion() - returns full version unchanged', async () => {
const result = await run.resolveKubectlVersion('v1.27.15')
expect(result).toBe('v1.27.15')
expect(await run.resolveKubectlVersion('v1.27.15')).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() - adds v prefix', async () => {
expect(await run.resolveKubectlVersion('1.27.15')).toBe('v1.27.15')
})
test('resolveKubectlVersion() - expands v-prefixed major.minor to latest patch', async () => {
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15')
const result = await run.resolveKubectlVersion('v1.27')
expect(result).toBe('v1.27.15')
})
test('run() - download specified version and set output', async () => {
vi.mocked(core.getInput).mockReturnValue('v1.15.5')
test('run() - reads version, downloadBaseURL, checksum inputs; defaults applied', async () => {
mockInputs({version: 'v1.15.5'})
vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Windows_NT')
vi.mocked(fs.chmodSync).mockImplementation()
vi.mocked(core.addPath).mockImplementation()
vi.spyOn(console, 'log').mockImplementation()
vi.mocked(core.setOutput).mockImplementation()
expect(await run.run()).toBeUndefined()
await expect(run.run()).resolves.toBeUndefined()
expect(core.getInput).toHaveBeenCalledWith('version', {required: true})
expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool')
expect(core.getInput).toHaveBeenCalledWith('downloadBaseURL', {
required: false
})
expect(core.getInput).toHaveBeenCalledWith('checksum', {required: false})
// Default base URL: no audit notice should fire.
expect(core.notice).not.toHaveBeenCalled()
expect(core.setOutput).toHaveBeenCalledWith(
'kubectl-path',
path.join('pathToCachedTool', 'kubectl.exe')
)
})
test('run() - get latest version, download it and set output', async () => {
vi.mocked(core.getInput).mockReturnValue('latest')
test('run() - latest + custom mirror routes stable.txt through the mirror', async () => {
mockInputs({
version: 'latest',
downloadBaseURL: 'https://mirror.example.com'
})
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4')
vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Windows_NT')
vi.mocked(fs.chmodSync).mockImplementation()
vi.mocked(core.addPath).mockImplementation()
vi.spyOn(console, 'log').mockImplementation()
vi.mocked(core.setOutput).mockImplementation()
expect(await run.run()).toBeUndefined()
await expect(run.run()).resolves.toBeUndefined()
// The fix: stable.txt comes from the custom mirror, not dl.k8s.io.
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://dl.k8s.io/release/stable.txt'
'https://mirror.example.com/release/stable.txt'
)
expect(core.getInput).toHaveBeenCalledWith('version', {required: true})
expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool')
expect(core.setOutput).toHaveBeenCalledWith(
'kubectl-path',
path.join('pathToCachedTool', 'kubectl.exe')
// Audit notice fires for any non-default base URL.
expect(core.notice).toHaveBeenCalledWith(
expect.stringContaining('https://mirror.example.com')
)
// No checksum + custom mirror => loud warning.
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('checksum')
)
})
test('run() - empty/whitespace downloadBaseURL falls back to default', async () => {
mockInputs({version: 'v1.15.5', downloadBaseURL: ' '})
vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Windows_NT')
vi.mocked(fs.chmodSync).mockImplementation()
vi.mocked(core.addPath).mockImplementation()
vi.mocked(core.setOutput).mockImplementation()
await expect(run.run()).resolves.toBeUndefined()
// Default fallback => no audit notice.
expect(core.notice).not.toHaveBeenCalled()
})
test('run() - invalid downloadBaseURL (http) fails fast', async () => {
mockInputs({version: 'v1.15.5', downloadBaseURL: 'http://insecure'})
await expect(run.run()).rejects.toThrow(/must use https/)
})
test('run() - uppercase checksum input is normalized and accepted', async () => {
// sha256('hi') = 8f43...aa4 — supplied uppercase to prove run() lowercases it
// before validateChecksum's strict /^[a-f0-9]{64}$/ regex sees it.
const correctLower =
'8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4'
mockInputs({
version: 'v1.15.5',
checksum: correctLower.toUpperCase()
})
vi.mocked(toolCache.find).mockReturnValue('')
vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool')
vi.mocked(toolCache.cacheFile).mockResolvedValue('pathToCachedTool')
vi.mocked(os.type).mockReturnValue('Linux')
vi.mocked(os.arch).mockReturnValue('x64')
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('hi') as never)
vi.mocked(fs.chmodSync).mockImplementation()
vi.mocked(core.addPath).mockImplementation()
vi.mocked(core.setOutput).mockImplementation()
await expect(run.run()).resolves.toBeUndefined()
})
})

View file

@ -1,27 +1,58 @@
import * as path from 'path'
import * as util from 'util'
import * as os from 'os'
import * as fs from 'fs'
import * as crypto from 'crypto'
import {buffer as readStreamToBuffer} from 'stream/consumers'
import * as toolCache from '@actions/tool-cache'
import * as core from '@actions/core'
import {HttpClient} from '@actions/http-client'
import {
DEFAULT_KUBECTL_BASE_URL,
getkubectlDownloadURL,
getKubectlArch,
getExecutableExtension,
getLatestPatchVersion
getLatestPatchVersion,
validateBaseURL,
validateVersion
} from './helpers.js'
const kubectlToolName = 'kubectl'
const stableKubectlVersion = 'v1.15.0'
const stableVersionUrl = 'https://dl.k8s.io/release/stable.txt'
export async function run() {
let version = core.getInput('version', {required: true})
if (version.toLocaleLowerCase() === 'latest') {
version = await getStableKubectlVersion()
} else {
version = await resolveKubectlVersion(version)
const rawBaseURL = core.getInput('downloadBaseURL', {required: false}).trim()
const downloadBaseURL = rawBaseURL || DEFAULT_KUBECTL_BASE_URL
validateBaseURL(downloadBaseURL)
const expectedChecksum = core
.getInput('checksum', {required: false})
.trim()
.toLowerCase()
if (downloadBaseURL !== DEFAULT_KUBECTL_BASE_URL) {
core.notice(
`kubectl will be downloaded from a custom mirror: ${downloadBaseURL}`
)
if (!expectedChecksum) {
core.warning(
'Custom downloadBaseURL set without a `checksum` input; downloaded binary will not be integrity-verified.'
)
}
}
const cachedPath = await downloadKubectl(version)
if (version.toLocaleLowerCase() === 'latest') {
version = await getStableKubectlVersion(downloadBaseURL)
} else {
version = await resolveKubectlVersion(version, downloadBaseURL)
}
const cachedPath = await downloadKubectl(
version,
downloadBaseURL,
expectedChecksum
)
core.addPath(path.dirname(cachedPath))
@ -31,7 +62,14 @@ export async function run() {
core.setOutput('kubectl-path', cachedPath)
}
export async function getStableKubectlVersion(): Promise<string> {
export async function getStableKubectlVersion(
baseURL: string = DEFAULT_KUBECTL_BASE_URL
): Promise<string> {
const url = validateBaseURL(baseURL)
const basePath = url.pathname.replace(/\/+$/, '')
url.pathname = `${basePath}/release/stable.txt`
const stableVersionUrl = url.toString()
return toolCache.downloadTool(stableVersionUrl).then(
(downloadPath) => {
let version = fs.readFileSync(downloadPath, 'utf8').toString().trim()
@ -48,15 +86,24 @@ export async function getStableKubectlVersion(): Promise<string> {
)
}
export async function downloadKubectl(version: string): Promise<string> {
export async function downloadKubectl(
version: string,
baseURL: string = DEFAULT_KUBECTL_BASE_URL,
expectedChecksum: string = ''
): Promise<string> {
validateVersion(version)
let cachedToolpath = toolCache.find(kubectlToolName, version)
let kubectlDownloadPath = ''
const arch = getKubectlArch()
if (!cachedToolpath) {
const downloadURL = getkubectlDownloadURL(version, arch, baseURL)
try {
kubectlDownloadPath = await toolCache.downloadTool(
getkubectlDownloadURL(version, arch)
)
if (baseURL === DEFAULT_KUBECTL_BASE_URL) {
kubectlDownloadPath = await toolCache.downloadTool(downloadURL)
} else {
kubectlDownloadPath = await secureDownload(downloadURL)
}
} catch (exception) {
if (
exception instanceof toolCache.HTTPError &&
@ -74,6 +121,10 @@ export async function downloadKubectl(version: string): Promise<string> {
}
}
if (expectedChecksum) {
verifyChecksum(kubectlDownloadPath, expectedChecksum)
}
cachedToolpath = await toolCache.cacheFile(
kubectlDownloadPath,
kubectlToolName + getExecutableExtension(),
@ -90,7 +141,57 @@ export async function downloadKubectl(version: string): Promise<string> {
return kubectlPath
}
export async function resolveKubectlVersion(version: string): Promise<string> {
async function secureDownload(downloadURL: string): Promise<string> {
const client = new HttpClient('setup-kubectl', [], {
allowRedirects: false
})
const response = await client.get(downloadURL)
const status = response.message.statusCode
if (status && status >= 300 && status < 400) {
const location = response.message.headers['location']
response.message.resume()
throw new Error(
`Refusing redirect from custom downloadBaseURL (status ${status} -> ${location}).`
)
}
if (status === 404) {
response.message.resume()
throw new toolCache.HTTPError(404)
}
if (status !== 200) {
response.message.resume()
throw new Error(`Download failed with status ${status}`)
}
const tmpDir = process.env['RUNNER_TEMP'] || os.tmpdir()
const tmpFile = path.join(tmpDir, `kubectl-${crypto.randomUUID()}`)
const body = await readStreamToBuffer(response.message)
fs.writeFileSync(tmpFile, body)
return tmpFile
}
function verifyChecksum(filePath: string, expected: string): void {
if (!/^[a-f0-9]{64}$/.test(expected)) {
throw new Error(
`Invalid checksum input: expected a 64-character hex SHA256 string.`
)
}
const actual = crypto
.createHash('sha256')
.update(fs.readFileSync(filePath))
.digest('hex')
if (actual !== expected) {
throw new Error(
`Checksum mismatch for downloaded kubectl. Expected ${expected}, got ${actual}.`
)
}
}
export async function resolveKubectlVersion(
version: string,
baseURL: string = DEFAULT_KUBECTL_BASE_URL
): Promise<string> {
const cleanedVersion = version.trim()
const versionMatch = cleanedVersion.match(
/^v?(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?$/
@ -111,6 +212,6 @@ export async function resolveKubectlVersion(version: string): Promise<string> {
: `v${cleanedVersion}`
}
// Patch version is missing, fetch the latest
return await getLatestPatchVersion(major, minor)
// Patch version is missing, fetch the latest from the (possibly custom) mirror.
return await getLatestPatchVersion(major, minor, baseURL)
}