diff --git a/action.yml b/action.yml index 2687b5ed..56379671 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,16 @@ inputs: tls-key: description: 'Base64 encoded TLS private key (PEM format)' required: false + use-invoke-command: + description: 'Set to true to use az aks command invoke for private clusters (recommended for GitHub-hosted runners with private AKS)' + required: false + default: 'false' + cluster-resource-group: + description: 'AKS cluster resource group (required when use-invoke-command is true)' + required: false + cluster-name: + description: 'AKS cluster name (required when use-invoke-command is true)' + required: false outputs: secret-name: description: 'Secret name' diff --git a/src/run.ts b/src/run.ts index 9ae7b05e..a875c195 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,4 +1,8 @@ import * as core from '@actions/core' +import * as exec from '@actions/exec' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' import { CoreV1ApiCreateNamespacedSecretRequest, CoreV1ApiDeleteNamespacedSecretRequest, @@ -51,7 +55,60 @@ export function buildContainerRegistryDockerConfigJSON( } return dockerConfigJson //Buffer.from(JSON.stringify(dockerConfigJson)).toString('base64'); } - +export async function runKubectlViaAz( + secret: V1Secret, + namespace: string, + secretName: string +) { + const resourceGroup = core.getInput('cluster-resource-group') + const clusterName = core.getInput('cluster-name') + if (!resourceGroup || !clusterName) { + throw new Error( + 'cluster-resource-group and cluster-name are required for private cluster support' + ) + } + // Write secret to temp file + const tempFile = path.join(os.tmpdir(), `secret-${secretName}.json`) + // Write secret to temp file securely + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'secret-')); + const tempFile = path.join(tempDir, `${secretName}.json`); + fs.writeFileSync(tempFile, JSON.stringify(secret, null, 2), { mode: 0o600 }); + try { + await exec.exec('az', [ + 'aks', + 'command', + 'invoke', + '--resource-group', + resourceGroup, + '--name', + clusterName, + '--command', + `kubectl delete secret ${secretName} -n ${namespace} --ignore-not-found` + ]) + await exec.exec('az', [ + 'aks', + 'command', + 'invoke', + '--resource-group', + resourceGroup, + '--name', + clusterName, + '--command', + `kubectl apply -f - -n ${namespace}`, + '--file', + tempFile + ]) + } finally { + // Remove temp file if it exists + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile) + } + // Remove temp directory and its contents + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + } +} export async function buildSecret( secretName: string, namespace: string, @@ -219,6 +276,13 @@ export async function run() { // The namespace in which to place the secret const namespace: string = core.getInput('namespace') || 'default' + const sec = await buildSecret(secretName, namespace, secretType) + + if (core.getInput('use-invoke-command') === 'true') { + await runKubectlViaAz(sec, namespace, secretName) + return + } + // Delete if exists let deleteSecretResponse try { diff --git a/test/run.test.ts b/test/run.test.ts index ffae78b4..b0c98f96 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,7 +1,7 @@ import {kStringMaxLength} from 'buffer' import * as fs from 'fs' import * as path from 'path' - +import * as exec from '@actions/exec' import * as core from '@actions/core' const k8s = require('@kubernetes/client-node') @@ -176,3 +176,57 @@ describe('buildContainerRegistryDockerConfigJSON', () => { expect(dockerConfigJson).toEqual(exptectedDockerConfigJsonNoEmail) }) }) + +describe('runKubectlViaAz', () => { + const mockSecret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: {name: 'test-secret', namespace: 'test-ns'}, + type: 'Opaque', + data: {foo: 'YmFy'} + } + + beforeEach(() => { + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) + jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {}) + jest.spyOn(exec, 'exec').mockResolvedValue(0) + }) + + afterEach(() => jest.restoreAllMocks()) + + it('calls az aks command invoke for delete and apply', async () => { + jest + .spyOn(core, 'getInput') + .mockImplementation((name: string) => + name === 'cluster-resource-group' + ? 'rg' + : name === 'cluster-name' + ? 'aks' + : '' + ) + const {runKubectlViaAz} = require('../src/run') + await runKubectlViaAz(mockSecret, 'test-ns', 'test-secret') + expect(exec.exec).toHaveBeenCalledWith( + 'az', + expect.arrayContaining([ + 'aks', + 'command', + 'invoke', + '--resource-group', + 'rg', + '--name', + 'aks' + ]) + ) + }) + + it('throws if required inputs are missing', async () => { + jest.spyOn(core, 'getInput').mockReturnValue('') + const {runKubectlViaAz} = require('../src/run') + await expect( + runKubectlViaAz(mockSecret, 'test-ns', 'test-secret') + ).rejects.toThrow( + 'cluster-resource-group and cluster-name are required for private cluster support' + ) + }) +})