Refactor entire project (#39)

- Use more modern TypeScript conventions
- Use JavaScript Kubernetes Client
- Add unit tests
- Add integration tests
- Add TypeScript compile verify workflow
- Switch codeowners
This commit is contained in:
Oliver King
2021-12-07 13:10:22 -05:00
committed by GitHub
parent 40d584de6d
commit 315a1c1f59
46 changed files with 16578 additions and 6813 deletions

2
.github/CODEOWNERS vendored
View File

@ -1 +1 @@
* @ds-ms * @Azure/aks-atlanta

View File

@ -1,10 +1,7 @@
--- ---
name: 'Issue: Bug Report / Feature Request' name: "Issue: Bug Report / Feature Request"
about: Create a report to help us improve about: Create a report to help us improve
title: '' title: ""
labels: need-to-triage labels: need-to-triage
assignees: 'shigupt202' assignees: "@Azure/aks-atlanta"
--- ---

View File

@ -1,33 +0,0 @@
token=$1
commit=$2
repository=$3
prNumber=$4
frombranch=$5
tobranch=$6
patUser=$7
getPayLoad() {
cat <<EOF
{
"event_type": "K8sSetContextPR",
"client_payload":
{
"action": "K8sSetContext",
"commit": "$commit",
"repository": "$repository",
"prNumber": "$prNumber",
"tobranch": "$tobranch",
"frombranch": "$frombranch"
}
}
EOF
}
response=$(curl -u $patUser:$token -X POST https://api.github.com/repos/Azure/azure-actions-integration-tests/dispatches --data "$(getPayLoad)")
if [ "$response" == "" ]; then
echo "Integration tests triggered successfully"
else
echo "Triggering integration tests failed with: '$response'"
exit 1
fi

View File

@ -1,9 +1,9 @@
name: setting-default-labels name: Setting Default Labels
# Controls when the action will run. # Controls when the action will run.
on: on:
schedule: schedule:
- cron: "0 0/3 * * *" - cron: "0 0/3 * * *"
# A workflow run is made up of one or more jobs that can run sequentially or in parallel # A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs: jobs:
@ -13,24 +13,23 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job # Steps represent a sequence of tasks that will be executed as part of the job
steps: steps:
- uses: actions/stale@v3 - uses: actions/stale@v3
name: Setting issue as idle name: Setting Issue as Idle
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is idle because it has been open for 14 days with no activity.' stale-issue-message: "This issue is idle because it has been open for 14 days with no activity."
stale-issue-label: 'idle' stale-issue-label: "idle"
days-before-stale: 14 days-before-stale: 14
days-before-close: -1 days-before-close: -1
operations-per-run: 100 operations-per-run: 100
exempt-issue-labels: 'backlog' exempt-issue-labels: "backlog"
- uses: actions/stale@v3 - uses: actions/stale@v3
name: Setting PR as idle name: Setting PR as Idle
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-pr-message: 'This PR is idle because it has been open for 14 days with no activity.' stale-pr-message: "This PR is idle because it has been open for 14 days with no activity."
stale-pr-label: 'idle' stale-pr-label: "idle"
days-before-stale: 14 days-before-stale: 14
days-before-close: -1 days-before-close: -1
operations-per-run: 100 operations-per-run: 100

View File

@ -1,19 +1,143 @@
name: "Trigger Integration tests" name: Run Integration Tests
on: on:
pull_request: pull_request:
branches: branches:
- master - master
- 'releases/*' - "releases/*"
jobs: push:
trigger-integration-tests: branches:
name: Trigger Integration tests - master
runs-on: ubuntu-latest - "releases/*"
steps:
- name: Check out repository jobs:
uses: actions/checkout@v2 kubeconfig-method-integration-test:
with: runs-on: ubuntu-latest
path: IntegrationTests steps:
- name: Checkout Source Code
- name: Trigger Test run id: checkout-code
run: | uses: actions/checkout@v2
bash ./IntegrationTests/.github/workflows/TriggerIntegrationTests.sh ${{ secrets.L2_REPO_TOKEN }} ${{ github.event.pull_request.head.sha }} ${{ github.repository }} ${{ github.event.pull_request.number }} ${{ github.event.pull_request.head.ref }} ${{ github.event.pull_request.base.ref }} ${{ secrets.L2_REPO_USER }} - name: Npm Install and Build
id: npm-build
run: |
npm install
npm run build
- name: Set Context
uses: ./
with:
method: kubeconfig
context: exp-scratch
kubeconfig: |
apiVersion: v1
clusters:
- cluster:
certificate-authority: fake-ca-file
server: https://1.2.3.4
name: development
- cluster:
insecure-skip-tls-verify: true
server: https://5.6.7.8
name: scratch
contexts:
- context:
cluster: development
namespace: frontend
user: developer
name: dev-frontend
- context:
cluster: development
namespace: storage
user: developer
name: dev-storage
- context:
cluster: scratch
namespace: default
user: experimenter
name: exp-scratch
current-context: ""
kind: Config
preferences: {}
users:
- name: developer
user:
client-certificate: fake-cert-file
client-key: fake-key-file
- name: experimenter
user:
password: some-password
username: exp
- name: Vertify Results
run: |
echo "$EXPECTED_KC" > /tmp/expected_kc.json
DIFF=$(diff <(jq -S -c . $KUBECONFIG) <(jq -S -c . /tmp/expected_kc.json))
if [ "$DIFF" != "" ]; then exit 1; else echo -e "Kubeconfig matches expected"; fi
env:
EXPECTED_KC: |
{
"apiVersion": "v1",
"clusters": [
{
"cluster": {
"certificate-authority": "fake-ca-file",
"insecure-skip-tls-verify": false,
"server": "https://1.2.3.4"
},
"name": "development"
},
{
"cluster": {
"insecure-skip-tls-verify": true,
"server": "https://5.6.7.8"
},
"name": "scratch"
}
],
"contexts": [
{
"context": {
"cluster": "development",
"name": "dev-frontend",
"namespace": "frontend",
"user": "developer"
},
"name": "dev-frontend"
},
{
"context": {
"cluster": "development",
"name": "dev-storage",
"namespace": "storage",
"user": "developer"
},
"name": "dev-storage"
},
{
"context": {
"cluster": "scratch",
"name": "exp-scratch",
"namespace": "default",
"user": "experimenter"
},
"name": "exp-scratch"
}
],
"current-context": "exp-scratch",
"kind": "Config",
"preferences": {
},
"users": [
{
"name": "developer",
"user": {
"client-certificate": "fake-cert-file",
"client-key": "fake-key-file"
}
},
{
"name": "experimenter",
"user": {
"password": "some-password",
"username": "exp"
}
}
]
}

41
.github/workflows/ts-build-check.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: TypeScript Build Check
on: pull_request
jobs:
ts-build-check:
runs-on: ubuntu-latest
steps:
- name: Checkout Pull Request
uses: actions/checkout@v2
with:
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
path: original-pr
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Clone and Build Pull Request
run: |
cp $GITHUB_WORKSPACE/original-pr/ $GITHUB_WORKSPACE/built-pr -r
cd $GITHUB_WORKSPACE/built-pr/
npm i
npm run build
- name: Compare Built Directories
id: diff
run: |
DIFF=$(diff $GITHUB_WORKSPACE/original-pr/lib $GITHUB_WORKSPACE/built-pr/lib -rqiEZbwBd)
if [ "$DIFF" != "" ]; then exit 1; else echo -e "PR contains up-to-date compiled JavaScript."; fi
- name: Comment Unbuilt TypeScript
if: failure() && steps.diff.outcome == 'failure'
uses: actions/github-script@v2
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.issues.createComment({
issue_number: ${{ github.event.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Please compile the TypeScript code with `npm run build`. The compiled JavaScript is not up-to-date.'
})

View File

@ -1,21 +1,20 @@
name: "Run L0 tests." name: Run Unit Tests
on: # rebuild any PRs and main branch changes on:
pull_request: pull_request:
branches: branches:
- master - master
- 'releases/*' - "releases/*"
push: push:
branches: branches:
- master - master
- 'releases/*' - "releases/*"
jobs: jobs:
build: # make sure build/ci works properly unit-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Run Unit Tests
- name: Run L0 tests. run: |
run: | npm install
npm install npm test
npm test

View File

@ -1,86 +1,38 @@
# Kubernetes set context # Kubernetes set context
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) and [`azure/k8s-create-secret`](https://github.com/Azure/k8s-create-secret/tree/master). It should also be used before `kubectl` commands (in script) are run subsequently in the workflow.
It is a requirement to use [`azure/login`](https://github.com/Azure/login/tree/master) in your workflow before using this action. It is a requirement to use [`azure/login`](https://github.com/Azure/login/tree/master) in your workflow before using this action when using the `service-account` or `service-principal` methods.
There are three approaches for specifying the deployment target: 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 - Service principal approach (only applicable for arc cluster) where service principal provided with 'creds' is used as input to action
If inputs related to all these approaches are provided, kubeconfig approach related inputs are given precedence. In all these approaches it is recommended to store these contents (kubeconfig file content or secret content) in a [secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets/).
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. Refer to the [action metadata file](./action.yml) for details about inputs. Note that different inputs are required for different method and cluster types. Use the below examples as a reference.
## Action inputs
<table>
<thead>
<tr>
<th>Action inputs</th>
<th>Description</th>
</tr>
</thead>
<tr>
<td><code>method</code><br/>Method</td>
<td>(Optional) Acceptable values: kubeconfig/service-account/service-principal. Default value: kubeconfig</td>
</tr>
<tr>
<td><code>kubeconfig</code><br/>Kubectl config</td>
<td>(Relevant for kubeconfig approach) Contents of the configuration file to be used with kubectl (e.g. can be pulled from a secret)</td>
</tr>
<tr>
<td><code>context</code><br/>Context</td>
<td>(Relevant for kubeconfig approach) Context to be used within the provided kubeconfig file</td>
</tr>
<tr>
<td><code>k8s-url</code><br/>API server URL</td>
<td>(Relevant for service account approach) API Server URL for the K8s cluster</td>
</tr>
<tr>
<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>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 ## Example usage
### Kubeconfig approach ### Kubeconfig approach
```yaml ```yaml
- uses: azure/k8s-set-context@v1 - uses: azure/k8s-set-context@v2
with: with:
method: kubeconfig method: kubeconfig
kubeconfig: <your kubeconfig> # Use secret (https://developer.github.com/actions/managing-workflows/storing-secrets/) kubeconfig: <your kubeconfig>
context: <context name> #If left unspecified, current-context from kubeconfig is used as default context: <context name> # current-context from kubeconfig is used as default
id: setcontext
``` ```
**Please note** that the input requires the _contents_ of the kubeconfig file, and not its path. **Please note** that the input requires the _contents_ of the kubeconfig file, and not its path.
Following are the ways to fetch kubeconfig file onto your local development machine so that the same can be used in the action input shown above: Following are the ways to fetch kubeconfig file onto your local development machine so that the same can be used in the action input shown above.
#### For Azure Kubernetes Service cluster #### Azure Kubernetes Service cluster
```sh ```bash
az aks get-credentials --name az aks get-credentials --name
--resource-group --resource-group
[--admin] [--admin]
@ -91,65 +43,60 @@ az aks get-credentials --name
Further details can be found in [az aks get-credentials documentation](https://docs.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-get-credentials). Further details can be found in [az aks get-credentials documentation](https://docs.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-get-credentials).
#### For any generic Kubernetes cluster #### Generic Kubernetes cluster
Please refer to documentation on fetching [kubeconfig for any generic K8s cluster](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/) Please refer to documentation on fetching [kubeconfig for any generic K8s cluster](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/)
### Service account approach ### Service account approach
```yaml ```yaml
- uses: azure/k8s-set-context@v1 - uses: azure/k8s-set-context@v2
with: with:
method: service-account method: service-account
k8s-url: <URL of the clsuter's API server> k8s-url: <URL of the cluster's API server>
k8s-secret: <secret associated with the service account> k8s-secret: <secret associated with the service account>
id: setcontext
``` ```
For fetching Server URL, execute the following command on your shell: For fetching Server URL, execute the following command on your shell:
```sh ```bash
kubectl config view --minify -o 'jsonpath={.clusters[0].cluster.server}' kubectl config view --minify -o 'jsonpath={.clusters[0].cluster.server}'
``` ```
For fetching Secret object required to connect and authenticate with the cluster, the following sequence of commands need to be run: For fetching Secret object required to connect and authenticate with the cluster, the following sequence of commands need to be run:
```sh ```bash
kubectl get serviceAccounts <service-account-name> -n <namespace> -o 'jsonpath={.secrets[*].name}' kubectl get serviceAccounts <service-account-name> -n <namespace> -o 'jsonpath={.secrets[*].name}'
```
```sh
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 ### Service account approach for arc cluster
```yaml ```yaml
- uses: azure/k8s-set-context@v1 - uses: azure/k8s-set-context@v2
with: with:
method: service-account method: service-account
cluster-type: 'arc' cluster-type: arc
cluster-name: <cluster-name> cluster-name: <cluster-name>
resource-group: <resource-group> resource-group: <resource-group>
token: '${{ secrets.SA_TOKEN }}' token: "${{ secrets.SA_TOKEN }}"
id: setcontext
``` ```
### Service principal approach for arc cluster ### Service principal approach for arc cluster
```yaml ```yaml
- uses: azure/k8s-set-context@v1 - uses: azure/k8s-set-context@v2
with: with:
method: service-principal method: service-principal
cluster-type: 'arc' cluster-type: arc
cluster-name: <cluster-name> cluster-name: <cluster-name>
resource-group: <resource-group> 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
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.

View File

@ -1,328 +0,0 @@
import * as run from '../src/login'
import * as arc from '../src/arc-login'
import * as os from 'os';
import * as io from '@actions/io';
import * as toolCache from '@actions/tool-cache';
import * as core from '@actions/core';
import * as fs from 'fs';
import * as jsyaml from 'js-yaml';
import * as path from 'path';
import * as child_process from 'child_process';
import * as exec from '@actions/exec';
var mockStatusCode;
const mockExecFn = jest.fn().mockImplementation(() => mockStatusCode);
jest.mock('@actions/exec/lib/toolrunner', () => {
return {
ToolRunner: jest.fn().mockImplementation(() => {
return {
exec: mockExecFn
}
})
}
});
describe('Testing all functions.', () => {
test('getArcKubeconfig() - checking execution of service-account scenario',async () =>{
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'resource-group') return 'testrg';
if (inputName == 'cluster-name') return 'testcluster';
if (inputName == 'token') return 'token';
});
jest.spyOn(io, 'which').mockResolvedValue('az');
process.env['RUNNER_TEMP'] = 'tempDirPath';
jest.spyOn(Date, 'now').mockImplementation(() => 1234561234567);
const dummy_process=await child_process.spawn('ls',[]);
const dummy_sleep_promise = new Promise(resolve => setTimeout(resolve, 0));
jest.spyOn(child_process,'spawn').mockImplementation((commands, arg, option)=>{
return dummy_process;
});
jest.spyOn(arc,'sleep').mockImplementation((ms)=>{
return dummy_sleep_promise;
});
jest.spyOn(fs, 'chmodSync').mockImplementation();
jest.spyOn(core, 'exportVariable').mockImplementation();
jest.spyOn(exec,'exec').mockImplementation();
await arc.getArcKubeconfig();
expect(core.getInput).toBeCalledTimes(4);
expect(io.which).toHaveBeenCalledWith("az",true);
expect(child_process.spawn).toHaveBeenCalledWith('az',['connectedk8s','proxy','-n','testcluster','-g','testrg','-f',path.join('tempDirPath', 'kubeconfig_1234561234567'),'--token','token'], {
detached: true,
stdio: 'ignore'
});
expect(fs.chmodSync).toHaveBeenCalledWith(path.join('tempDirPath', 'kubeconfig_1234561234567'), '600');
expect(core.exportVariable).toHaveBeenCalledWith('KUBECONFIG', path.join('tempDirPath', 'kubeconfig_1234561234567'));
},180000);
test('getArcKubeconfig() - checking execution of service-principal scenario',async () =>{
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-principal';
if (inputName == 'resource-group') return 'testrg';
if (inputName == 'cluster-name') return 'testcluster';
});
jest.spyOn(io, 'which').mockResolvedValue('az');
process.env['RUNNER_TEMP'] = 'tempDirPath';
jest.spyOn(Date, 'now').mockImplementation(() => 1234561234567);
const dummy_process=await child_process.spawn('ls',[]);
const dummy_sleep_promise = new Promise(resolve => setTimeout(resolve, 0));
jest.spyOn(child_process,'spawn').mockImplementation((commands, arg, option)=>{
return dummy_process;
});
jest.spyOn(arc,'sleep').mockImplementation((ms)=>{
return dummy_sleep_promise;
});
jest.spyOn(fs, 'chmodSync').mockImplementation();
jest.spyOn(core, 'exportVariable').mockImplementation();
jest.spyOn(exec,'exec').mockImplementation();
await arc.getArcKubeconfig();
expect(core.getInput).toBeCalledTimes(3);
expect(io.which).toHaveBeenCalledWith("az",true);
expect(child_process.spawn).toHaveBeenCalledWith('az',['connectedk8s','proxy','-n','testcluster','-g','testrg','-f',path.join('tempDirPath', 'kubeconfig_1234561234567')], {
detached: true,
stdio: 'ignore'
});
expect(fs.chmodSync).toHaveBeenCalledWith(path.join('tempDirPath', 'kubeconfig_1234561234567'), '600');
expect(core.exportVariable).toHaveBeenCalledWith('KUBECONFIG', path.join('tempDirPath', 'kubeconfig_1234561234567'));
},180000);
test('getArcKubeconfig() - wrong method passed',async ()=>{
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'someMethod';
});
await expect(arc.getArcKubeconfig()).rejects.toThrow("Supported methods for arc cluster are 'service-account' and 'service-principal'.");
expect(core.getInput).toBeCalledTimes(1);
});
test('getArcKubeconfig() - resource group not passed',async ()=>{
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'resource-group') return '';
});
await expect(arc.getArcKubeconfig()).rejects.toThrow("'resourceGroupName' is not passed for arc cluster.");
expect(core.getInput).toBeCalledTimes(3);
});
test('getArcKubeconfig() - cluster name not passed',async ()=>{
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'resource-group') return 'testrg';
if (inputName == 'cluster-name') return '';
});
await expect(arc.getArcKubeconfig()).rejects.toThrow("'clusterName' is not passed for arc cluster.");
expect(core.getInput).toBeCalledTimes(3);
});
test('getArcKubeconfig() - token not passed',async ()=>{
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'resource-group') return 'testrg';
if (inputName == 'cluster-name') return 'testcluster';
if (inputName == 'token') return '';
});
jest.spyOn(io, 'which').mockResolvedValue('az');
jest.spyOn(exec,'exec').mockImplementation();
process.env['RUNNER_TEMP'] = 'tempDirPath';
jest.spyOn(Date, 'now').mockImplementation(() => 1234561234567);
await expect(arc.getArcKubeconfig()).rejects.toThrow("'saToken' is not passed for 'service-account' method.");
expect(core.getInput).toBeCalledTimes(4);
expect(io.which).toBeCalled();
});
test('executeAzCliCommand() - testing execution of function',()=>{
jest.spyOn(exec,'exec').mockImplementation();
var azPath = "az";
expect(arc.executeAzCliCommand("some command",false));
expect(exec.exec).toBeCalled();
});
test('getExecutableExtension() - return .exe when os is Windows', () => {
jest.spyOn(os, 'type').mockReturnValue('Windows_NT');
expect(run.getExecutableExtension()).toBe('.exe');
expect(os.type).toBeCalled();
});
test('getExecutableExtension() - return empty string for non-windows OS', () => {
jest.spyOn(os, 'type').mockReturnValue('Darwin');
expect(run.getExecutableExtension()).toBe('');
expect(os.type).toBeCalled();
});
test('getKubectlPath() - return path to existing kubectl', async () => {
jest.spyOn(io, 'which').mockResolvedValue('pathToKubectl');
expect(await run.getKubectlPath()).toBe('pathToKubectl');
expect(io.which).toBeCalled();
});
test('getKubectlPath() - throw error when kubectl not installed', async () => {
jest.spyOn(io, 'which').mockResolvedValue('');
jest.spyOn(toolCache, 'findAllVersions').mockReturnValue([]);
await expect(run.getKubectlPath()).rejects.toThrow('Kubectl is not installed');
expect(io.which).toBeCalled();
expect(toolCache.findAllVersions).toBeCalled();
});
test('getKubectlPath() - return path to kubectl in toolCache', async () => {
jest.spyOn(io, 'which').mockResolvedValue('');
jest.spyOn(toolCache, 'findAllVersions').mockReturnValue(['v1.15.0']);
jest.spyOn(toolCache, 'find').mockReturnValue('pathToTool');
jest.spyOn(os, 'type').mockReturnValue('Windows_NT');
expect(await run.getKubectlPath()).toBe(path.join('pathToTool', 'kubectl.exe'));
expect(io.which).toBeCalled();
expect(toolCache.findAllVersions).toBeCalled();
expect(toolCache.find).toBeCalledWith('kubectl', 'v1.15.0');
});
test('getKubeconfig() - throw error on invalid input', () => {
jest.spyOn(core, 'getInput').mockReturnValue('invalid');
expect(() => run.getKubeconfig()).toThrow('Invalid method specified. Acceptable values are kubeconfig and service-account.');
expect(core.getInput).toBeCalled();
});
test('getKubeconfig() - return kubeconfig from input', () => {
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'kubeconfig';
if (inputName == 'kubeconfig') return '###';
});
jest.spyOn(core, 'debug').mockImplementation();
expect(run.getKubeconfig()).toBe('###');
expect(core.getInput).toBeCalledWith('method', { required: true });
expect(core.getInput).toBeCalledWith('kubeconfig', { required: true });
});
test('getKubeconfig() - create kubeconfig from secret provided and return it', () => {
jest.spyOn(core, 'debug').mockImplementation();
const k8Secret = fs.readFileSync('__tests__/sample-secret.yml').toString();
const kubeconfig = JSON.stringify({
"apiVersion": "v1",
"kind": "Config",
"clusters": [
{
"cluster": {
"certificate-authority-data": 'LS0tLS1CRUdJTiBDRWyUSUZJQ',
"server": 'https://testing-dns-4za.hfp.earth.azmk8s.io:443'
}
}
],
"users": [
{
"user": {
"token": Buffer.from('ZXlKaGJHY2lPcUpTVXpJMU5pSX=', 'base64').toString()
}
}
]
});
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'k8s-url') return 'https://testing-dns-4za.hfp.earth.azmk8s.io:443';
if (inputName == 'k8s-secret') return k8Secret;
});
expect(run.getKubeconfig()).toBe(kubeconfig);
expect(core.getInput).toBeCalledTimes(3);
});
test('getKubeconfig() - throw error if empty config provided', () => {
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'k8s-url') return 'https://testing-dns-4da.hcp.earth.azmk8s.io:443';
if (inputName == 'k8s-secret') return '';
});
expect(() => run.getKubeconfig()).toThrow('The service account secret yaml specified is invalid. Make sure that its a valid yaml and try again.');
expect(core.getInput).toBeCalledTimes(3);
});
test('getKubeconfig() - throw error if data field doesn\'t exist', () => {
var k8SecretYaml = fs.readFileSync('__tests__/sample-secret.yml').toString();
var k8SecretObject = jsyaml.safeLoad(k8SecretYaml);
delete k8SecretObject['data'];
k8SecretYaml = jsyaml.dump(k8SecretObject);
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'k8s-url') return 'https://testing-dns-4da.hcp.earth.azmk8s.io:443';
if (inputName == 'k8s-secret') return k8SecretYaml;
});
expect(() => run.getKubeconfig()).toThrow('The service account secret yaml does not contain data; field. Make sure that its present and try again.');
expect(core.getInput).toBeCalledTimes(3);
});
test('getKubeconfig() - throw error if data.token field doesn\'t exist', () => {
var k8SecretYaml = fs.readFileSync('__tests__/sample-secret.yml').toString();
var k8SecretObject = jsyaml.safeLoad(k8SecretYaml);
delete k8SecretObject['data']['token'];
k8SecretYaml = jsyaml.dump(k8SecretObject);
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'k8s-url') return 'https://testing-dns-4da.hcp.earth.azmk8s.io:443';
if (inputName == 'k8s-secret') return k8SecretYaml;
});
expect(() => run.getKubeconfig()).toThrow('The service account secret yaml does not contain data.token; field. Make sure that its present and try again.');
expect(core.getInput).toBeCalledTimes(3);
});
test('getKubeconfig() - throw error if data[ca.crt] field doesn\'t exist', () => {
var k8SecretYaml = fs.readFileSync('__tests__/sample-secret.yml').toString();
var k8SecretObject = jsyaml.safeLoad(k8SecretYaml);
delete k8SecretObject['data']['ca.crt'];
k8SecretYaml = jsyaml.dump(k8SecretObject);
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'service-account';
if (inputName == 'k8s-url') return 'https://testing-dns-4da.hcp.earth.azmk8s.io:443';
if (inputName == 'k8s-secret') return k8SecretYaml;
});
expect(() => run.getKubeconfig()).toThrow('The service account secret yaml does not contain data[ca.crt]; field. Make sure that its present and try again.');
expect(core.getInput).toBeCalledTimes(3);
});
test('setContext() - set context using kubectl', async () => {
jest.spyOn(core, 'getInput').mockReturnValue('abc');
jest.spyOn(io, 'which').mockResolvedValue('pathToKubectl');
mockStatusCode = 0;
expect(await run.setContext('/pathToKubeconfig'));
expect(mockExecFn).toBeCalledTimes(2);
});
test('run() - create kubeconfig, exportvariable and give appropriate access', async () => {
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
jest.spyOn(core, 'exportVariable').mockImplementation(() => {});
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'method') return 'kubeconfig';
if (inputName == 'kubeconfig') return '###';
if (inputName == 'context') return '';
if (inputName == 'cluster-type') return 'generic';
});
process.env['RUNNER_TEMP'] = 'tempDirPath'
jest.spyOn(Date, 'now').mockImplementation(() => 1234561234567);
expect(run.run());
expect(fs.writeFileSync).toHaveBeenCalledWith(path.join('tempDirPath', 'kubeconfig_1234561234567'), '###');
expect(fs.chmodSync).toHaveBeenCalledWith(path.join('tempDirPath', 'kubeconfig_1234561234567'), '600');
expect(core.exportVariable).toHaveBeenCalledWith('KUBECONFIG', path.join('tempDirPath', 'kubeconfig_1234561234567'));
});
test('run() - check if arc scenario is getting triggered', async () =>{
jest.spyOn(arc,'getArcKubeconfig').mockImplementation();
jest.spyOn(core, 'getInput').mockImplementation((inputName, options) => {
if (inputName == 'cluster-type') return 'arc';
});
expect(run.run());
expect(arc.getArcKubeconfig).toBeCalled();
});
});

