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. 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 - 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 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 ## Action inputs
@ -22,7 +25,7 @@ In both these approaches it is recommended to store these contents (kubeconfig f
</thead> </thead>
<tr> <tr>
<td><code>method</code><br/>Method</td> <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>
<tr> <tr>
<td><code>kubeconfig</code><br/>Kubectl config</td> <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><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> <td>(Relevant for service account approach) Secret associated with the service account to be used for deployments</td>
</tr> </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> </table>
## Example usage ## 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 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 ## Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a This project welcomes contributions and suggestions. Most contributions require you to agree to a

View File

@ -7,7 +7,7 @@ inputs:
required: true required: true
default: 'generic' default: 'generic'
method: method:
description: 'Acceptable values: kubeconfig or service-account' description: 'Acceptable values: kubeconfig or service-account or service-principal'
required: true required: true
default: 'kubeconfig' default: 'kubeconfig'
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' 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 required: false
default: '' default: ''
creds:
description: 'Azure credentials i.e. output of `az ad sp create-for-rbac --sdk-auth`'
required: false
default: ''
token: token:
description: 'Token extracted from the secret of service account (should be base 64 decoded)' description: 'Token extracted from the secret of service account (should be base 64 decoded)'
required: false required: false
@ -44,6 +39,7 @@ inputs:
description: 'Azure connected cluster name' description: 'Azure connected cluster name'
required: false required: false
default: '' default: ''
branding: branding:
color: 'green' # optional, decorates the entry in the GitHub Marketplace color: 'green' # optional, decorates the entry in the GitHub Marketplace
runs: runs:

View File

@ -10,93 +10,65 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
const core = require("@actions/core"); const core = require("@actions/core");
const client_1 = require("./client"); const path = require("path");
const querystring = require("querystring"); const child_process_1 = require("child_process");
function getAzureAccessToken(servicePrincipalId, servicePrincipalKey, tenantId, authorityUrl, managementEndpointUrl) { const fs = require("fs");
return __awaiter(this, void 0, void 0, function* () { const io = require("@actions/io");
if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !authorityUrl) { const exec = require("@actions/exec");
throw new Error("Not all values are present in the creds object. Ensure appId, password and tenant are supplied"); var azPath;
} const kubeconfig_timeout = 120; //timeout in seconds
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() { function getArcKubeconfig() {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
try { try {
let creds = core.getInput('creds'); let method = core.getInput('method');
let credsObject; if (method != 'service-account' && method != 'service-principal') {
try { throw Error("Supported methods for arc cluster are 'service-account' and 'service-principal'.");
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 resourceGroupName = core.getInput('resource-group');
let clusterName = core.getInput('cluster-name'); let clusterName = core.getInput('cluster-name');
let saToken = core.getInput('token'); if (!resourceGroupName) {
return new Promise((resolve, reject) => { throw Error("'resourceGroupName' is not passed for arc cluster.");
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 (!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) => { proc.unref();
let kubeconfigs = response.body.kubeconfigs;
if (kubeconfigs && kubeconfigs.length > 0) {
var kubeconfig = Buffer.from(kubeconfigs[0].value, 'base64');
resolve(kubeconfig.toString());
} }
else { else {
reject(JSON.stringify(response.body)); 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], {
}).catch(reject); 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) { catch (ex) {
return Promise.reject(ex); return Promise.reject(ex);
@ -104,3 +76,17 @@ function getArcKubeconfig() {
}); });
} }
exports.getArcKubeconfig = 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,15 +111,14 @@ function run() {
let kubeconfig = ''; let kubeconfig = '';
const cluster_type = core.getInput('cluster-type', { required: true }); const cluster_type = core.getInput('cluster-type', { required: true });
if (cluster_type == 'arc') { 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); throw new Error('Error: Could not get the KUBECONFIG for arc cluster: ' + ex);
}); });
} }
else { else {
kubeconfig = getKubeconfig();
}
const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated
const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`);
kubeconfig = getKubeconfig();
core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`); core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`);
fs.writeFileSync(kubeconfigPath, kubeconfig); fs.writeFileSync(kubeconfigPath, kubeconfig);
fs.chmodSync(kubeconfigPath, '600'); fs.chmodSync(kubeconfigPath, '600');
@ -127,6 +126,7 @@ function run() {
console.log('KUBECONFIG environment variable is set'); console.log('KUBECONFIG environment variable is set');
yield setContext(kubeconfigPath); yield setContext(kubeconfigPath);
} }
}
catch (ex) { catch (ex) {
return Promise.reject(ex); return Promise.reject(ex);
} }

View File

@ -1,98 +1,83 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
import { rejects } from 'assert'; import * as path from 'path';
import { WebRequest, WebRequestOptions, WebResponse, sendRequest } from './client'; import {spawn} from 'child_process';
import * as querystring from 'querystring'; import * as fs from 'fs';
import * as io from '@actions/io';
import * as exec from '@actions/exec';
var azPath: string;
async function getAzureAccessToken(servicePrincipalId, servicePrincipalKey, tenantId, authorityUrl, managementEndpointUrl: string): Promise<string> { const kubeconfig_timeout = 120;//timeout in seconds
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)
}
);
});
}
export async function getArcKubeconfig(): Promise<string> { export async function getArcKubeconfig(): Promise<string> {
try { try {
let creds = core.getInput('creds'); let method = core.getInput('method');
let credsObject: { [key: string]: string; }; if (method != 'service-account' && method != 'service-principal'){
try { throw Error("Supported methods for arc cluster are 'service-account' and 'service-principal'.");
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 resourceGroupName = core.getInput('resource-group');
let clusterName = core.getInput('cluster-name'); let clusterName = core.getInput('cluster-name');
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'); let saToken = core.getInput('token');
return new Promise<string>((resolve, reject) => { if(!saToken){
var webRequest = new WebRequest(); throw Error("'saToken' is not passed for 'service-account' method.")
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
} }
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) => { proc.unref();
let kubeconfigs = response.body.kubeconfigs;
if (kubeconfigs && kubeconfigs.length > 0) {
var kubeconfig = Buffer.from(kubeconfigs[0].value, 'base64');
resolve(kubeconfig.toString());
} else{ } else{
reject(JSON.stringify(response.body)); console.log("using 'service-principal' method for authenticating to arc cluster.")
} const proc=spawn(azPath,['connectedk8s','proxy','-n',clusterName,'-g',resourceGroupName,'-f',kubeconfigPath], {
}).catch(reject); 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) { } catch (ex) {
return Promise.reject(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 = ''; let kubeconfig = '';
const cluster_type = core.getInput('cluster-type', { required: true }); const cluster_type = core.getInput('cluster-type', { required: true });
if (cluster_type == 'arc') { 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); throw new Error('Error: Could not get the KUBECONFIG for arc cluster: ' + ex);
}); });
} }
else { else {
kubeconfig = getKubeconfig();
}
const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated const runnerTempDirectory = process.env['RUNNER_TEMP']; // Using process.env until the core libs are updated
const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`); const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`);
kubeconfig = getKubeconfig();
core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`); core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`);
fs.writeFileSync(kubeconfigPath, kubeconfig); fs.writeFileSync(kubeconfigPath, kubeconfig);
fs.chmodSync(kubeconfigPath, '600'); fs.chmodSync(kubeconfigPath, '600');
core.exportVariable('KUBECONFIG', kubeconfigPath); core.exportVariable('KUBECONFIG', kubeconfigPath);
console.log('KUBECONFIG environment variable is set'); console.log('KUBECONFIG environment variable is set');
await setContext(kubeconfigPath); await setContext(kubeconfigPath);
}
} catch (ex) { } catch (ex) {
return Promise.reject(ex); return Promise.reject(ex);
} }