diff --git a/README.md b/README.md index d7a72981..97a42632 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,27 @@ Acceptable values are latest or any semantic version string like `v1.15.0`. Use id: install ``` +Alternatively, read the kubectl version from an +[asdf/mise `.tool-versions` file](https://asdf-vm.com/manage/configuration.html): + +```yaml +- uses: azure/setup-kubectl@v5 + with: + version-file: '.tool-versions' + id: install +``` + +The kubectl entry may use `major.minor`, `major.minor.patch`, an optional `v` +prefix, or `latest`: + +```text +kubectl 1.31.2 +``` + +When both inputs are set, a specific `version` other than `latest` takes +precedence and the action emits a warning. The default `version: latest` allows +`version-file` to select the version. + Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-kubectl/blob/main/action.yml # Contributing diff --git a/action.yml b/action.yml index 8e286001..8a22907c 100644 --- a/action.yml +++ b/action.yml @@ -3,8 +3,11 @@ description: 'Install a specific version of kubectl binary. Acceptable values ar inputs: version: description: 'Version of kubectl' - required: true + required: false default: 'latest' + version-file: + description: 'Path to a .tool-versions (asdf/mise) file to read the kubectl version from. A specific version other than latest takes precedence.' + required: false downloadBaseURL: description: 'Base URL to download kubectl from (https only). Use for private mirrors.' required: false diff --git a/src/helpers.ts b/src/helpers.ts index f2e8b1e6..bd1cc235 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -304,3 +304,72 @@ export function getExecutableExtension(): string { } return '' } + +export function parseToolVersionsFile(filePath: string): string { + const maxBytes = 64 * 1024 + const pathStats = fs.lstatSync(filePath, {bigint: true}) + if (pathStats.isSymbolicLink() || !pathStats.isFile()) { + throw new Error( + `The version-file '${filePath}' must be a regular file and not a symbolic link.` + ) + } + if (pathStats.size > BigInt(maxBytes)) { + throw new Error( + `The version-file '${filePath}' exceeds the ${maxBytes}-byte size limit.` + ) + } + + const noFollow = fs.constants.O_NOFOLLOW ?? 0 + // A FIFO swapped in after lstat must not block open before fstat can reject it. + // O_NONBLOCK has no effect on regular files; these flags are POSIX-only. + const nonBlock = fs.constants.O_NONBLOCK ?? 0 + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonBlock) + let content: string + try { + const fileStats = fs.fstatSync(fd, {bigint: true}) + if (!fileStats.isFile()) { + throw new Error( + `The version-file '${filePath}' must be a regular file and not a symbolic link.` + ) + } + if (pathStats.dev !== fileStats.dev || pathStats.ino !== fileStats.ino) { + throw new Error( + `The version-file '${filePath}' changed while it was being opened.` + ) + } + + const buffer = Buffer.alloc(maxBytes + 1) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const count = fs.readSync( + fd, + buffer, + bytesRead, + buffer.length - bytesRead, + null + ) + if (count === 0) break + bytesRead += count + } + if (bytesRead > maxBytes) { + throw new Error( + `The version-file '${filePath}' exceeds the ${maxBytes}-byte size limit.` + ) + } + content = buffer.toString('utf8', 0, bytesRead) + } finally { + fs.closeSync(fd) + } + + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (trimmed.startsWith('#') || trimmed === '') continue + const [tool, version] = trimmed.split(/\s+/) + if (tool === 'kubectl' && version) { + return version + } + } + throw new Error( + `Could not find a kubectl entry in tool-versions file: ${filePath}` + ) +} diff --git a/src/run.test.ts b/src/run.test.ts index 429b7f4d..a7de79a0 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -45,6 +45,7 @@ const { getExecutableExtension, getLatestPatchVersion, isDefaultBaseURL, + parseToolVersionsFile, secureDownload, validateBaseURL, validateVersion @@ -56,6 +57,20 @@ function mockInputs(inputs: Record) { ) } +function mockVersionFileContent(content: string) { + const source = Buffer.from(content) + let offset = 0 + vi.mocked(fs.readSync).mockImplementation( + (_fd, buffer, bufferOffset, length) => { + const count = Math.min(length, source.length - offset) + if (count <= 0) return 0 + source.copy(buffer as Buffer, bufferOffset, offset, offset + count) + offset += count + return count + } + ) +} + function fakeHttpResponse(opts: { status: number body?: string @@ -108,6 +123,19 @@ describe('Testing all functions in run file.', () => { vi.mocked(fs.openSync).mockReturnValue(3 as never) vi.mocked(fs.writeSync).mockReturnValue(0 as never) vi.mocked(fs.closeSync).mockImplementation(() => {}) + vi.mocked(fs.fstatSync).mockReturnValue({ + dev: 1n, + ino: 1n, + isFile: () => true + } as fs.BigIntStats) + vi.mocked(fs.readSync).mockReturnValue(0) + vi.mocked(fs.lstatSync).mockReturnValue({ + dev: 1n, + ino: 1n, + isSymbolicLink: () => false, + isFile: () => true, + size: 1024n + } as fs.BigIntStats) }) test('getExecutableExtension() - return .exe when os is Windows', () => { @@ -798,7 +826,7 @@ describe('Testing all functions in run file.', () => { vi.mocked(core.setOutput).mockImplementation() await expect(run.run()).resolves.toBeUndefined() - expect(core.getInput).toHaveBeenCalledWith('version', {required: true}) + expect(core.getInput).toHaveBeenCalledWith('version') expect(core.getInput).toHaveBeenCalledWith('downloadBaseURL', { required: false }) @@ -957,4 +985,229 @@ describe('Testing all functions in run file.', () => { // And the resolved version must be cached/looked up under v1.27.15. expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') }) + + test('parseToolVersionsFile() - returns kubectl version', () => { + mockVersionFileContent('kubectl 1.27.15\nnode 20.0.0\n') + expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') + expect(fs.openSync).toHaveBeenCalledWith( + '.tool-versions', + fs.constants.O_RDONLY | + (fs.constants.O_NOFOLLOW ?? 0) | + (fs.constants.O_NONBLOCK ?? 0) + ) + expect(fs.lstatSync).toHaveBeenCalledWith('.tool-versions', { + bigint: true + }) + expect(fs.fstatSync).toHaveBeenCalledWith(3, {bigint: true}) + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - ignores comments and blank lines', () => { + mockVersionFileContent('# comment\n\nkubectl 1.27.15\n') + expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') + }) + + test('parseToolVersionsFile() - throws when kubectl entry is absent', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + dev: 1n, + ino: 1n, + isSymbolicLink: () => false, + isFile: () => true, + size: 34n + } as fs.BigIntStats) + mockVersionFileContent('node 20.0.0\npython 3.11.0\n') + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'Could not find a kubectl entry in tool-versions file: .tool-versions' + ) + }) + + test('parseToolVersionsFile() - rejects symbolic links', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => true, + isFile: () => true, + size: 16 + } as fs.Stats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'must be a regular file and not a symbolic link' + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + test('parseToolVersionsFile() - rejects non-regular files', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + isFile: () => false, + size: 0 + } as fs.Stats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'must be a regular file and not a symbolic link' + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + test('parseToolVersionsFile() - rejects files larger than 64 KiB', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + isFile: () => true, + size: 64 * 1024 + 1 + } as fs.Stats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'exceeds the 65536-byte size limit' + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + test('parseToolVersionsFile() - rejects a file replaced between check and open', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + dev: 1n, + ino: 10n, + isSymbolicLink: () => false, + isFile: () => true, + size: 16n + } as fs.BigIntStats) + vi.mocked(fs.fstatSync).mockReturnValue({ + dev: 1n, + ino: 11n, + isFile: () => true + } as fs.BigIntStats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'changed while it was being opened' + ) + expect(fs.readSync).not.toHaveBeenCalled() + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - rejects growth beyond 64 KiB after opening', () => { + mockVersionFileContent('x'.repeat(64 * 1024 + 1)) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'exceeds the 65536-byte size limit' + ) + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - accepts exactly 64 KiB', () => { + mockVersionFileContent(`kubectl 1.27.15\n${' '.repeat(64 * 1024 - 17)}`) + + expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - closes the descriptor when reading fails', () => { + vi.mocked(fs.readSync).mockImplementation(() => { + throw new Error('read failed') + }) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'read failed' + ) + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('run() - uses version-file to determine kubectl version', async () => { + mockInputs({ + version: '', + 'version-file': '.tool-versions' + }) + mockVersionFileContent('kubectl 1.27.15\n') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(fs.openSync).toHaveBeenCalledWith( + '.tool-versions', + fs.constants.O_RDONLY | + (fs.constants.O_NOFOLLOW ?? 0) | + (fs.constants.O_NONBLOCK ?? 0) + ) + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') + expect(core.setOutput).toHaveBeenCalledWith( + 'kubectl-path', + path.join('pathToCachedTool', 'kubectl') + ) + }) + + test('run() - expands a major.minor version from version-file', async () => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent('kubectl 1.27\n') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToStableTxt') + vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://dl.k8s.io/release/stable-1.27.txt' + ) + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') + }) + + test('run() - supports latest from version-file', async () => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent('kubectl latest\n') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToStableTxt') + vi.mocked(fs.readFileSync).mockReturnValue('v1.31.0') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://dl.k8s.io/release/stable.txt' + ) + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.31.0') + }) + + test('run() - preserves a v-prefixed version from version-file', async () => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent('kubectl v1.27.15\n') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') + expect(toolCache.downloadTool).not.toHaveBeenCalled() + }) + + test.each(['../etc', '1.27%2Ffoo', '1.27.0;cmd'])( + 'run() - rejects malformed version-file value %s before download', + async (value) => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent(`kubectl ${value}\n`) + + await expect(run.run()).rejects.toThrow('Invalid version format') + + expect(toolCache.find).not.toHaveBeenCalled() + expect(toolCache.downloadTool).not.toHaveBeenCalled() + } + ) + + test('run() - explicit version takes precedence over version-file', async () => { + mockInputs({ + version: '1.30.0', + 'version-file': '.tool-versions' + }) + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(core.warning).toHaveBeenCalledWith( + "Both 'version' and 'version-file' inputs are specified; using 'version'." + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.30.0') + }) }) diff --git a/src/run.ts b/src/run.ts index f8b40005..1bbb7d11 100644 --- a/src/run.ts +++ b/src/run.ts @@ -12,6 +12,7 @@ import { getLatestPatchVersion, isDefaultBaseURL, normalizeBaseURL, + parseToolVersionsFile, secureDownload, validateBaseURL } from './helpers.js' @@ -20,7 +21,21 @@ const kubectlToolName = 'kubectl' const stableKubectlVersion = 'v1.15.0' export async function run() { - let version = core.getInput('version', {required: true}) + const versionFile = core.getInput('version-file') + let version = core.getInput('version') + + if (versionFile) { + if (version && version.trim().toLowerCase() !== 'latest') { + core.warning( + "Both 'version' and 'version-file' inputs are specified; using 'version'." + ) + } else { + version = parseToolVersionsFile(versionFile) + } + } + if (!version) { + version = 'latest' + } const rawBaseURL = core.getInput('downloadBaseURL', {required: false}).trim() const downloadBaseURL = rawBaseURL || DEFAULT_KUBECTL_BASE_URL diff --git a/src/version-file.test.ts b/src/version-file.test.ts new file mode 100644 index 00000000..27bdef0c --- /dev/null +++ b/src/version-file.test.ts @@ -0,0 +1,67 @@ +import {test, expect} from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {spawnSync} from 'node:child_process' +import {parseToolVersionsFile} from './helpers.js' + +test('version-file reads a real regular file', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kubectl-version-')) + try { + const file = path.join(dir, '.tool-versions') + fs.writeFileSync(file, 'kubectl 1.27.15\n') + expect(parseToolVersionsFile(file)).toBe('1.27.15') + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } +}) + +test.skipIf(process.platform === 'win32')( + 'version-file rejects a FIFO swapped in after lstat without blocking', + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kubectl-fifo-')) + try { + const file = path.join(dir, '.tool-versions') + fs.writeFileSync(file, 'kubectl 1.27.15\n') + // Isolate a potentially blocking open in a child with a hard timeout. + // Replace only lstat: open, fstat and the FIFO are real OS operations. + const script = ` + const fs = require('node:fs'); + const {execFileSync} = require('node:child_process'); + const {syncBuiltinESMExports} = require('node:module'); + const file = ${JSON.stringify(file)}; + const lstat = fs.lstatSync; + fs.lstatSync = function(p, options) { + const stats = lstat(p, options); + if (p === file) { + fs.unlinkSync(file); + execFileSync('mkfifo', [file]); + } + return stats; + }; + syncBuiltinESMExports(); + import(${JSON.stringify(new URL('./helpers.ts', import.meta.url).href)}) + .then(({parseToolVersionsFile}) => { + try { + parseToolVersionsFile(file); + process.exitCode = 1; + } catch (error) { + console.log(error.message); + process.exitCode = /must be a regular file/.test(error.message) ? 0 : 1; + } + }).catch(error => { console.error(error); process.exitCode = 1; }); + ` + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', '-e', script], + {encoding: 'utf8', timeout: 5000} + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('must be a regular file') + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }, + 10000 +)