View File

@ -1,47 +1,39 @@
name: 'Kubernetes set context' name: "Kubernetes Set Context"
description: 'Kubernetes set context' description: "Set the context of a target Kubernetes cluster and export the kubeconfig which will be used by other actions like azure/k8s-deploy"
inputs: 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 # Please ensure you have used azure/login in the workflow before this action
cluster-type: cluster-type:
description: 'Acceptable values: generic or arc' description: "Acceptable values: generic or arc"
required: true required: true
default: 'generic' default: "generic"
method: method:
description: 'Acceptable values: kubeconfig or service-account or service-principal' description: "Acceptable values: kubeconfig or service-account or service-principal"
required: true required: true
default: 'kubeconfig' default: "kubeconfig"
kubeconfig: kubeconfig:
description: 'Contents of kubeconfig file' description: "Contents of kubeconfig file"
required: false required: false
default: ''
context: context:
description: 'If your kubeconfig has multiple contexts, use this field to use a specific context, otherwise the default one would be chosen' description: "If your kubeconfig has multiple contexts, use this field to use a specific context, otherwise the default one would be chosen"
required: false required: false
default: ''
k8s-url: k8s-url:
description: 'Cluster Url' description: "Cluster Url"
required: false required: false
default: ''
k8s-secret: k8s-secret:
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)"
required: false 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
default: ''
resource-group: resource-group:
description: 'Azure resource group name' description: "Azure resource group name"
required: false required: false
default: ''
cluster-name: cluster-name:
description: 'Azure connected cluster name' description: "Azure connected cluster name"
required: false required: false
default: ''
branding: branding:
color: 'green' # optional, decorates the entry in the GitHub Marketplace color: "blue"
runs: runs:
using: 'node12' using: "node12"
main: 'lib/login.js' main: "lib/run.js"

