From ebc225eb4401d6a7cb15de8d2bfa3aa14b2c33a9 Mon Sep 17 00:00:00 2001 From: Shivam Gupta Date: Thu, 24 Sep 2020 17:32:50 +0530 Subject: [PATCH] Adding arc set context support --- action.yml | 20 +++++++++ lib/arc-login.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++ lib/client.js | 99 +++++++++++++++++++++++++++++++++++++++++++ lib/login.js | 31 ++++++++++---- package.json | 2 +- src/arc-login.ts | 98 +++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 103 +++++++++++++++++++++++++++++++++++++++++++++ src/login.ts | 36 +++++++++++----- 8 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 lib/arc-login.js create mode 100644 lib/client.js create mode 100644 src/arc-login.ts create mode 100644 src/client.ts diff --git a/action.yml b/action.yml index 8c656ae2..6ce308c0 100644 --- a/action.yml +++ b/action.yml @@ -2,6 +2,10 @@ name: 'Kubernetes set context' description: 'Kubernetes set context' inputs: # Used for setting the target K8s cluster context which will be used by other actions like azure/k8s-actions/k8s-deploy or azure/k8s-actions/k8s-create-secret + cluster-type: + description: 'Acceptable values: generic or arc' + required: true + default: 'generic' method: description: 'Acceptable values: kubeconfig or service-account' required: true @@ -24,6 +28,22 @@ inputs: 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 + default: '' + resource-group: + description: 'Azure resource group name' + required: false + default: '' + cluster-name: + description: 'Azure connected cluster name' + required: false + default: '' branding: color: 'green' # optional, decorates the entry in the GitHub Marketplace runs: diff --git a/lib/arc-login.js b/lib/arc-login.js new file mode 100644 index 00000000..ec42343f --- /dev/null +++ b/lib/arc-login.js @@ -0,0 +1,106 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +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); + }); + }); + }); +} +function getArcKubeconfig() { + return __awaiter(this, void 0, void 0, function* () { + try { + let creds = core.getInput('creds'); + let credsObject; + 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 = 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 + } + }); + 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); + }); + } + catch (ex) { + return Promise.reject(ex); + } + }); +} +exports.getArcKubeconfig = getArcKubeconfig; diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 00000000..d19f77ec --- /dev/null +++ b/lib/client.js @@ -0,0 +1,99 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const util = require("util"); +const fs = require("fs"); +const httpClient = require("typed-rest-client/HttpClient"); +const core = require("@actions/core"); +var httpCallbackClient = new httpClient.HttpClient('GITHUB_RUNNER', null, {}); +class WebRequest { +} +exports.WebRequest = WebRequest; +class WebResponse { +} +exports.WebResponse = WebResponse; +class WebRequestOptions { +} +exports.WebRequestOptions = WebRequestOptions; +function sendRequest(request, options) { + return __awaiter(this, void 0, void 0, function* () { + let i = 0; + let retryCount = options && options.retryCount ? options.retryCount : 5; + let retryIntervalInSeconds = options && options.retryIntervalInSeconds ? options.retryIntervalInSeconds : 2; + let retriableErrorCodes = options && options.retriableErrorCodes ? options.retriableErrorCodes : ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ESOCKETTIMEDOUT", "ECONNREFUSED", "EHOSTUNREACH", "EPIPE", "EA_AGAIN"]; + let retriableStatusCodes = options && options.retriableStatusCodes ? options.retriableStatusCodes : [408, 409, 500, 502, 503, 504]; + let timeToWait = retryIntervalInSeconds; + while (true) { + try { + if (request.body && typeof (request.body) !== 'string' && !request.body["readable"]) { + request.body = fs.createReadStream(request.body["path"]); + } + let response = yield sendRequestInternal(request); + if (retriableStatusCodes.indexOf(response.statusCode) != -1 && ++i < retryCount) { + core.debug(util.format("Encountered a retriable status code: %s. Message: '%s'.", response.statusCode, response.statusMessage)); + yield sleepFor(timeToWait); + timeToWait = timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; + continue; + } + return response; + } + catch (error) { + if (retriableErrorCodes.indexOf(error.code) != -1 && ++i < retryCount) { + core.debug(util.format("Encountered a retriable error:%s. Message: %s.", error.code, error.message)); + yield sleepFor(timeToWait); + timeToWait = timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; + } + else { + if (error.code) { + core.debug("error code =" + error.code); + } + throw error; + } + } + } + }); +} +exports.sendRequest = sendRequest; +function sleepFor(sleepDurationInSeconds) { + return new Promise((resolve, reject) => { + setTimeout(resolve, sleepDurationInSeconds * 1000); + }); +} +exports.sleepFor = sleepFor; +function sendRequestInternal(request) { + return __awaiter(this, void 0, void 0, function* () { + core.debug(util.format("[%s]%s", request.method, request.uri)); + var response = yield httpCallbackClient.request(request.method, request.uri, request.body, request.headers); + return yield toWebResponse(response); + }); +} +function toWebResponse(response) { + return __awaiter(this, void 0, void 0, function* () { + var res = new WebResponse(); + if (response) { + res.statusCode = response.message.statusCode; + res.statusMessage = response.message.statusMessage; + res.headers = response.message.headers; + var body = yield response.readBody(); + if (body) { + try { + res.body = JSON.parse(body); + } + catch (error) { + core.debug("Could not parse response: " + JSON.stringify(error)); + core.debug("Response: " + JSON.stringify(res.body)); + res.body = body; + } + } + } + return res; + }); +} diff --git a/lib/login.js b/lib/login.js index 0a8ade25..bb2ed2a6 100644 --- a/lib/login.js +++ b/lib/login.js @@ -19,6 +19,7 @@ const os = require("os"); const toolrunner_1 = require("@actions/exec/lib/toolrunner"); const jsyaml = require("js-yaml"); const util = require("util"); +const arc_login_1 = require("./arc-login"); function getKubeconfig() { const method = core.getInput('method', { required: true }); if (method == 'kubeconfig') { @@ -107,14 +108,28 @@ function setContext(kubeconfigPath) { } function run() { return __awaiter(this, void 0, void 0, function* () { - let kubeconfig = getKubeconfig(); - 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); - command_1.issueCommand('set-env', { name: 'KUBECONFIG' }, kubeconfigPath); - console.log('KUBECONFIG environment variable is set'); - yield setContext(kubeconfigPath); + try { + let kubeconfig = ''; + const cluster_type = core.getInput('cluster-type', { required: true }); + if (cluster_type == 'arc') { + kubeconfig = yield arc_login_1.getArcKubeconfig().catch(ex => { + throw new Error('Error: Could not get the KUBECONFIG for arc cluster: ' + ex); + }); + } + else { + kubeconfig = getKubeconfig(); + } + 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); + command_1.issueCommand('set-env', { name: 'KUBECONFIG' }, kubeconfigPath); + console.log('KUBECONFIG environment variable is set'); + yield setContext(kubeconfigPath); + } + catch (ex) { + return Promise.reject(ex); + } }); } run().catch(core.setFailed); diff --git a/package.json b/package.json index 9c1f4bef..e4f5eef0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "main": "lib/run.js", "scripts": { - "build": "tsc --outDir .\\lib\\ --rootDir .\\src\\" + "build": "tsc --outDir ./lib --rootDir ./src" }, "keywords": [ "actions", diff --git a/src/arc-login.ts b/src/arc-login.ts new file mode 100644 index 00000000..06ce91aa --- /dev/null +++ b/src/arc-login.ts @@ -0,0 +1,98 @@ +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) + } + ); + }); +} + +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 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' + } + webRequest.body = JSON.stringify({ + authenticationMethod: "Token", + value: { + token: saToken + } + }); + 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); + }); + } catch (ex) { + return Promise.reject(ex); + } +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 00000000..a314d2df --- /dev/null +++ b/src/client.ts @@ -0,0 +1,103 @@ +import util = require("util"); +import fs = require('fs'); +import httpClient = require("typed-rest-client/HttpClient"); +import * as core from '@actions/core'; + +var httpCallbackClient = new httpClient.HttpClient('GITHUB_RUNNER', null, {}); + +export class WebRequest { + public method: string; + public uri: string; + // body can be string or ReadableStream + public body: string | NodeJS.ReadableStream; + public headers: any; +} + +export class WebResponse { + public statusCode: number; + public statusMessage: string; + public headers: any; + public body: any; +} + +export class WebRequestOptions { + public retriableErrorCodes?: string[]; + public retryCount?: number; + public retryIntervalInSeconds?: number; + public retriableStatusCodes?: number[]; + public retryRequestTimedout?: boolean; +} + +export async function sendRequest(request: WebRequest, options?: WebRequestOptions): Promise { + let i = 0; + let retryCount = options && options.retryCount ? options.retryCount : 5; + let retryIntervalInSeconds = options && options.retryIntervalInSeconds ? options.retryIntervalInSeconds : 2; + let retriableErrorCodes = options && options.retriableErrorCodes ? options.retriableErrorCodes : ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ESOCKETTIMEDOUT", "ECONNREFUSED", "EHOSTUNREACH", "EPIPE", "EA_AGAIN"]; + let retriableStatusCodes = options && options.retriableStatusCodes ? options.retriableStatusCodes : [408, 409, 500, 502, 503, 504]; + let timeToWait: number = retryIntervalInSeconds; + while (true) { + try { + if (request.body && typeof (request.body) !== 'string' && !request.body["readable"]) { + request.body = fs.createReadStream(request.body["path"]); + } + + let response: WebResponse = await sendRequestInternal(request); + if (retriableStatusCodes.indexOf(response.statusCode) != -1 && ++i < retryCount) { + core.debug(util.format("Encountered a retriable status code: %s. Message: '%s'.", response.statusCode, response.statusMessage)); + await sleepFor(timeToWait); + timeToWait = timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; + continue; + } + + return response; + } + catch (error) { + if (retriableErrorCodes.indexOf(error.code) != -1 && ++i < retryCount) { + core.debug(util.format("Encountered a retriable error:%s. Message: %s.", error.code, error.message)); + await sleepFor(timeToWait); + timeToWait = timeToWait * retryIntervalInSeconds + retryIntervalInSeconds; + } + else { + if (error.code) { + core.debug("error code =" + error.code); + } + + throw error; + } + } + } +} + +export function sleepFor(sleepDurationInSeconds: number): Promise { + return new Promise((resolve, reject) => { + setTimeout(resolve, sleepDurationInSeconds * 1000); + }); +} + +async function sendRequestInternal(request: WebRequest): Promise { + core.debug(util.format("[%s]%s", request.method, request.uri)); + var response: httpClient.HttpClientResponse = await httpCallbackClient.request(request.method, request.uri, request.body, request.headers); + return await toWebResponse(response); +} + +async function toWebResponse(response: httpClient.HttpClientResponse): Promise { + var res = new WebResponse(); + if (response) { + res.statusCode = response.message.statusCode; + res.statusMessage = response.message.statusMessage; + res.headers = response.message.headers; + var body = await response.readBody(); + if (body) { + try { + res.body = JSON.parse(body); + } + catch (error) { + core.debug("Could not parse response: " + JSON.stringify(error)); + core.debug("Response: " + JSON.stringify(res.body)); + res.body = body; + } + } + } + + return res; +} \ No newline at end of file diff --git a/src/login.ts b/src/login.ts index 4a217355..ae05d726 100644 --- a/src/login.ts +++ b/src/login.ts @@ -8,18 +8,19 @@ import * as os from 'os'; import { ToolRunner } from "@actions/exec/lib/toolrunner"; import * as jsyaml from 'js-yaml'; import * as util from 'util'; +import { getArcKubeconfig } from './arc-login'; function getKubeconfig(): string { - const method = core.getInput('method', {required: true}); + const method = core.getInput('method', { required: true }); if (method == 'kubeconfig') { - const kubeconfig = core.getInput('kubeconfig', {required : true}); + const kubeconfig = core.getInput('kubeconfig', { required: true }); core.debug("Setting context using kubeconfig"); return kubeconfig; } else if (method == 'service-account') { const clusterUrl = core.getInput('k8s-url', { required: true }); core.debug("Found clusterUrl, creating kubeconfig using certificate and token"); - let k8sSecret = core.getInput('k8s-secret', {required : true}); + let k8sSecret = core.getInput('k8s-secret', { required: true }); var parsedk8sSecret = jsyaml.safeLoad(k8sSecret); let kubernetesServiceAccountSecretFieldNotPresent = 'The service acount secret yaml does not contain %s; field. Make sure that its present and try again.'; if (!parsedk8sSecret) { @@ -103,14 +104,27 @@ async function setContext(kubeconfigPath: string) { } async function run() { - let kubeconfig = getKubeconfig(); - 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); - issueCommand('set-env', { name: 'KUBECONFIG' }, kubeconfigPath); - console.log('KUBECONFIG environment variable is set'); - await setContext(kubeconfigPath); + try { + let kubeconfig = ''; + const cluster_type = core.getInput('cluster-type', { required: true }); + if (cluster_type == 'arc') { + kubeconfig = await getArcKubeconfig().catch(ex => { + throw new Error('Error: Could not get the KUBECONFIG for arc cluster: ' + ex); + }); + } + else { + kubeconfig = getKubeconfig(); + } + 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); + issueCommand('set-env', { name: 'KUBECONFIG' }, kubeconfigPath); + console.log('KUBECONFIG environment variable is set'); + await setContext(kubeconfigPath); + } catch (ex) { + return Promise.reject(ex); + } } run().catch(core.setFailed); \ No newline at end of file