Merge pull request #26 from atharvamulmuley/cluster-connect-2

Using az connectedk8s proxy for arc clusters
This commit is contained in:
Atharva Mulmuley 2021-05-11 18:14:51 +05:30 committed by GitHub
commit b34891d1f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 212 additions and 195 deletions

View File

@ -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
</thead>
<tr>
<td><code>method</code><br/>Method</td>
<td>(Optional) Acceptable values: kubeconfig/service-account. Default value: kubeconfig</td>
<td>(Optional) Acceptable values: kubeconfig/service-account/service-principal. Default value: kubeconfig</td>
</tr>
<tr>
<td><code>kubeconfig</code><br/>Kubectl config</td>
@ -40,6 +43,28 @@ In both these approaches it is recommended to store these contents (kubeconfig f
<td><code>k8s-secret</code><br/>Secret</td>
<td>(Relevant for service account approach) Secret associated with the service account to be used for deployments</td>
</tr>
<tr>
<td><code>cluster-type</code><br/>Type of cluster</td>
<td>Type of cluster. Acceptable values: generic/arc</td>
</tr>
<tr>
<td><code>creds</code><br/>Service principal credentials for az login</td>
<td>Provide json output of 'az ad sp create-for-rbac --sdk-auth' command</td>
</tr>
<tr>
<td><code>cluster-name</code><br/>Name of arc cluster</td>
<td>Name of Azure Arc enabled Kubernetes cluster. Required only if cluster-type is 'arc'.</td>
</tr>
<tr>
<td><code>resource-group</code><br/>resource group</td>
<td>Resource group containing the Azure Arc enabled Kubernetes cluster. Required only if cluster-type is 'arc'.</td>
</tr>
<tr>
<td><code>token</code><br/>Service account token</td>
<td>Applicable for 'service-account' method.</td>
</tr>
</table>
## Example usage
@ -101,6 +126,31 @@ kubectl get serviceAccounts <service-account-name> -n <namespace> -o 'jsonpath={
kubectl get secret <service-account-secret-name> -n <namespace> -o yaml
```
### Service account approach for arc cluster
```yaml
- uses: azure/k8s-set-context@v1
with:
method: service-account
cluster-type: 'arc'
cluster-name: <cluster-name>
resource-group: <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: <cluster-name>
resource-group: <resource-group>
id: setcontext
```
## Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a

View File

@ -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 <service-account-name> -o yaml and copy the service-account-secret-name. Copy the ouptut of kubectl get secret <service-account-secret-name> -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'

View File

@ -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);
}
});
}

View File

@ -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);

View File

@ -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<string> {
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<string>((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<string> {
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<string>((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);
}
}

View File

@ -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);
}