View File

@ -1,18 +1,20 @@
module.exports = { module.exports = {
restoreMocks: true,
clearMocks: true, clearMocks: true,
moduleFileExtensions: ['js', 'ts'], resetMocks: true,
testEnvironment: 'node', moduleFileExtensions: ["js", "ts"],
testMatch: ['**/*.test.ts'], testEnvironment: "node",
testMatch: ["**/*.test.ts"],
transform: { transform: {
'^.+\\.ts$': 'ts-jest' "^.+\\.ts$": "ts-jest",
}, },
verbose: true, verbose: true,
coverageThreshold: { coverageThreshold: {
"global": { global: {
"branches": 0, branches: 0,
"functions": 40, functions: 40,
"lines": 22, lines: 22,
"statements": 22 statements: 22,
} },
} },
} };

View File

@ -1,94 +0,0 @@
"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 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 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');
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'
});
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);
}
});
}
exports.getArcKubeconfig = getArcKubeconfig;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
exports.sleep = sleep;
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);
}
});
}
exports.executeAzCliCommand = executeAzCliCommand;

84
lib/kubeconfigs/arc.js Normal file
View File

@ -0,0 +1,84 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
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 });
exports.getArcKubeconfig = exports.KUBECONFIG_LOCATION = void 0;
const core = __importStar(require("@actions/core"));
const io = __importStar(require("@actions/io"));
const method_1 = require("../types/method");
const path = __importStar(require("path"));
const azCommands_1 = require("./azCommands");
const RUNNER_TEMP = process.env["RUNNER_TEMP"] || "";
exports.KUBECONFIG_LOCATION = path.join(RUNNER_TEMP, `arc_kubeconfig_${Date.now()}`);
/**
* Gets the kubeconfig based on provided method for an Arc Kubernetes cluster
* @returns The kubeconfig wrapped in a Promise
*/
function getArcKubeconfig() {
return __awaiter(this, void 0, void 0, function* () {
const resourceGroupName = core.getInput("resource-group", { required: true });
const clusterName = core.getInput("cluster-name", { required: true });
const azPath = yield io.which("az", true);
const method = method_1.parseMethod(core.getInput("method", { required: true }));
yield azCommands_1.runAzCliCommand(azPath, ["extension", "add", "-n", "connectedk8s"]);
switch (method) {
case method_1.Method.SERVICE_ACCOUNT:
const saToken = core.getInput("token", { required: true });
return yield azCommands_1.runAzKubeconfigCommandBlocking(azPath, [
"connectedk8s",
"proxy",
"-n",
clusterName,
"-g",
resourceGroupName,
"--token",
saToken,
"-f",
exports.KUBECONFIG_LOCATION,
], exports.KUBECONFIG_LOCATION);
case method_1.Method.SERVICE_PRINCIPAL:
return yield azCommands_1.runAzKubeconfigCommandBlocking(azPath, [
"connectedk8s",
"proxy",
"-n",
clusterName,
"-g",
resourceGroupName,
"-f",
exports.KUBECONFIG_LOCATION,
], exports.KUBECONFIG_LOCATION);
case undefined:
core.warning("Defaulting to kubeconfig method");
case method_1.Method.KUBECONFIG:
default:
throw Error("Kubeconfig method not supported for Arc cluster");
}
});
}
exports.getArcKubeconfig = getArcKubeconfig;

