Skip to content

Commit

Permalink
feat: allow customizing context_name, default to the same as gcloud (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo committed Dec 15, 2021
1 parent 9e078df commit 9424404
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 22 deletions.
6 changes: 6 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ inputs:
encoded.
required: false

context_name:
description: |-
Name to use when creating the kubectl context. If not specified, the
default value is "gke_{PROJECT_ID}_{LOCATION}_${CLUSTER_NAME}".
required: false

branding:
icon: 'lock'
color: 'blue'
Expand Down
63 changes: 51 additions & 12 deletions src/gkeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const { version: appVersion } = require('../package.json');

// clusterResourceNamePattern is the regular expression to use to match resource
// names.
const clusterResourceNamePattern = new RegExp(/^projects\/.+\/locations\/.+\/clusters\/.+$/gi);
const clusterResourceNamePattern = new RegExp(/^projects\/(.+)\/locations\/(.+)\/clusters\/(.+)$/i);

/**
* Available options to create the client.
Expand All @@ -45,6 +45,44 @@ type ClientOptions = {
* @returns Cluster client.
*/
export class ClusterClient {
/**
* parseResourceName parses a string as a cluster resource name. If it's just
* a cluster name, it returns an empty project ID and location. If a full
* resource name is given, the values are parsed and returned. All other
* inputs throw an error.
*
* @param name Name of the cluster (e.g. "my-cluster" or "projects/p/locations/l/clusters/c")
*/
static parseResourceName(name: string): {
projectID: string;
location: string;
id: string;
} {
name = (name || '').trim();
if (!name) {
throw new Error(`Failed to parse cluster name: value is the empty string`);
}

if (!name.includes('/')) {
return {
projectID: '',
location: '',
id: name,
};
}

const matches = name.match(clusterResourceNamePattern);
if (!matches) {
throw new Error(`Failed to parse cluster name "${name}": invalid pattern`);
}

return {
projectID: matches[1],
location: matches[2],
id: matches[3],
};
}

/**
* projectID and location are hints to the client if a resource name does not
* include the full resource name. If a full resource name is given (e.g.
Expand Down Expand Up @@ -93,32 +131,28 @@ export class ClusterClient {
* @returns full resource name.
*/
getResource(name: string): string {
name = (name || '').trim();
if (!name) {
name = '';
}

name = name.trim();
if (!name) {
throw new Error(`Failed to parse resource name: name cannot be empty`);
throw new Error(`Failed to parse cluster name: name cannot be empty`);
}

if (name.includes('/')) {
if (name.match(clusterResourceNamePattern)) {
return name;
} else {
throw new Error(`Invalid resource name "${name}"`);
throw new Error(`Invalid cluster name "${name}"`);
}
}

const projectID = this.#projectID;
if (!projectID) {
throw new Error(`Failed to get project ID to build resource name. Try setting "project_id".`);
throw new Error(`Failed to get project ID to build cluster name. Try setting "project_id".`);
}

const location = this.#location;
if (!location) {
throw new Error(
`Failed to get location (region/zone) to build resource name. Try setting "location".`,
`Failed to get location (region/zone) to build cluster name. Try setting "location".`,
);
}

Expand Down Expand Up @@ -155,6 +189,8 @@ export class ClusterClient {
const auth = opts.useAuthProvider
? { user: { 'auth-provider': { name: 'gcp' } } }
: { user: { token: await this.getToken() } };
const contextName = opts.contextName;

const kubeConfig: KubeConfig = {
'apiVersion': 'v1',
'clusters': [
Expand All @@ -172,11 +208,11 @@ export class ClusterClient {
cluster: cluster.data.name,
user: cluster.data.name,
},
name: cluster.data.name,
name: contextName,
},
],
'kind': 'Config',
'current-context': cluster.data.name,
'current-context': contextName,
'users': [{ ...{ name: cluster.data.name }, ...auth }],
};
return YAML.stringify(kubeConfig);
Expand Down Expand Up @@ -210,6 +246,9 @@ export type CreateKubeConfigOptions = {

// clusterData is the cluster response data.
clusterData: ClusterResponse;

// contextName is the name of the context.
contextName: string;
};

export type KubeConfig = {
Expand Down
43 changes: 37 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ async function run(): Promise<void> {
try {
// Get inputs
let projectID = getInput('project_id');
const location = getInput('location');
const name = getInput('cluster_name', { required: true });
let location = getInput('location');
const clusterName = ClusterClient.parseResourceName(
getInput('cluster_name', { required: true }),
);
const credentials = getInput('credentials');
const useAuthProvider = getBooleanInput('use_auth_provider');
const useInternalIP = getBooleanInput('use_internal_ip');
let contextName = getInput('context_name');

// Add warning if using credentials
let credentialsJSON: ServiceAccountKey | ExternalAccountClientOptions | undefined;
Expand All @@ -57,15 +60,42 @@ async function run(): Promise<void> {

// Pick the best project ID.
if (!projectID) {
if (credentialsJSON && isServiceAccountKey(credentialsJSON)) {
if (clusterName.projectID) {
projectID = clusterName.projectID;
logInfo(`Extracted projectID "${projectID}" from cluster resource name`);
} else if (credentialsJSON && isServiceAccountKey(credentialsJSON)) {
projectID = credentialsJSON?.project_id;
logInfo(`Extracted project ID '${projectID}' from credentials JSON`);
logInfo(`Extracted project ID "${projectID}" from credentials JSON`);
} else if (process.env?.GCLOUD_PROJECT) {
projectID = process.env.GCLOUD_PROJECT;
logInfo(`Extracted project ID '${projectID}' from $GCLOUD_PROJECT`);
logInfo(`Extracted project ID "${projectID}" from $GCLOUD_PROJECT`);
} else {
throw new Error(
`Failed to extract project ID, please set the "project_id" input, ` +
`set $GCLOUD_PROJECT, or specify the cluster name as a full ` +
`resource name.`,
);
}
}

// Pick the best location.
if (!location) {
if (clusterName.location) {
location = clusterName.location;
logInfo(`Extracted location "${location}" from cluster resource name`);
} else {
throw new Error(
`Failed to extract location, please set the "location" input or ` +
`specify the cluster name as a full resource name.`,
);
}
}

// Pick the best context name.
if (!contextName) {
contextName = `gke_${projectID}_${location}_${clusterName.id}`;
}

// Create Container Cluster client
const client = new ClusterClient({
projectID: projectID,
Expand All @@ -74,13 +104,14 @@ async function run(): Promise<void> {
});

// Get Cluster object
const clusterData = await client.getCluster(name);
const clusterData = await client.getCluster(clusterName.id);

// Create KubeConfig
const kubeConfig = await client.createKubeConfig({
useAuthProvider: useAuthProvider,
useInternalIP: useInternalIP,
clusterData: clusterData,
contextName: contextName,
});

// Write kubeconfig to disk
Expand Down
68 changes: 64 additions & 4 deletions tests/clusterClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import { expect } from 'chai';
import 'mocha';

import crypto from 'crypto';

import YAML from 'yaml';

import { parseServiceAccountKeyJSON } from '../src/util';
Expand Down Expand Up @@ -55,6 +57,56 @@ const privateCluster: ClusterResponse = {
import { ClusterClient, ClusterResponse } from '../src/gkeClient';

describe('Cluster', function () {
describe('.parseResourceName', () => {
const cases = [
{
name: 'empty string',
input: '',
error: 'Failed to parse cluster name',
},
{
name: 'padded string',
input: ' ',
error: 'Failed to parse cluster name',
},
{
name: 'single name',
input: 'my-cluster',
expected: {
projectID: '',
location: '',
id: 'my-cluster',
},
},
{
name: 'full resource name',
input: 'projects/p/locations/l/clusters/c',
expected: {
projectID: 'p',
location: 'l',
id: 'c',
},
},
{
name: 'partial resource name',
input: 'projects/p/locations',
error: 'Failed to parse cluster name',
},
];

cases.forEach((tc) => {
it(tc.name, async () => {
if (tc.expected) {
expect(ClusterClient.parseResourceName(tc.input)).to.eql(tc.expected);
} else if (tc.error) {
expect(() => {
ClusterClient.parseResourceName(tc.input);
}).to.throw(tc.error);
}
});
});
});

it('initializes with ADC', async function () {
if (!process.env.GCLOUD_PROJECT) this.skip();

Expand Down Expand Up @@ -108,6 +160,7 @@ describe('Cluster', function () {
it('can get generate kubeconfig with token for public clusters', async function () {
if (!credentials) this.skip();

const contextName = crypto.randomBytes(12).toString('hex');
const client = new ClusterClient({
projectID: project,
location: location,
Expand All @@ -118,6 +171,7 @@ describe('Cluster', function () {
useAuthProvider: false,
useInternalIP: false,
clusterData: publicCluster,
contextName: contextName,
}),
);

Expand All @@ -126,7 +180,7 @@ describe('Cluster', function () {
publicCluster.data.masterAuth.clusterCaCertificate,
);
expect(kubeconfig.clusters[0].cluster.server).to.eql(`https://${publicCluster.data.endpoint}`);
expect(kubeconfig['current-context']).to.eql(publicCluster.data.name);
expect(kubeconfig['current-context']).to.eql(contextName);
expect(kubeconfig.users[0].name).to.eql(publicCluster.data.name);
expect(kubeconfig.users[0].user.token).to.be.not.null;
expect(kubeconfig.users[0].user).to.not.have.property('auth-provider');
Expand All @@ -135,6 +189,7 @@ describe('Cluster', function () {
it('can get generate kubeconfig with auth plugin for public clusters', async function () {
if (!credentials) this.skip();

const contextName = crypto.randomBytes(12).toString('hex');
const client = new ClusterClient({
projectID: project,
location: location,
Expand All @@ -145,6 +200,7 @@ describe('Cluster', function () {
useAuthProvider: true,
useInternalIP: false,
clusterData: publicCluster,
contextName: contextName,
}),
);

Expand All @@ -153,7 +209,7 @@ describe('Cluster', function () {
publicCluster.data.masterAuth.clusterCaCertificate,
);
expect(kubeconfig.clusters[0].cluster.server).to.eql(`https://${publicCluster.data.endpoint}`);
expect(kubeconfig['current-context']).to.eql(publicCluster.data.name);
expect(kubeconfig['current-context']).to.eql(contextName);
expect(kubeconfig.users[0].name).to.eql(publicCluster.data.name);
expect(kubeconfig.users[0].user['auth-provider'].name).to.eql('gcp');
expect(kubeconfig.users[0].user).to.not.have.property('token');
Expand All @@ -162,6 +218,7 @@ describe('Cluster', function () {
it('can get generate kubeconfig with token for private clusters', async function () {
if (!credentials) this.skip();

const contextName = crypto.randomBytes(12).toString('hex');
const client = new ClusterClient({
projectID: project,
location: location,
Expand All @@ -172,6 +229,7 @@ describe('Cluster', function () {
useAuthProvider: false,
useInternalIP: true,
clusterData: privateCluster,
contextName: contextName,
}),
);

Expand All @@ -182,7 +240,7 @@ describe('Cluster', function () {
expect(kubeconfig.clusters[0].cluster.server).to.eql(
`https://${privateCluster.data.privateClusterConfig.privateEndpoint}`,
);
expect(kubeconfig['current-context']).to.eql(privateCluster.data.name);
expect(kubeconfig['current-context']).to.eql(contextName);
expect(kubeconfig.users[0].name).to.eql(privateCluster.data.name);
expect(kubeconfig.users[0].user.token).to.be.not.null;
expect(kubeconfig.users[0].user).to.not.have.property('auth-provider');
Expand All @@ -191,6 +249,7 @@ describe('Cluster', function () {
it('can get generate kubeconfig with auth plugin for private clusters', async function () {
if (!credentials) this.skip();

const contextName = crypto.randomBytes(12).toString('hex');
const client = new ClusterClient({
projectID: project,
location: location,
Expand All @@ -201,6 +260,7 @@ describe('Cluster', function () {
useAuthProvider: true,
useInternalIP: true,
clusterData: privateCluster,
contextName: contextName,
}),
);

Expand All @@ -211,7 +271,7 @@ describe('Cluster', function () {
expect(kubeconfig.clusters[0].cluster.server).to.eql(
`https://${privateCluster.data.privateClusterConfig.privateEndpoint}`,
);
expect(kubeconfig['current-context']).to.eql(privateCluster.data.name);
expect(kubeconfig['current-context']).to.eql(contextName);
expect(kubeconfig.users[0].name).to.eql(privateCluster.data.name);
expect(kubeconfig.users[0].user['auth-provider'].name).to.eql('gcp');
expect(kubeconfig.users[0].user).to.not.have.property('token');
Expand Down

0 comments on commit 9424404

Please sign in to comment.