add github:environment:create and github:deployKey:create

Signed-off-by: Andrew Ochsner <andrew.ochsner@cognizant.com>
This commit is contained in:
Andrew Ochsner
2023-06-02 10:04:07 -05:00
parent fa002c19ac
commit 1948845861
10 changed files with 789 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added `github:deployKey:create` and `github:environment:create` scaffolder actions. You will need to add `read/write` permissions to your GITHUB_TOKEN and/or Github Backstage App for Repository `Administration` (for deploy key functionality) and `Environments` (for Environment functionality)
+1
View File
@@ -138,3 +138,4 @@ integration:
- `Commit statuses`: `Read-only`
- `Variables`: `Read & write` (if templates include GitHub Action Repository Variables)
- `Secrets`: `Read & write` (if templates include GitHub Action Repository Secrets)
- `Environments`: `Read & write` (if templates include GitHub Environments)
+44
View File
@@ -211,6 +211,50 @@ export function createGithubActionsDispatchAction(options: {
JsonObject
>;
// @public
export function createGithubDeployKeyAction(options: {
integrations: ScmIntegrationRegistry;
}): TemplateAction_2<
{
repoUrl: string;
publicKey: string;
privateKey: string;
deployKeyName: string;
privateKeySecretName?: string | undefined;
token?: string | undefined;
},
JsonObject
>;
// @public
export function createGithubEnvironmentAction(options: {
integrations: ScmIntegrationRegistry;
}): TemplateAction_2<
{
repoUrl: string;
name: string;
deploymentBranchPolicy?:
| {
protected_branches: boolean;
custom_branch_policies: boolean;
}
| undefined;
customBranchPolicyNames?: string[] | undefined;
environmentVariables?:
| {
[key: string]: string;
}
| undefined;
secrets?:
| {
[key: string]: string;
}
| undefined;
token?: string | undefined;
},
JsonObject
>;
// @public
export function createGithubIssuesLabelAction(options: {
integrations: ScmIntegrationRegistry;
@@ -42,6 +42,8 @@ import {
} from './filesystem';
import {
createGithubActionsDispatchAction,
createGithubDeployKeyAction,
createGithubEnvironmentAction,
createGithubIssuesLabelAction,
createGithubRepoCreateAction,
createGithubRepoPushAction,
@@ -194,6 +196,12 @@ export const createBuiltinActions = (
config,
githubCredentialsProvider,
}),
createGithubEnvironmentAction({
integrations,
}),
createGithubDeployKeyAction({
integrations,
}),
];
return actions as TemplateAction[];
@@ -0,0 +1,112 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PassThrough } from 'stream';
import { createGithubDeployKeyAction } from './githubDeployKey';
import { getVoidLogger } from '@backstage/backend-common';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
const mockOctokit = {
rest: {
actions: {
getRepoPublicKey: jest.fn(),
createOrUpdateRepoSecret: jest.fn(),
},
repos: {
createDeployKey: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
describe('github:deployKey:create', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
let action: TemplateAction<any>;
const mockContext = {
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
publicKey: 'pubkey',
privateKey: 'privkey',
deployKeyName: 'Push Tags',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
action = createGithubDeployKeyAction({
integrations,
});
});
it('should work happy path', async () => {
mockOctokit.rest.actions.getRepoPublicKey.mockResolvedValue({
data: {
key: publicKey,
key_id: 'keyid',
},
});
await action.handler(mockContext);
expect(mockOctokit.rest.repos.createDeployKey).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
title: 'Push Tags',
key: 'pubkey',
});
expect(
mockOctokit.rest.actions.createOrUpdateRepoSecret,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
secret_name: 'PUSH_TAGS_PRIVATE_KEY',
key_id: 'keyid',
encrypted_value: expect.any(String),
});
expect(mockContext.output).toHaveBeenCalledWith(
'privateKeySecretName',
'PUSH_TAGS_PRIVATE_KEY',
);
});
});
@@ -0,0 +1,157 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
import { Octokit } from 'octokit';
import Sodium from 'libsodium-wrappers';
/**
* Creates an `github:deployKey:create` Scaffolder action that creates a Deploy Key
*
* @public
*/
export function createGithubDeployKeyAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
repoUrl: string;
publicKey: string;
privateKey: string;
deployKeyName: string;
privateKeySecretName?: string;
token?: string;
}>({
id: 'github:deployKey:create',
description: 'Creates and stores Deploy Keys',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'publicKey', 'privateKey', 'deployKeyName'],
properties: {
repoUrl: {
title: 'Repository Location',
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
type: 'string',
},
publicKey: {
title: 'SSH Public Key',
description: `Generated from ssh-keygen. Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'.`,
type: 'string',
},
privateKey: {
title: 'SSH Private Key',
description: `SSH Private Key generated from ssh-keygen`,
type: 'string',
},
deployKeyName: {
title: 'Deploy Key Name',
description: `Name of the Deploy Key`,
type: 'string',
},
privateKeySecretName: {
title: 'Private Key GitHub Secret Name',
description: `Name of the GitHub Secret to store the private key related to the Deploy Key. Defaults to: 'KEY_NAME_PRIVATE_KEY' where 'KEY_NAME' is the name of the Deploy Key`,
type: 'string',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
},
},
},
output: {
type: 'object',
properties: {
privateKeySecretName: {
title: 'The GitHub Action Repo Secret Name for the Private Key',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
publicKey,
privateKey,
deployKeyName,
privateKeySecretName = `${deployKeyName
.split(' ')
.join('_')
.toLocaleUpperCase('en-US')}_PRIVATE_KEY`,
token: providedToken,
} = ctx.input;
const octokitOptions = await getOctokitOptions({
integrations,
token: providedToken,
repoUrl: repoUrl,
});
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError(`No owner provided for repo ${repoUrl}`);
}
const client = new Octokit(octokitOptions);
await client.rest.repos.createDeployKey({
owner: owner,
repo: repo,
title: deployKeyName,
key: publicKey,
});
const publicKeyResponse = await client.rest.actions.getRepoPublicKey({
owner: owner,
repo: repo,
});
await Sodium.ready;
const binaryKey = Sodium.from_base64(
publicKeyResponse.data.key,
Sodium.base64_variants.ORIGINAL,
);
const binarySecret = Sodium.from_string(privateKey);
const encryptedBinarySecret = Sodium.crypto_box_seal(
binarySecret,
binaryKey,
);
const encryptedBase64Secret = Sodium.to_base64(
encryptedBinarySecret,
Sodium.base64_variants.ORIGINAL,
);
await client.rest.actions.createOrUpdateRepoSecret({
owner: owner,
repo: repo,
secret_name: privateKeySecretName,
encrypted_value: encryptedBase64Secret,
key_id: publicKeyResponse.data.key_id,
});
ctx.output('privateKeySecretName', privateKeySecretName);
},
});
}
@@ -0,0 +1,258 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PassThrough } from 'stream';
import { createGithubEnvironmentAction } from './githubEnvironment';
import { getVoidLogger } from '@backstage/backend-common';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
const mockOctokit = {
rest: {
actions: {
getRepoPublicKey: jest.fn(),
createEnvironmentVariable: jest.fn(),
createOrUpdateEnvironmentSecret: jest.fn(),
},
repos: {
createDeploymentBranchPolicy: jest.fn(),
createOrUpdateEnvironment: jest.fn(),
get: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
describe('github:environment:create', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
let action: TemplateAction<any>;
const mockContext = {
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
name: 'envname',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
mockOctokit.rest.actions.getRepoPublicKey.mockResolvedValue({
data: {
key: publicKey,
key_id: 'keyid',
},
});
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
id: 'repoid',
},
});
action = createGithubEnvironmentAction({
integrations,
});
});
afterEach(jest.resetAllMocks);
it('should work happy path', async () => {
await action.handler(mockContext);
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
});
});
it('should work specify deploymentBranchPolicy protected', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
deploymentBranchPolicy: {
protected_branches: true,
custom_branch_policies: false,
},
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: {
protected_branches: true,
custom_branch_policies: false,
},
});
});
it('should work specify deploymentBranchPolicy custom', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
deploymentBranchPolicy: {
protected_branches: false,
custom_branch_policies: true,
},
customBranchPolicyNames: ['main', '*.*.*'],
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: {
protected_branches: false,
custom_branch_policies: true,
},
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
name: 'main',
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
name: '*.*.*',
});
});
it('should work specify environment variables', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
environmentVariables: {
key1: 'val1',
key2: 'val2',
},
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
});
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).toHaveBeenCalledTimes(2);
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
name: 'key1',
value: 'val1',
});
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
name: 'key2',
value: 'val2',
});
});
it('should work specify secrets', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
secrets: {
key1: 'val1',
key2: 'val2',
},
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
});
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).toHaveBeenCalledTimes(2);
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
secret_name: 'key1',
key_id: 'keyid',
encrypted_value: expect.any(String),
});
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
secret_name: 'key2',
key_id: 'keyid',
encrypted_value: expect.any(String),
});
});
});
@@ -0,0 +1,200 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
import { Octokit } from 'octokit';
import Sodium from 'libsodium-wrappers';
/**
* Creates an `github:environment:create` Scaffolder action that creates a Github Environment.
*
* @public
*/
export function createGithubEnvironmentAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
repoUrl: string;
name: string;
deploymentBranchPolicy?: {
protected_branches: boolean;
custom_branch_policies: boolean;
};
customBranchPolicyNames?: string[];
environmentVariables?: { [key: string]: string };
secrets?: { [key: string]: string };
token?: string;
}>({
id: 'github:environment:create',
description: 'Creates Deployment Environments',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'name'],
properties: {
repoUrl: {
title: 'Repository Location',
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
type: 'string',
},
name: {
title: 'Environment Name',
description: `Name of the deployment environment to create`,
type: 'string',
},
deploymentBranchPolicy: {
title: 'Deployment Branch Policy',
description: `The type of deployment branch policy for this environment. To allow all branches to deploy, set to null.`,
type: 'object',
required: ['protected_branches', 'custom_branch_policies'],
properties: {
protected_branches: {
title: 'Protected Branches',
description: `Whether only branches with branch protection rules can deploy to this environment. If protected_branches is true, custom_branch_policies must be false; if protected_branches is false, custom_branch_policies must be true.`,
type: 'boolean',
},
custom_branch_policies: {
title: 'Custom Branch Policies',
description: `Whether only branches that match the specified name patterns can deploy to this environment. If custom_branch_policies is true, protected_branches must be false; if custom_branch_policies is false, protected_branches must be true.`,
type: 'boolean',
},
},
},
customBranchPolicyNames: {
title: 'Custom Branch Policy Name',
description: `The name pattern that branches must match in order to deploy to the environment.
Wildcard characters will not match /. For example, to match branches that begin with release/ and contain an additional single slash, use release/*/*. For more information about pattern matching syntax, see the Ruby File.fnmatch documentation.`,
type: 'array',
items: {
type: 'string',
},
},
environmentVariables: {
title: 'Environment Variables',
description: `Environment variables attached to the deployment environment`,
type: 'object',
},
secrets: {
title: 'Deployment Secrets',
description: `Secrets attached to the deployment environment`,
type: 'object',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
name,
deploymentBranchPolicy,
customBranchPolicyNames,
environmentVariables,
secrets,
token: providedToken,
} = ctx.input;
const octokitOptions = await getOctokitOptions({
integrations,
token: providedToken,
repoUrl: repoUrl,
});
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError(`No owner provided for repo ${repoUrl}`);
}
const client = new Octokit(octokitOptions);
const repository = await client.rest.repos.get({
owner: owner,
repo: repo,
});
await client.rest.repos.createOrUpdateEnvironment({
owner: owner,
repo: repo,
environment_name: name,
deployment_branch_policy: deploymentBranchPolicy ?? null,
});
if (customBranchPolicyNames) {
for (const item of customBranchPolicyNames) {
await client.rest.repos.createDeploymentBranchPolicy({
owner: owner,
repo: repo,
environment_name: name,
name: item,
});
}
}
for (const [key, value] of Object.entries(environmentVariables ?? {})) {
await client.rest.actions.createEnvironmentVariable({
repository_id: repository.data.id,
environment_name: name,
name: key,
value,
});
}
if (secrets) {
const publicKeyResponse = await client.rest.actions.getRepoPublicKey({
owner: owner,
repo: repo,
});
await Sodium.ready;
const binaryKey = Sodium.from_base64(
publicKeyResponse.data.key,
Sodium.base64_variants.ORIGINAL,
);
for (const [key, value] of Object.entries(secrets)) {
const binarySecret = Sodium.from_string(value);
const encryptedBinarySecret = Sodium.crypto_box_seal(
binarySecret,
binaryKey,
);
const encryptedBase64Secret = Sodium.to_base64(
encryptedBinarySecret,
Sodium.base64_variants.ORIGINAL,
);
await client.rest.actions.createOrUpdateEnvironmentSecret({
repository_id: repository.data.id,
environment_name: name,
secret_name: key,
encrypted_value: encryptedBase64Secret,
key_id: publicKeyResponse.data.key_id,
});
}
}
},
});
}
@@ -98,7 +98,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
client: Octokit,
repo: string,
owner: string,
repoVisibility: 'private' | 'internal' | 'public',
repoVisibility: 'private' | 'internal' | 'public' | undefined,
description: string | undefined,
homepage: string | undefined,
deleteBranchOnMerge: boolean,
@@ -149,7 +149,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
name: repo,
org: owner,
private: repoVisibility === 'private',
// @ts-ignore
// @ts-ignore https://github.com/octokit/types.ts/issues/522
visibility: repoVisibility,
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
@@ -19,3 +19,5 @@ export { createGithubIssuesLabelAction } from './githubIssuesLabel';
export { createGithubRepoCreateAction } from './githubRepoCreate';
export { createGithubRepoPushAction } from './githubRepoPush';
export { createGithubWebhookAction } from './githubWebhook';
export { createGithubDeployKeyAction } from './githubDeployKey';
export { createGithubEnvironmentAction } from './githubEnvironment';