View File

@ -0,0 +1,67 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
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 });
exports.runAzKubeconfigCommandBlocking = exports.runAzCliCommand = void 0;
const fs = __importStar(require("fs"));
const exec_1 = require("@actions/exec");
const child_process_1 = require("child_process");
const AZ_TIMEOUT_SECONDS = 120;
/**
* Executes an az cli command
* @param azPath The path to the az tool
* @param args The arguments to be invoked
* @param options Optional options for the command execution
*/
function runAzCliCommand(azPath, args, options = {}) {
return __awaiter(this, void 0, void 0, function* () {
yield exec_1.exec(azPath, args, options);
});
}
exports.runAzCliCommand = runAzCliCommand;
/**
* Executes an az cli command that will set the kubeconfig
* @param azPath The path to the az tool
* @param args The arguments to be be invoked
* @param kubeconfigPath The path to the kubeconfig that is updated by the command
* @returns The contents of the kubeconfig
*/
function runAzKubeconfigCommandBlocking(azPath, args, kubeconfigPath) {
return __awaiter(this, void 0, void 0, function* () {
const proc = child_process_1.spawn(azPath, args, {
detached: true,
stdio: "ignore",
});
proc.unref();
yield sleep(AZ_TIMEOUT_SECONDS);
return fs.readFileSync(kubeconfigPath).toString();
});
}
exports.runAzKubeconfigCommandBlocking = runAzKubeconfigCommandBlocking;
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

View File

@ -0,0 +1,87 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createKubeconfig = exports.getDefaultKubeconfig = void 0;
const core = __importStar(require("@actions/core"));
const jsyaml = __importStar(require("js-yaml"));
const k8sSecret_1 = require("../types/k8sSecret");
const method_1 = require("../types/method");
/**
* Gets the kubeconfig based on provided method for a default Kubernetes cluster
* @returns The kubeconfig
*/
function getDefaultKubeconfig() {
const method = method_1.parseMethod(core.getInput("method", { required: true }));
switch (method) {
case method_1.Method.SERVICE_ACCOUNT: {
const clusterUrl = core.getInput("k8s-url", { required: true });
core.debug("Found clusterUrl. Creating kubeconfig using certificate and token");
const k8sSecret = core.getInput("k8s-secret", {
required: true,
});
const parsedK8sSecret = k8sSecret_1.parseK8sSecret(jsyaml.load(k8sSecret));
const certAuth = parsedK8sSecret.data["ca.crt"];
const token = Buffer.from(parsedK8sSecret.data.token, "base64").toString();
return createKubeconfig(certAuth, token, clusterUrl);
}
case method_1.Method.SERVICE_PRINCIPAL: {
core.warning("Service Principal method not supported for default cluster type");
}
case undefined: {
core.warning("Defaulting to kubeconfig method");
}
default: {
core.debug("Setting context using kubeconfig");
return core.getInput("kubeconfig", { required: true });
}
}
}
exports.getDefaultKubeconfig = getDefaultKubeconfig;
/**
* Creates a kubeconfig and returns the string representation
* @param certAuth The certificate authentication of the cluster
* @param token The user token
* @param clusterUrl The server url of the cluster
* @returns The kubeconfig as a string
*/
function createKubeconfig(certAuth, token, clusterUrl) {
const kubeconfig = {
apiVersion: "v1",
kind: "Config",
clusters: [
{
cluster: {
"certificate-authority-data": certAuth,
server: clusterUrl,
},
},
],
users: [
{
user: {
token: token,
},
},
],
};
return JSON.stringify(kubeconfig);
}
exports.createKubeconfig = createKubeconfig;

View File

