diff --git a/README.md b/README.md index baf652fb..34fc88bf 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,17 @@ This action can be used to set cluster context before other actions like [`azure/k8s-deploy`](https://github.com/Azure/k8s-deploy/tree/master), [`azure/k8s-create-secret`](https://github.com/Azure/k8s-create-secret/tree/master) or any kubectl commands (in script) can be run subsequently in the workflow. -There are two approaches for specifying the deployment target: +It is a requirement to use [`azure/login`](https://github.com/Azure/login/tree/master) in your workflow before using this action. + +There are three approaches for specifying the deployment target: - Kubeconfig file provided as input to the action - Service account approach where the secret associated with the service account is provided as input to the action +- Service principal approach(only applicable for arc cluster) where service principal provided with 'creds' is used as input to action -If inputs related to both these approaches are provided, kubeconfig approach related inputs are given precedence. +If inputs related to all these approaches are provided, kubeconfig approach related inputs are given precedence. -In both these approaches it is recommended to store these contents (kubeconfig file content or secret content) in a [secret](https://developer.github.com/actions/managing-workflows/storing-secrets/) which could be referenced later in the action. +In all these approaches it is recommended to store these contents (kubeconfig file content or secret content) in a [secret](https://developer.github.com/actions/managing-workflows/storing-secrets/) which could be referenced later in the action. ## Action inputs @@ -22,7 +25,7 @@ In both these approaches it is recommended to store these contents (kubeconfig f method
Method - (Optional) Acceptable values: kubeconfig/service-account. Default value: kubeconfig + (Optional) Acceptable values: kubeconfig/service-account/service-principal. Default value: kubeconfig kubeconfig
Kubectl config @@ -40,6 +43,28 @@ In both these approaches it is recommended to store these contents (kubeconfig f k8s-secret
Secret (Relevant for service account approach) Secret associated with the service account to be used for deployments + + cluster-type
Type of cluster + Type of cluster. Acceptable values: generic/arc + + + creds
Service principal credentials for az login + Provide json output of 'az ad sp create-for-rbac --sdk-auth' command + + + cluster-name
Name of arc cluster + Name of Azure Arc enabled Kubernetes cluster. Required only if cluster-type is 'arc'. + + + + resource-group
resource group + Resource group containing the Azure Arc enabled Kubernetes cluster. Required only if cluster-type is 'arc'. + + + + token
Service account token + Applicable for 'service-account' method. + ## Example usage @@ -101,6 +126,31 @@ kubectl get serviceAccounts -n -o 'jsonpath={ kubectl get secret -n -o yaml ``` +### Service account approach for arc cluster + +```yaml +- uses: azure/k8s-set-context@v1 + with: + method: service-account + cluster-type: 'arc' + cluster-name: + resource-group: + token: '${{ secrets.SA_TOKEN }}' + id: setcontext +``` + +### Service principal approach for arc cluster + +```yaml +- uses: azure/k8s-set-context@v1 + with: + method: service-principal + cluster-type: 'arc' + cluster-name: + resource-group: + id: setcontext +``` + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/action.yml b/action.yml index 6ce308c0..cf50db3d 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ inputs: required: true default: 'generic' method: - description: 'Acceptable values: kubeconfig or service-account' + description: 'Acceptable values: kubeconfig or service-account or service-principal' required: true default: 'kubeconfig' kubeconfig: @@ -27,11 +27,6 @@ inputs: description: 'Service account secret. Run kubectl get serviceaccounts -o yaml and copy the service-account-secret-name. Copy the ouptut of kubectl get secret -o yaml' required: false default: '' - - creds: - description: 'Azure credentials i.e. output of `az ad sp create-for-rbac --sdk-auth`' - required: false - default: '' token: description: 'Token extracted from the secret of service account (should be base 64 decoded)' required: false @@ -44,8 +39,9 @@ inputs: description: 'Azure connected cluster name' required: false default: '' + branding: color: 'green' # optional, decorates the entry in the GitHub Marketplace runs: using: 'node12' - main: 'lib/login.js' + main: 'lib/login.js' \ No newline at end of file diff --git a/lib/arc-login.js b/lib/arc-login.js index ec42343f..ae57a203 100644 --- a/lib/arc-login.js +++ b/lib/arc-login.js @@ -10,93 +10,65 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; Object.defineProperty(exports, "__esModule", { value: true }); const core = require("@actions/core"); -const client_1 = require("./client"); -const querystring = require("querystring"); -function getAzureAccessToken(servicePrincipalId, servicePrincipalKey, tenantId, authorityUrl, managementEndpointUrl) { - return __awaiter(this, void 0, void 0, function* () { - if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !authorityUrl) { - throw new Error("Not all values are present in the creds object. Ensure appId, password and tenant are supplied"); - } - return new Promise((resolve, reject) => { - let webRequest = new client_1.WebRequest(); - webRequest.method = "POST"; - webRequest.uri = `${authorityUrl}/${tenantId}/oauth2/token/`; - webRequest.body = querystring.stringify({ - resource: managementEndpointUrl, - client_id: servicePrincipalId, - grant_type: "client_credentials", - client_secret: servicePrincipalKey - }); - webRequest.headers = { - "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" - }; - let webRequestOptions = { - retriableStatusCodes: [400, 408, 409, 500, 502, 503, 504], - }; - client_1.sendRequest(webRequest, webRequestOptions).then((response) => { - if (response.statusCode == 200) { - resolve(response.body.access_token); - } - else if ([400, 401, 403].indexOf(response.statusCode) != -1) { - reject('ExpiredServicePrincipal'); - } - else { - reject('CouldNotFetchAccessTokenforAzureStatusCode'); - } - }, (error) => { - reject(error); - }); - }); - }); -} +const path = require("path"); +const child_process_1 = require("child_process"); +const fs = require("fs"); +const io = require("@actions/io"); +const exec = require("@actions/exec"); +var azPath; +const kubeconfig_timeout = 120; //timeout in seconds function getArcKubeconfig() { return __awaiter(this, void 0, void 0, function* () { try { - let creds = core.getInput('creds'); - let credsObject; - try { - credsObject = JSON.parse(creds); + let method = core.getInput('method'); + if (method != 'service-account' && method != 'service-principal') { + throw Error("Supported methods for arc cluster are 'service-account' and 'service-principal'."); } - catch (ex) { - throw new Error('Credentials object is not a valid JSON: ' + ex); - } - let servicePrincipalId = credsObject["clientId"]; - let servicePrincipalKey = credsObject["clientSecret"]; - let tenantId = credsObject["tenantId"]; - let authorityUrl = credsObject["activeDirectoryEndpointUrl"] || "https://login.microsoftonline.com"; - let managementEndpointUrl = credsObject["resourceManagerEndpointUrl"] || "https://management.azure.com/"; - let subscriptionId = credsObject["subscriptionId"]; - let azureSessionToken = yield getAzureAccessToken(servicePrincipalId, servicePrincipalKey, tenantId, authorityUrl, managementEndpointUrl).catch(ex => { - throw new Error('Could not fetch the azure access token: ' + ex); - }); let resourceGroupName = core.getInput('resource-group'); let clusterName = core.getInput('cluster-name'); - let saToken = core.getInput('token'); - return new Promise((resolve, reject) => { - var webRequest = new client_1.WebRequest(); - webRequest.method = 'POST'; - webRequest.uri = `${managementEndpointUrl}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.Kubernetes/connectedClusters/${clusterName}/listClusterUserCredentials?api-version=2020-01-01-preview`; - webRequest.headers = { - 'Authorization': 'Bearer ' + azureSessionToken, - 'Content-Type': 'application/json; charset=utf-8' - }; - webRequest.body = JSON.stringify({ - authenticationMethod: "Token", - value: { - token: saToken - } + if (!resourceGroupName) { + throw Error("'resourceGroupName' is not passed for arc cluster."); + } + if (!clusterName) { + throw Error("'clusterName' is not passed for arc cluster."); + } + azPath = yield io.which("az", true); + yield executeAzCliCommand(`account show`, false); + try { + yield executeAzCliCommand(`extension remove -n connectedk8s`, false); + } + catch (_a) { + //ignore if this causes an error + } + yield executeAzCliCommand(`extension add -n connectedk8s`, false); + yield executeAzCliCommand(`extension list`, false); + const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated + const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); + if (method == 'service-account') { + let saToken = core.getInput('token'); + if (!saToken) { + throw Error("'saToken' is not passed for 'service-account' method."); + } + console.log("using 'service-account' method for authenticating to arc cluster."); + const proc = child_process_1.spawn(azPath, ['connectedk8s', 'proxy', '-n', clusterName, '-g', resourceGroupName, '-f', kubeconfigPath, '--token', saToken], { + detached: true, + stdio: 'ignore' }); - client_1.sendRequest(webRequest).then((response) => { - let kubeconfigs = response.body.kubeconfigs; - if (kubeconfigs && kubeconfigs.length > 0) { - var kubeconfig = Buffer.from(kubeconfigs[0].value, 'base64'); - resolve(kubeconfig.toString()); - } - else { - reject(JSON.stringify(response.body)); - } - }).catch(reject); - }); + proc.unref(); + } + else { + console.log("using 'service-principal' method for authenticating to arc cluster."); + const proc = child_process_1.spawn(azPath, ['connectedk8s', 'proxy', '-n', clusterName, '-g', resourceGroupName, '-f', kubeconfigPath], { + detached: true, + stdio: 'ignore' + }); + proc.unref(); + } + console.log(`Waiting for ${kubeconfig_timeout} seconds for kubeconfig to be merged....`); + yield sleep(kubeconfig_timeout * 1000); //sleeping for 2 minutes to allow kubeconfig to be merged + fs.chmodSync(kubeconfigPath, '600'); + core.exportVariable('KUBECONFIG', kubeconfigPath); + console.log('KUBECONFIG environment variable is set'); } catch (ex) { return Promise.reject(ex); @@ -104,3 +76,17 @@ function getArcKubeconfig() { }); } exports.getArcKubeconfig = getArcKubeconfig; +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +function executeAzCliCommand(command, silent, execOptions = {}, args = []) { + return __awaiter(this, void 0, void 0, function* () { + execOptions.silent = !!silent; + try { + yield exec.exec(`"${azPath}" ${command}`, args, execOptions); + } + catch (error) { + throw new Error(error); + } + }); +} diff --git a/lib/login.js b/lib/login.js index e8d04d13..f7749276 100644 --- a/lib/login.js +++ b/lib/login.js @@ -111,21 +111,21 @@ function run() { let kubeconfig = ''; const cluster_type = core.getInput('cluster-type', { required: true }); if (cluster_type == 'arc') { - kubeconfig = yield arc_login_1.getArcKubeconfig().catch(ex => { + yield arc_login_1.getArcKubeconfig().catch(ex => { throw new Error('Error: Could not get the KUBECONFIG for arc cluster: ' + ex); }); } else { + const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated + const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); kubeconfig = getKubeconfig(); + core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`); + fs.writeFileSync(kubeconfigPath, kubeconfig); + fs.chmodSync(kubeconfigPath, '600'); + core.exportVariable('KUBECONFIG', kubeconfigPath); + console.log('KUBECONFIG environment variable is set'); + yield setContext(kubeconfigPath); } - const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated - const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); - core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`); - fs.writeFileSync(kubeconfigPath, kubeconfig); - fs.chmodSync(kubeconfigPath, '600'); - core.exportVariable('KUBECONFIG', kubeconfigPath); - console.log('KUBECONFIG environment variable is set'); - yield setContext(kubeconfigPath); } catch (ex) { return Promise.reject(ex); diff --git a/src/arc-login.ts b/src/arc-login.ts index 06ce91aa..88e94303 100644 --- a/src/arc-login.ts +++ b/src/arc-login.ts @@ -1,98 +1,83 @@ import * as core from '@actions/core'; -import { rejects } from 'assert'; -import { WebRequest, WebRequestOptions, WebResponse, sendRequest } from './client'; -import * as querystring from 'querystring'; - -async function getAzureAccessToken(servicePrincipalId, servicePrincipalKey, tenantId, authorityUrl, managementEndpointUrl: string): Promise { - - if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !authorityUrl) { - throw new Error("Not all values are present in the creds object. Ensure appId, password and tenant are supplied"); - } - return new Promise((resolve, reject) => { - let webRequest = new WebRequest(); - webRequest.method = "POST"; - webRequest.uri = `${authorityUrl}/${tenantId}/oauth2/token/`; - webRequest.body = querystring.stringify({ - resource: managementEndpointUrl, - client_id: servicePrincipalId, - grant_type: "client_credentials", - client_secret: servicePrincipalKey - }); - webRequest.headers = { - "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" - }; - - let webRequestOptions: WebRequestOptions = { - retriableStatusCodes: [400, 408, 409, 500, 502, 503, 504], - }; - - sendRequest(webRequest, webRequestOptions).then( - (response: WebResponse) => { - if (response.statusCode == 200) { - resolve(response.body.access_token); - } - else if ([400, 401, 403].indexOf(response.statusCode) != -1) { - reject('ExpiredServicePrincipal'); - } - else { - reject('CouldNotFetchAccessTokenforAzureStatusCode'); - } - }, - (error) => { - reject(error) - } - ); - }); -} +import * as path from 'path'; +import {spawn} from 'child_process'; +import * as fs from 'fs'; +import * as io from '@actions/io'; +import * as exec from '@actions/exec'; +var azPath: string; + +const kubeconfig_timeout = 120;//timeout in seconds export async function getArcKubeconfig(): Promise { try { - let creds = core.getInput('creds'); - let credsObject: { [key: string]: string; }; - try { - credsObject = JSON.parse(creds); - } catch (ex) { - throw new Error('Credentials object is not a valid JSON: ' + ex); - } - - let servicePrincipalId = credsObject["clientId"]; - let servicePrincipalKey = credsObject["clientSecret"]; - let tenantId = credsObject["tenantId"]; - let authorityUrl = credsObject["activeDirectoryEndpointUrl"] || "https://login.microsoftonline.com"; - let managementEndpointUrl = credsObject["resourceManagerEndpointUrl"] || "https://management.azure.com/"; - let subscriptionId = credsObject["subscriptionId"]; - - let azureSessionToken = await getAzureAccessToken(servicePrincipalId, servicePrincipalKey, tenantId, authorityUrl, managementEndpointUrl).catch(ex => { - throw new Error('Could not fetch the azure access token: ' + ex); - }); + let method = core.getInput('method'); + if (method != 'service-account' && method != 'service-principal'){ + throw Error("Supported methods for arc cluster are 'service-account' and 'service-principal'."); + } + let resourceGroupName = core.getInput('resource-group'); let clusterName = core.getInput('cluster-name'); - let saToken = core.getInput('token'); - return new Promise((resolve, reject) => { - var webRequest = new WebRequest(); - webRequest.method = 'POST'; - webRequest.uri = `${managementEndpointUrl}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.Kubernetes/connectedClusters/${clusterName}/listClusterUserCredentials?api-version=2020-01-01-preview`; - webRequest.headers = { - 'Authorization': 'Bearer ' + azureSessionToken, - 'Content-Type': 'application/json; charset=utf-8' + if(!resourceGroupName){ + throw Error("'resourceGroupName' is not passed for arc cluster.") + } + if(!clusterName){ + throw Error("'clusterName' is not passed for arc cluster.") + } + azPath = await io.which("az", true); + await executeAzCliCommand(`account show`, false); + try{ + await executeAzCliCommand(`extension remove -n connectedk8s`, false); + } + catch{ + //ignore if this causes an error + } + await executeAzCliCommand(`extension add -n connectedk8s`, false); + await executeAzCliCommand(`extension list`, false); + const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated + const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); + if (method == 'service-account'){ + let saToken = core.getInput('token'); + if(!saToken){ + throw Error("'saToken' is not passed for 'service-account' method.") } - webRequest.body = JSON.stringify({ - authenticationMethod: "Token", - value: { - token: saToken - } + console.log("using 'service-account' method for authenticating to arc cluster.") + const proc=spawn(azPath,['connectedk8s','proxy','-n',clusterName,'-g',resourceGroupName,'-f',kubeconfigPath,'--token',saToken], { + detached: true, + stdio: 'ignore' }); - sendRequest(webRequest).then((response: WebResponse) => { - let kubeconfigs = response.body.kubeconfigs; - if (kubeconfigs && kubeconfigs.length > 0) { - var kubeconfig = Buffer.from(kubeconfigs[0].value, 'base64'); - resolve(kubeconfig.toString()); - } else { - reject(JSON.stringify(response.body)); - } - }).catch(reject); - }); + proc.unref(); + } else{ + console.log("using 'service-principal' method for authenticating to arc cluster.") + const proc=spawn(azPath,['connectedk8s','proxy','-n',clusterName,'-g',resourceGroupName,'-f',kubeconfigPath], { + detached: true, + stdio: 'ignore' + }); + proc.unref(); + } + console.log(`Waiting for ${kubeconfig_timeout} seconds for kubeconfig to be merged....`) + await sleep(kubeconfig_timeout*1000) //sleeping for 2 minutes to allow kubeconfig to be merged + fs.chmodSync(kubeconfigPath, '600'); + core.exportVariable('KUBECONFIG', kubeconfigPath); + console.log('KUBECONFIG environment variable is set'); } catch (ex) { return Promise.reject(ex); } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function executeAzCliCommand( + command: string, + silent?: boolean, + execOptions: any = {}, + args: any = []) { + execOptions.silent = !!silent; + try { + await exec.exec(`"${azPath}" ${command}`, args, execOptions); + } + catch (error) { + throw new Error(error); + } } \ No newline at end of file diff --git a/src/login.ts b/src/login.ts index f4fb91e0..eb44f6e7 100644 --- a/src/login.ts +++ b/src/login.ts @@ -107,21 +107,21 @@ async function run() { let kubeconfig = ''; const cluster_type = core.getInput('cluster-type', { required: true }); if (cluster_type == 'arc') { - kubeconfig = await getArcKubeconfig().catch(ex => { + await getArcKubeconfig().catch(ex => { throw new Error('Error: Could not get the KUBECONFIG for arc cluster: ' + ex); }); } else { + const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated + const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); kubeconfig = getKubeconfig(); + core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`); + fs.writeFileSync(kubeconfigPath, kubeconfig); + fs.chmodSync(kubeconfigPath, '600'); + core.exportVariable('KUBECONFIG', kubeconfigPath); + console.log('KUBECONFIG environment variable is set'); + await setContext(kubeconfigPath); } - const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated - const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); - core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`); - fs.writeFileSync(kubeconfigPath, kubeconfig); - fs.chmodSync(kubeconfigPath, '600'); - core.exportVariable('KUBECONFIG', kubeconfigPath); - console.log('KUBECONFIG environment variable is set'); - await setContext(kubeconfigPath); } catch (ex) { return Promise.reject(ex); }