@ -1,140 +0,0 @@
"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 path = require("path");
const fs = require("fs");
const io = require("@actions/io");
const toolCache = require("@actions/tool-cache");
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') {
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 });
var parsedk8sSecret = jsyaml.safeLoad(k8sSecret);
let kubernetesServiceAccountSecretFieldNotPresent = 'The service account secret yaml does not contain %s; field. Make sure that its present and try again.';
if (!parsedk8sSecret) {
throw Error("The service account secret yaml specified is invalid. Make sure that its a valid yaml and try again.");
}
if (!parsedk8sSecret.data) {
throw Error(util.format(kubernetesServiceAccountSecretFieldNotPresent, "data"));
}
if (!parsedk8sSecret.data.token) {
throw Error(util.format(kubernetesServiceAccountSecretFieldNotPresent, "data.token"));
}
if (!parsedk8sSecret.data["ca.crt"]) {
throw Error(util.format(kubernetesServiceAccountSecretFieldNotPresent, "data[ca.crt]"));
}
const certAuth = parsedk8sSecret.data["ca.crt"];
const token = Buffer.from(parsedk8sSecret.data.token, 'base64').toString();
const kubeconfigObject = {
"apiVersion": "v1",
"kind": "Config",
"clusters": [
{
"cluster": {
"certificate-authority-data": certAuth,
"server": clusterUrl
}
}
],
"users": [
{
"user": {
"token": token
}
}
]
};
return JSON.stringify(kubeconfigObject);
}
else {
throw Error("Invalid method specified. Acceptable values are kubeconfig and service-account.");
}
}
exports.getKubeconfig = getKubeconfig;
function getExecutableExtension() {
if (os.type().match(/^Win/)) {
return '.exe';
}
return '';
}
exports.getExecutableExtension = getExecutableExtension;
function getKubectlPath() {
return __awaiter(this, void 0, void 0, function* () {
let kubectlPath = yield io.which('kubectl', false);
if (!kubectlPath) {
const allVersions = toolCache.findAllVersions('kubectl');
kubectlPath = allVersions.length > 0 ? toolCache.find('kubectl', allVersions[0]) : '';
if (!kubectlPath) {
throw new Error('Kubectl is not installed');
}
kubectlPath = path.join(kubectlPath, `kubectl${getExecutableExtension()}`);
}
return kubectlPath;
});
}
exports.getKubectlPath = getKubectlPath;
function setContext(kubeconfigPath) {
return __awaiter(this, void 0, void 0, function* () {
let context = core.getInput('context');
if (context) {
//To use kubectl commands, the environment variable KUBECONFIG needs to be set for this step
process.env['KUBECONFIG'] = kubeconfigPath;
const kubectlPath = yield getKubectlPath();
let toolRunner = new toolrunner_1.ToolRunner(kubectlPath, ['config', 'use-context', context]);
yield toolRunner.exec();
toolRunner = new toolrunner_1.ToolRunner(kubectlPath, ['config', 'current-context']);
yield toolRunner.exec();
}
});
}
exports.setContext = setContext;
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
let kubeconfig = '';
const cluster_type = core.getInput('cluster-type', { required: true });
if (cluster_type == 'arc') {
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);
}
}
catch (ex) {
return Promise.reject(ex);
}
});
}
exports.run = run;
run().catch(core.setFailed);

61
lib/run.js Normal file
View File

@ -0,0 +1,61 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
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 });
exports.run = void 0;
const core = __importStar(require("@actions/core"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const cluster_1 = require("./types/cluster");
const utils_1 = require("./utils");
/**
* Sets the Kubernetes context based on supplied action inputs
*/
function run() {
return __awaiter(this, void 0, void 0, function* () {
// get inputs
const clusterType = cluster_1.parseCluster(core.getInput("cluster-type", {
required: true,
}));
const runnerTempDirectory = process.env["RUNNER_TEMP"];
const kubeconfigPath = path.join(runnerTempDirectory, `kubeconfig_${Date.now()}`);
// get kubeconfig and update context
const kubeconfig = yield utils_1.getKubeconfig(clusterType);
const kubeconfigWithContext = utils_1.setContext(kubeconfig);
// output kubeconfig
core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`);
fs.writeFileSync(kubeconfigPath, kubeconfigWithContext);
fs.chmodSync(kubeconfigPath, "600");
core.debug("Setting KUBECONFIG environment variable");
core.exportVariable("KUBECONFIG", kubeconfigPath);
});
}
exports.run = run;
// Run the application
run().catch(core.setFailed);

14
lib/types/cluster.js Normal file
View File

@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseCluster = exports.Cluster = void 0;
var Cluster;
(function (Cluster) {
Cluster["ARC"] = "arc";
Cluster["GENERIC"] = "generic";
})(Cluster = exports.Cluster || (exports.Cluster = {}));
/**
* Converts a string to the Cluster enum
* @param str The cluster type (case insensitive)
* @returns The Cluster enum or undefined if it can't be parsed
*/
exports.parseCluster = (str) => Cluster[Object.keys(Cluster).filter((k) => Cluster[k].toString().toLowerCase() === str.toLowerCase())[0]];

41
lib/types/k8sSecret.js Normal file
View File

@ -0,0 +1,41 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseK8sSecret = void 0;
const util = __importStar(require("util"));
/**
* Throws an error if an object does not have all required fields to be a K8sSecret
* @param secret
* @returns A type guarded K8sSecret
*/
function parseK8sSecret(secret) {
if (!secret)
throw Error("K8s secret yaml is invalid");
if (!secret.data)
throw k8sSecretMissingFieldError("data");
if (!secret.data.token)
throw k8sSecretMissingFieldError("token");
if (!secret.data["ca.crt"])
throw k8sSecretMissingFieldError("ca.crt");
return secret;
}
exports.parseK8sSecret = parseK8sSecret;
const k8sSecretMissingFieldError = (field) => Error(util.format("K8s secret yaml does not contain %s field", field));

15
lib/types/method.js Normal file
View File

@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseMethod = exports.Method = void 0;
var Method;
(function (Method) {
Method["KUBECONFIG"] = "kubeconfig";
Method["SERVICE_ACCOUNT"] = "service-account";
Method["SERVICE_PRINCIPAL"] = "service-principal";
})(Method = exports.Method || (exports.Method = {}));
/**
* Converts a string to the Method enum
* @param str The method (case insensitive)
* @returns The Method enum or undefined if it can't be parsed
*/
exports.parseMethod = (str) => Method[Object.keys(Method).filter((k) => Method[k].toString().toLowerCase() === str.toLowerCase())[0]];

76
lib/utils.js Normal file
View File

@ -0,0 +1,76 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
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 });
exports.setContext = exports.getKubeconfig = void 0;
const core = __importStar(require("@actions/core"));
const client_node_1 = require("@kubernetes/client-node");
const default_1 = require("./kubeconfigs/default");
const arc_1 = require("./kubeconfigs/arc");
const cluster_1 = require("./types/cluster");
/**
* Gets the kubeconfig based on Kubernetes cluster type
* @param type The cluster type for the kubeconfig (defaults to generic)
* @returns A promise of the kubeconfig
*/
function getKubeconfig(type) {
return __awaiter(this, void 0, void 0, function* () {
switch (type) {
case cluster_1.Cluster.ARC: {
return yield arc_1.getArcKubeconfig();
}
case undefined: {
core.warning("Cluster type not recognized. Defaulting to generic.");
}
default: {
return default_1.getDefaultKubeconfig();
}
}
});
}
exports.getKubeconfig = getKubeconfig;
/**
* Sets the context by updating the kubeconfig
* @param kubeconfig The kubeconfig
* @returns Updated kubeconfig with the context
*/
function setContext(kubeconfig) {
const context = core.getInput("context");
if (!context) {
core.debug("Can't set context because context is unspecified.");
return kubeconfig;
}
// load current kubeconfig
const kc = new client_node_1.KubeConfig();
kc.loadFromString(kubeconfig);
// update kubeconfig
kc.setCurrentContext(context);
return kc.exportConfig();
}
exports.setContext = setContext;

20856
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,33 @@
{ {
"name": "k8s-set-context-action", "name": "k8s-set-context-action",
"version": "0.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "lib/run.js", "main": "lib/run.js",
"scripts": { "scripts": {
"build": "tsc --outDir ./lib --rootDir ./src", "build": "tsc --outDir ./lib --rootDir ./src",
"test": "jest", "test": "jest",
"test-coverage": "jest --coverage" "test-coverage": "jest --coverage"
}, },
"keywords": [ "keywords": [
"actions", "actions",
"node", "node",
"setup" "setup"
], ],
"author": "GitHub", "author": "GitHub",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/core": "^1.2.6", "@actions/core": "^1.2.6",
"@actions/exec": "^1.0.0", "@actions/exec": "^1.0.0",
"@actions/tool-cache": "^1.0.0", "@actions/tool-cache": "^1.0.0",
"js-yaml": "^3.13.1" "@kubernetes/client-node": "^0.16.0",
}, "js-yaml": "^3.13.1"
"devDependencies": { },
"@types/jest": "^25.2.2", "devDependencies": {
"@types/node": "^12.0.4", "@types/jest": "^25.2.2",
"jest": "^26.6.3", "@types/js-yaml": "^4.0.4",
"ts-jest": "^25.5.1", "@types/node": "^12.0.4",
"typescript": "3.9.2" "jest": "^26.6.3",
} "ts-jest": "^25.5.1",
"typescript": "3.9.2"
}
} }

View File

@ -1,83 +0,0 @@
import * as core from '@actions/core';
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 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');
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.")
}
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'
});
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);
}
}
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export 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);
}
}

103
src/kubeconfigs/arc.test.ts Normal file
View File

@ -0,0 +1,103 @@
import * as actions from "@actions/exec";
import * as io from "@actions/io";
import { getRequiredInputError } from "../../tests/util";
import { getArcKubeconfig, KUBECONFIG_LOCATION } from "./arc";
import * as az from "./azCommands";
describe("Arc kubeconfig", () => {
test("it throws error without resource group", async () => {
await expect(getArcKubeconfig()).rejects.toThrow(
getRequiredInputError("resource-group")
);
});
test("it throws error without cluster name", async () => {
process.env["INPUT_RESOURCE-GROUP"] = "group";
await expect(getArcKubeconfig()).rejects.toThrow(
getRequiredInputError("cluster-name")
);
});
describe("runs az cli commands", () => {
const group = "group";
const name = "name";
const path = "path";
const kubeconfig = "kubeconfig";
beforeEach(() => {
process.env["INPUT_RESOURCE-GROUP"] = group;
process.env["INPUT_CLUSTER-NAME"] = name;
jest.spyOn(io, "which").mockImplementation(async () => path);
jest.spyOn(az, "runAzCliCommand").mockImplementation(async () => {});
jest
.spyOn(az, "runAzKubeconfigCommandBlocking")
.mockImplementation(async () => kubeconfig);
});
it("throws an error without method", async () => {
await expect(getArcKubeconfig()).rejects.toThrow(
getRequiredInputError("method")
);
});
describe("service account method", () => {
beforeEach(() => {
process.env["INPUT_METHOD"] = "service-account";
});
it("throws an error without token", async () => {
await expect(getArcKubeconfig()).rejects.toThrow(
getRequiredInputError("token")
);
});
it("gets the kubeconfig", async () => {
const token = "token";
process.env["INPUT_TOKEN"] = token;
expect(await getArcKubeconfig()).toBe(kubeconfig);
expect(az.runAzKubeconfigCommandBlocking).toHaveBeenCalledWith(
path,
[
"connectedk8s",
"proxy",
"-n",
name,
"-g",
group,
"--token",
token,
"-f",
KUBECONFIG_LOCATION,
],
KUBECONFIG_LOCATION
);
});
});
describe("service principal method", () => {
beforeEach(() => {
process.env["INPUT_METHOD"] = "service-principal";
});
it("gets the kubeconfig", async () => {
expect(await getArcKubeconfig()).toBe(kubeconfig);
expect(az.runAzKubeconfigCommandBlocking).toHaveBeenCalledWith(
path,
[
"connectedk8s",
"proxy",
"-n",
name,
"-g",
group,
"-f",
KUBECONFIG_LOCATION,
],
KUBECONFIG_LOCATION
);
});
});
});
});

68
src/kubeconfigs/arc.ts Normal file
View File

@ -0,0 +1,68 @@
import * as core from "@actions/core";
import * as io from "@actions/io";
import { Method, parseMethod } from "../types/method";
import * as path from "path";
import { runAzCliCommand, runAzKubeconfigCommandBlocking } from "./azCommands";
const RUNNER_TEMP: string = process.env["RUNNER_TEMP"] || "";
export const KUBECONFIG_LOCATION: string = path.join(
RUNNER_TEMP,
`arc_kubeconfig_${Date.now()}`
);
/**
* Gets the kubeconfig based on provided method for an Arc Kubernetes cluster
* @returns The kubeconfig wrapped in a Promise
*/
export async function getArcKubeconfig(): Promise<string> {
const resourceGroupName = core.getInput("resource-group", { required: true });
const clusterName = core.getInput("cluster-name", { required: true });
const azPath = await io.which("az", true);
const method: Method | undefined = parseMethod(
core.getInput("method", { required: true })
);
await runAzCliCommand(azPath, ["extension", "add", "-n", "connectedk8s"]);
switch (method) {
case Method.SERVICE_ACCOUNT:
const saToken = core.getInput("token", { required: true });
return await runAzKubeconfigCommandBlocking(
azPath,
[
"connectedk8s",
"proxy",
"-n",
clusterName,
"-g",
resourceGroupName,
"--token",
saToken,
"-f",
KUBECONFIG_LOCATION,
],
KUBECONFIG_LOCATION
);
case Method.SERVICE_PRINCIPAL:
return await runAzKubeconfigCommandBlocking(
azPath,
[
"connectedk8s",
"proxy",
"-n",
clusterName,
"-g",
resourceGroupName,
"-f",
KUBECONFIG_LOCATION,
],
KUBECONFIG_LOCATION
);
case undefined:
core.warning("Defaulting to kubeconfig method");
case Method.KUBECONFIG:
default:
throw Error("Kubeconfig method not supported for Arc cluster");
}
}

View File

@ -0,0 +1,14 @@
import * as actions from "@actions/exec";
import { runAzCliCommand } from "./azCommands";
describe("Az commands", () => {
test("it runs an az cli command", async () => {
const path = "path";
const args = ["args"];
jest.spyOn(actions, "exec").mockImplementation(async () => 0);
expect(await runAzCliCommand(path, args));
expect(actions.exec).toBeCalledWith(path, args, {});
});
});

View File

@ -0,0 +1,44 @@
import * as fs from "fs";
import { ExecOptions } from "@actions/exec/lib/interfaces";
import { exec } from "@actions/exec";
import { spawn } from "child_process";
const AZ_TIMEOUT_SECONDS: number = 120;
/**
* Executes an az cli command
* @param azPath The path to the az tool
* @param args The arguments to be invoked
* @param options Optional options for the command execution
*/
export async function runAzCliCommand(
azPath: string,
args: string[],
options: ExecOptions = {}
) {
await exec(azPath, args, options);
}
/**
* Executes an az cli command that will set the kubeconfig
* @param azPath The path to the az tool
* @param args The arguments to be be invoked
* @param kubeconfigPath The path to the kubeconfig that is updated by the command
* @returns The contents of the kubeconfig
*/
export async function runAzKubeconfigCommandBlocking(
azPath: string,
args: string[],
kubeconfigPath: string
): Promise<string> {
const proc = spawn(azPath, args, {
detached: true,
stdio: "ignore",
});
proc.unref();
await sleep(AZ_TIMEOUT_SECONDS);
return fs.readFileSync(kubeconfigPath).toString();
}
const sleep = (seconds: number) =>
new Promise((resolve) => setTimeout(resolve, seconds * 1000));

View File

@ -0,0 +1,128 @@
import * as fs from "fs";
import { getRequiredInputError } from "../../tests/util";
import { createKubeconfig, getDefaultKubeconfig } from "./default";
describe("Default kubeconfig", () => {
test("it creates a kubeconfig with proper format", () => {
const certAuth = "certAuth";
const token = "token";
const clusterUrl = "clusterUrl";
const kc = createKubeconfig(certAuth, token, clusterUrl);
const expected = JSON.stringify({
apiVersion: "v1",
kind: "Config",
clusters: [
{
cluster: {
"certificate-authority-data": certAuth,
server: clusterUrl,
},
},
],
users: [
{
user: {
token: token,
},
},
],
});
expect(kc).toBe(expected);
});
test("it throws error without method", () => {
expect(() => getDefaultKubeconfig()).toThrow(
getRequiredInputError("method")
);
});
describe("default method", () => {
beforeEach(() => {
process.env["INPUT_METHOD"] = "default";
});
test("it throws error without kubeconfig", () => {
expect(() => getDefaultKubeconfig()).toThrow(
getRequiredInputError("kubeconfig")
);
});
test("it gets default config through kubeconfig input", () => {
const kc = "example kc";
process.env["INPUT_KUBECONFIG"] = kc;
expect(getDefaultKubeconfig()).toBe(kc);
});
});
test("it defaults to default method", () => {
process.env["INPUT_METHOD"] = "unknown";
const kc = "example kc";
process.env["INPUT_KUBECONFIG"] = kc;
expect(getDefaultKubeconfig()).toBe(kc);
});
test("it defaults to default method from service-principal", () => {
process.env["INPUT_METHOD"] = "service-principal";
const kc = "example kc";
process.env["INPUT_KUBECONFIG"] = kc;
expect(getDefaultKubeconfig()).toBe(kc);
});
describe("service-account method", () => {
beforeEach(() => {
process.env["INPUT_METHOD"] = "service-account";
});
test("it throws error without cluster url", () => {
expect(() => getDefaultKubeconfig()).toThrow(
getRequiredInputError("k8s-url")
);
});
test("it throws error without k8s secret", () => {
process.env["INPUT_K8S-URL"] = "url";
expect(() => getDefaultKubeconfig()).toThrow(
getRequiredInputError("k8s-secret")
);
});
test("it gets kubeconfig through service-account", () => {
const k8sUrl = "https://testing-dns-4za.hfp.earth.azmk8s.io:443";
const token = "ZXlKaGJHY2lPcUpTVXpJMU5pSX=";
const cert = "LS0tLS1CRUdJTiBDRWyUSUZJQ";
const k8sSecret = fs.readFileSync("tests/sample-secret.yml").toString();
process.env["INPUT_K8S-URL"] = k8sUrl;
process.env["INPUT_K8S-SECRET"] = k8sSecret;
const expectedConfig = JSON.stringify({
apiVersion: "v1",
kind: "Config",
clusters: [
{
cluster: {
"certificate-authority-data": cert,
server: k8sUrl,
},
},
],
users: [
{
user: {
token: Buffer.from(token, "base64").toString(),
},
},
],
});
expect(getDefaultKubeconfig()).toBe(expectedConfig);
});
});
});

View File

@ -0,0 +1,82 @@
import * as core from "@actions/core";
import * as jsyaml from "js-yaml";
import { K8sSecret, parseK8sSecret } from "../types/k8sSecret";
import { Method, parseMethod } from "../types/method";
/**
* Gets the kubeconfig based on provided method for a default Kubernetes cluster
* @returns The kubeconfig
*/
export function getDefaultKubeconfig(): string {
const method: Method | undefined = parseMethod(
core.getInput("method", { required: true })
);
switch (method) {
case Method.SERVICE_ACCOUNT: {
const clusterUrl = core.getInput("k8s-url", { required: true });
core.debug(
"Found clusterUrl. Creating kubeconfig using certificate and token"
);
const k8sSecret: string = core.getInput("k8s-secret", {
required: true,
});
const parsedK8sSecret: K8sSecret = parseK8sSecret(jsyaml.load(k8sSecret));
const certAuth: string = parsedK8sSecret.data["ca.crt"];
const token: string = Buffer.from(
parsedK8sSecret.data.token,
"base64"
).toString();
return createKubeconfig(certAuth, token, clusterUrl);
}
case Method.SERVICE_PRINCIPAL: {
core.warning(
"Service Principal method not supported for default cluster type"
);
}
case undefined: {
core.warning("Defaulting to kubeconfig method");
}
default: {
core.debug("Setting context using kubeconfig");
return core.getInput("kubeconfig", { required: true });
}
}
}
/**
* Creates a kubeconfig and returns the string representation
* @param certAuth The certificate authentication of the cluster
* @param token The user token
* @param clusterUrl The server url of the cluster
* @returns The kubeconfig as a string
*/
export function createKubeconfig(
certAuth: string,
token: string,
clusterUrl: string
): string {
const kubeconfig = {
apiVersion: "v1",
kind: "Config",
clusters: [
{
cluster: {
"certificate-authority-data": certAuth,
server: clusterUrl,
},
},
],
users: [
{
user: {
token: token,
},
},
],
};
return JSON.stringify(kubeconfig);
}

View File

@ -1,133 +0,0 @@
import * as core from '@actions/core';
import * as path from 'path';
import * as fs from 'fs';
import * as io from '@actions/io';
import * as toolCache from '@actions/tool-cache';
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';
export function getKubeconfig(): string {
const method = core.getInput('method', { required: true });
if (method == 'kubeconfig') {
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 });
var parsedk8sSecret = jsyaml.safeLoad(k8sSecret);
let kubernetesServiceAccountSecretFieldNotPresent = 'The service account secret yaml does not contain %s; field. Make sure that its present and try again.';
if (!parsedk8sSecret) {
throw Error("The service account secret yaml specified is invalid. Make sure that its a valid yaml and try again.");
}
if (!parsedk8sSecret.data) {
throw Error(util.format(kubernetesServiceAccountSecretFieldNotPresent, "data"));
}
if (!parsedk8sSecret.data.token) {
throw Error(util.format(kubernetesServiceAccountSecretFieldNotPresent, "data.token"));
}
if (!parsedk8sSecret.data["ca.crt"]) {
throw Error(util.format(kubernetesServiceAccountSecretFieldNotPresent, "data[ca.crt]"));
}
const certAuth = parsedk8sSecret.data["ca.crt"];
const token = Buffer.from(parsedk8sSecret.data.token, 'base64').toString();
const kubeconfigObject = {
"apiVersion": "v1",
"kind": "Config",
"clusters": [
{
"cluster": {
"certificate-authority-data": certAuth,
"server": clusterUrl
}
}
],
"users": [
{
"user": {
"token": token
}
}
]
};
return JSON.stringify(kubeconfigObject);
}
else {
throw Error("Invalid method specified. Acceptable values are kubeconfig and service-account.");
}
}
export function getExecutableExtension(): string {
if (os.type().match(/^Win/)) {
return '.exe';
}
return '';
}
export async function getKubectlPath() {
let kubectlPath = await io.which('kubectl', false);
if (!kubectlPath) {
const allVersions = toolCache.findAllVersions('kubectl');
kubectlPath = allVersions.length > 0 ? toolCache.find('kubectl', allVersions[0]) : '';
if (!kubectlPath) {
throw new Error('Kubectl is not installed');
}
kubectlPath = path.join(kubectlPath, `kubectl${getExecutableExtension()}`);
}
return kubectlPath;
}
export async function setContext(kubeconfigPath: string) {
let context = core.getInput('context');
if (context) {
//To use kubectl commands, the environment variable KUBECONFIG needs to be set for this step
process.env['KUBECONFIG'] = kubeconfigPath;
const kubectlPath = await getKubectlPath();
let toolRunner = new ToolRunner(kubectlPath, ['config', 'use-context', context]);
await toolRunner.exec();
toolRunner = new ToolRunner(kubectlPath, ['config', 'current-context']);
await toolRunner.exec();
}
}
export async function run() {
try {
let kubeconfig = '';
const cluster_type = core.getInput('cluster-type', { required: true });
if (cluster_type == 'arc') {
try{
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);
}
} catch (ex) {
return Promise.reject(ex);
}
}
run().catch(core.setFailed);

30
src/run.test.ts Normal file
View File

@ -0,0 +1,30 @@
import { getRequiredInputError } from "../tests/util";
import { run } from "./run";
import fs from "fs";
import * as utils from "./utils";
describe("Run", () => {
it("throws error without cluster type", async () => {
await expect(run()).rejects.toThrow(getRequiredInputError("cluster-type"));
});
it("writes kubeconfig and sets context", async () => {
const kubeconfig = "kubeconfig";
process.env["INPUT_CLUSTER-TYPE"] = "default";
process.env["RUNNER_TEMP"] = "/sample/path";
jest
.spyOn(utils, "getKubeconfig")
.mockImplementation(async () => kubeconfig);
jest.spyOn(fs, "writeFileSync").mockImplementation(() => {});
jest.spyOn(fs, "chmodSync").mockImplementation(() => {});
jest.spyOn(utils, "setContext").mockImplementation(() => kubeconfig);
expect(await run());
expect(utils.getKubeconfig).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalled();
expect(fs.chmodSync).toHaveBeenCalled();
expect(utils.setContext).toHaveBeenCalled();
});
});

36
src/run.ts Normal file
View File

@ -0,0 +1,36 @@
import * as core from "@actions/core";
import * as path from "path";
import * as fs from "fs";
import { Cluster, parseCluster } from "./types/cluster";
import { setContext, getKubeconfig } from "./utils";
/**
* Sets the Kubernetes context based on supplied action inputs
*/
export async function run() {
// get inputs
const clusterType: Cluster | undefined = parseCluster(
core.getInput("cluster-type", {
required: true,
})
);
const runnerTempDirectory: string = process.env["RUNNER_TEMP"];
const kubeconfigPath: string = path.join(
runnerTempDirectory,
`kubeconfig_${Date.now()}`
);
// get kubeconfig and update context
const kubeconfig: string = await getKubeconfig(clusterType);
const kubeconfigWithContext: string = setContext(kubeconfig);
// output kubeconfig
core.debug(`Writing kubeconfig contents to ${kubeconfigPath}`);
fs.writeFileSync(kubeconfigPath, kubeconfigWithContext);
fs.chmodSync(kubeconfigPath, "600");
core.debug("Setting KUBECONFIG environment variable");
core.exportVariable("KUBECONFIG", kubeconfigPath);
}
// Run the application
run().catch(core.setFailed);

24
src/types/cluster.test.ts Normal file
View File

@ -0,0 +1,24 @@
import { Cluster, parseCluster } from "./cluster";
describe("Cluster type", () => {
test("it has required values", () => {
const vals = <any>Object.values(Cluster);
expect(vals.includes("arc")).toBe(true);
expect(vals.includes("generic")).toBe(true);
});
test("it can parse valid values from a string", () => {
expect(parseCluster("arc")).toBe(Cluster.ARC);
expect(parseCluster("Arc")).toBe(Cluster.ARC);
expect(parseCluster("ARC")).toBe(Cluster.ARC);
expect(parseCluster("generic")).toBe(Cluster.GENERIC);
expect(parseCluster("Generic")).toBe(Cluster.GENERIC);
expect(parseCluster("GENERIC")).toBe(Cluster.GENERIC);
});
test("it will return undefined if it can't parse values from a string", () => {
expect(parseCluster("invalid")).toBe(undefined);
expect(parseCluster("unsupportedType")).toBe(undefined);
});
});

16
src/types/cluster.ts Normal file
View File

@ -0,0 +1,16 @@
export enum Cluster {
ARC = "arc",
GENERIC = "generic",
}
/**
* Converts a string to the Cluster enum
* @param str The cluster type (case insensitive)
* @returns The Cluster enum or undefined if it can't be parsed
*/
export const parseCluster = (str: string): Cluster | undefined =>
Cluster[
Object.keys(Cluster).filter(
(k) => Cluster[k].toString().toLowerCase() === str.toLowerCase()
)[0] as keyof typeof Cluster
];

View File

@ -0,0 +1,33 @@
import { parseK8sSecret, K8sSecret } from "./k8sSecret";
describe("K8sSecret type", () => {
describe("Parsing from any", () => {
test("it returns a type guarded secret", () => {
const secret = { data: { token: "token", "ca.crt": "cert" } };
expect(() => parseK8sSecret(secret)).not.toThrow();
});
test("it throws an error when secret not provided", () => {
expect(() => parseK8sSecret(undefined)).toThrow();
});
test("it throws an error when there is no data field", () => {
const secret = {};
expect(() => parseK8sSecret(secret)).toThrow();
});
test("it throws an error when there is no token", () => {
const secret = {
data: {
"ca.crt": "cert",
},
};
expect(() => parseK8sSecret(secret)).toThrow();
});
test("it throws an error when there is no ca.crt field", () => {
const secret = { data: { token: "token" } };
expect(() => parseK8sSecret(secret)).toThrow();
});
});
});

25
src/types/k8sSecret.ts Normal file
View File

@ -0,0 +1,25 @@
import * as util from "util";
export interface K8sSecret {
data: {
token: string;
"ca.crt": string;
};
}
/**
* Throws an error if an object does not have all required fields to be a K8sSecret
* @param secret
* @returns A type guarded K8sSecret
*/
export function parseK8sSecret(secret: any): K8sSecret {
if (!secret) throw Error("K8s secret yaml is invalid");
if (!secret.data) throw k8sSecretMissingFieldError("data");
if (!secret.data.token) throw k8sSecretMissingFieldError("token");
if (!secret.data["ca.crt"]) throw k8sSecretMissingFieldError("ca.crt");
return secret as K8sSecret;
}
const k8sSecretMissingFieldError = (field: string): Error =>
Error(util.format("K8s secret yaml does not contain %s field", field));

29
src/types/method.test.ts Normal file
View File

@ -0,0 +1,29 @@
import { Method, parseMethod } from "./method";
describe("Method type", () => {
test("it has required values", () => {
const vals = <any>Object.values(Method);
expect(vals.includes("kubeconfig")).toBe(true);
expect(vals.includes("service-account")).toBe(true);
expect(vals.includes("service-principal")).toBe(true);
});
test("it can parse valid values from a string", () => {
expect(parseMethod("kubeconfig")).toBe(Method.KUBECONFIG);
expect(parseMethod("Kubeconfig")).toBe(Method.KUBECONFIG);
expect(parseMethod("KUBECONFIG")).toBe(Method.KUBECONFIG);
expect(parseMethod("service-account")).toBe(Method.SERVICE_ACCOUNT);
expect(parseMethod("Service-Account")).toBe(Method.SERVICE_ACCOUNT);
expect(parseMethod("SERVICE-ACCOUNT")).toBe(Method.SERVICE_ACCOUNT);
expect(parseMethod("service-principal")).toBe(Method.SERVICE_PRINCIPAL);
expect(parseMethod("Service-Principal")).toBe(Method.SERVICE_PRINCIPAL);
expect(parseMethod("SERVICE-PRINCIPAL")).toBe(Method.SERVICE_PRINCIPAL);
});
test("it will return undefined if it can't parse values from a string", () => {
expect(parseMethod("invalid")).toBe(undefined);
expect(parseMethod("unsupportedType")).toBe(undefined);
});
});

17
src/types/method.ts Normal file
View File

@ -0,0 +1,17 @@
export enum Method {
KUBECONFIG = "kubeconfig",
SERVICE_ACCOUNT = "service-account",
SERVICE_PRINCIPAL = "service-principal",
}
/**
* Converts a string to the Method enum
* @param str The method (case insensitive)
* @returns The Method enum or undefined if it can't be parsed
*/
export const parseMethod = (str: string): Method | undefined =>
Method[
Object.keys(Method).filter(
(k) => Method[k].toString().toLowerCase() === str.toLowerCase()
)[0] as keyof typeof Method
];

47
src/utils.test.ts Normal file
View File

@ -0,0 +1,47 @@
import fs from "fs";
import * as arc from "./kubeconfigs/arc";
import * as def from "./kubeconfigs/default";
import { Cluster } from "./types/cluster";
import { getKubeconfig, setContext } from "./utils";
describe("Utils", () => {
describe("get kubeconfig", () => {
test("it gets arc kubeconfig when type is arc", async () => {
const arcKubeconfig = "arckubeconfig";
jest
.spyOn(arc, "getArcKubeconfig")
.mockImplementation(async () => arcKubeconfig);
expect(await getKubeconfig(Cluster.ARC)).toBe(arcKubeconfig);
});
test("it defaults to default kubeconfig", async () => {
const defaultKubeconfig = "arckubeconfig";
jest
.spyOn(def, "getDefaultKubeconfig")
.mockImplementation(() => defaultKubeconfig);
expect(await getKubeconfig(undefined)).toBe(defaultKubeconfig);
expect(await getKubeconfig(Cluster.GENERIC)).toBe(defaultKubeconfig);
});
});
describe("set context", () => {
const kc = fs.readFileSync("tests/sample-kubeconfig.yml").toString();
test("it doesn't change kubeconfig without context", () => {
expect(setContext(kc)).toBe(kc);
});
test("it writes the context to the kubeconfig", () => {
process.env["INPUT_CONTEXT"] = "example";
const received = JSON.parse(setContext(kc));
const expectedKc = JSON.parse(
fs.readFileSync("tests/expected-kubeconfig.json").toString()
);
expect(received).toMatchObject(expectedKc);
});
});
});

48
src/utils.ts Normal file
View File

@ -0,0 +1,48 @@
import * as core from "@actions/core";
import * as fs from "fs";
import { KubeConfig } from "@kubernetes/client-node";
import { getDefaultKubeconfig } from "./kubeconfigs/default";
import { getArcKubeconfig } from "./kubeconfigs/arc";
import { Cluster } from "./types/cluster";
/**
* Gets the kubeconfig based on Kubernetes cluster type
* @param type The cluster type for the kubeconfig (defaults to generic)
* @returns A promise of the kubeconfig
*/
export async function getKubeconfig(
type: Cluster | undefined
): Promise<string> {
switch (type) {
case Cluster.ARC: {
return await getArcKubeconfig();
}
case undefined: {
core.warning("Cluster type not recognized. Defaulting to generic.");
}
default: {
return getDefaultKubeconfig();
}
}
}
/**
* Sets the context by updating the kubeconfig
* @param kubeconfig The kubeconfig
* @returns Updated kubeconfig with the context
*/
export function setContext(kubeconfig: string): string {
const context: string = core.getInput("context");
if (!context) {
core.debug("Can't set context because context is unspecified.");
return kubeconfig;
}
// load current kubeconfig
const kc = new KubeConfig();
kc.loadFromString(kubeconfig);
// update kubeconfig
kc.setCurrentContext(context);
return kc.exportConfig();
}

View File

@ -0,0 +1,27 @@
{
"apiVersion": "v1",
"kind": "Config",
"clusters": [
{
"name": "example",
"cluster": {
"server": "http://example.com:8080",
"insecure-skip-tls-verify": false
}
}
],
"users": [],
"contexts": [
{
"name": "example",
"context": {
"cluster": "example",
"name": "example",
"user": "example",
"namespace": "example"
}
}
],
"preferences": {},
"current-context": "example"
}

View File

@ -0,0 +1,12 @@
apiVersion: v1
kind: Config
clusters:
- cluster:
server: http://example.com:8080
name: example
contexts:
- context:
cluster: example
namespace: example
user: example
name: example

7
tests/util.ts Normal file
View File

@ -0,0 +1,7 @@
/**
* Returns error thrown by core.getInput when input required but not found
* @param inputName Name of input
* @returns Error with explanation message
*/
export const getRequiredInputError = (inputName) =>
Error(`Input required and not supplied: ${inputName}`);

View File

@ -1,10 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES6", "target": "ES6",
"module": "commonjs" "module": "commonjs",
"esModuleInterop": true
}, },
"exclude": [ "exclude": ["node_modules", "tests", "src/**/*.test.ts"]
"node_modules",
"__tests__"
]
} }