Skip to content

Commit

Permalink
Merge pull request #4271 from cloud-gov/feat-admin-site-repo-migrator…
Browse files Browse the repository at this point in the history
…-4237

feat(admin): Add site repo migrator script
  • Loading branch information
apburnes authored Oct 17, 2023
2 parents 5322835 + 0fe0c2c commit 90bc193
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 5 deletions.
24 changes: 24 additions & 0 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Platform Operations

These are tips and functionalities available to run operations against the platform

## Scripts

The [`./scripts/`](./scripts/) directory is used for scripts to be run on the platform in any environment. These can be scripts run at adhoc by platform operators or scripts that are run in CI.

### Migrating a site's repository

Run this script if the site's Github repository needs to be moved to a new Github owner and/or repository name.

Requirements:

- `siteId`: The unique integer id of the site
- `email`: The user's UAA Identity email with the proper Github credentials to add webhooks to the new site repository
- `owner`: The new Github owner or organization name
- `repository`: The new Github repository name

Example:

```bash
$ cf run-task pages-<env> "yarn migrate-site-repo 1 [email protected] agency-org site-repo" --name site-1-migrate
```
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ More examples can be found at [https://cloud.gov/pages/success-stories/](https:/

For information on development of this application, please see [DEVELOPMENT.md](DEVELOPMENT.md)

## Platform Ops

For information on running platform operations with this application, please see [OPERATIONS.md](OPERATIONS.md)

## Initial proposal

Federalist is new open source publishing system based on proven open source components and techniques. Once the text has been written, images uploaded, and a page is published, the outward-facing site will act like a simple web site -- fast, reliable, and easily scalable. Administrative tools, which require authentication and additional interactive components, can be responsive with far fewer users.
Expand Down
80 changes: 80 additions & 0 deletions api/services/SiteRepoMigrator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const { Site, UAAIdentity, User } = require('../models');
const GitHub = require('./GitHub');
const CloudFoundryAPIClient = require('../utils/cfApiClient');
const { generateS3ServiceName, generateSubdomain } = require('../utils');

const apiClient = new CloudFoundryAPIClient();

async function getUserGHCredentials(email) {
const uaaUser = await UAAIdentity.findOne({
where: { email },
include: [User],
raw: true,
nest: true,
});

if (!uaaUser) throw new Error('No UAA Identity exists with that email.');

const { githubAccessToken, githubUserId } = uaaUser.User;

return { githubAccessToken, githubUserId };
}

async function setRepoWebhook(site, uaaEmail) {
const { githubAccessToken } = await getUserGHCredentials(uaaEmail);
const webhook = await GitHub.setWebhook(site, githubAccessToken);

if (webhook) {
site.set('webhookId', webhook.data.id);
}

return site.save();
}

async function updateSiteServices(oldServceName, newServiceName) {
const s3ServiceInstance = await apiClient.fetchServiceInstance(oldServceName);
const s3KeyInstance = await apiClient.fetchCredentialBindingsInstance(
`${oldServceName}-key`
);

// Rename s3 service instance
await apiClient.authRequest(
'PATCH',
`/v3/service_instances/${s3ServiceInstance.guid}/`,
{ name: newServiceName }
);

// Create new service key based on new service name
await apiClient.createServiceKey(newServiceName, s3ServiceInstance.guid);

// Delete old service key
await apiClient.authRequest(
'DELETE',
`/v3/service_credential_bindings/${s3KeyInstance.guid}`
);
}

async function siteRepoMigrator(siteId, uaaEmail, { repository, owner }) {
const subdomain = generateSubdomain(owner, repository);
const s3ServiceName = generateS3ServiceName(owner, repository);
const site = await Site.findByPk(siteId);
const oldS3ServiceName = site.s3ServiceName;

site.set({
owner,
repository,
s3ServiceName,
subdomain,
});

await site.save();
await updateSiteServices(oldS3ServiceName, s3ServiceName);
await setRepoWebhook(site, uaaEmail);
}

module.exports = {
getUserGHCredentials,
setRepoWebhook,
siteRepoMigrator,
updateSiteServices,
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"update-proxy-db": "node ./scripts/proxy-edge.js",
"archive-build-logs": "node ./scripts/archive-build-logs.js",
"migrate-build-logs": "node ./scripts/migrate-build-logs.js",
"migrate-site-repo": "node ./scripts/migrate-site-repo.js",
"send-email": "node ./scripts/send-email.js",
"bootstrap-admins": "node ./scripts/bootstrap-admins.js",
"migrate-build-notification-settings": "node ./scripts/migrate-build-notification-settings.js",
Expand Down
30 changes: 30 additions & 0 deletions scripts/migrate-site-repo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* eslint-disable no-console */
const { siteRepoMigrator } = require('../api/services/SiteRepoMigrator');

async function main() {
try {
const args = Array.prototype.slice.call(process.argv, 2);

if (args.length !== 4) {
throw `
Please make sure you provide 4 arguments. (siteId, uaaEmail, owner, repository).\n
You provided the following: ${args}
`;
}

const [siteId, email, owner, repository] = args;

await siteRepoMigrator(siteId, email, { owner, repository });

console.log(
`Site Id: ${siteId} migrated to new repo ${owner}/${repository}`
);

process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}

main();
16 changes: 11 additions & 5 deletions test/api/support/githubAPINocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,16 @@ const status = ({
return statusNock.reply(...resp);
};

const webhook = ({
// eslint-disable-next-line no-shadow
accessToken, owner, repo, response,
} = {}) => {
const webhook = (
{
// eslint-disable-next-line no-shadow
accessToken,
owner,
repo,
response,
} = {},
{ id = 1 } = {}
) => {
let webhookNock = nock('https://api.github.com');

if (owner && repo) {
Expand All @@ -298,7 +304,7 @@ const webhook = ({

let resp = response || 201;
if (typeof resp === 'number') {
resp = [resp, { id: 1 }];
resp = [resp, { id: id }];
}

return webhookNock.reply(...resp);
Expand Down
111 changes: 111 additions & 0 deletions test/api/unit/services/SiteRepoMigrator.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const crypto = require('node:crypto');
const { expect } = require('chai');
const {
getUserGHCredentials,
setRepoWebhook,
siteRepoMigrator,
} = require('../../../../api/services/SiteRepoMigrator');
const { Site, User, UAAIdentity } = require('../../../../api/models');
const githubAPINocks = require('../../support/githubAPINocks');
const factory = require('../../support/factory');

async function cleanDb() {
return await Promise.all([
User.truncate({ force: true, cascade: true }),
Site.truncate({ force: true, cascade: true }),
UAAIdentity.truncate({ force: true, cascade: true }),
]);
}

describe('SiteRepoMigrator', () => {
beforeEach(async () => await cleanDb);

afterEach(async () => await cleanDb);

describe('getUserGHCredentials', () => {
it('Should get the Github credentials of a user based on their UAA email', async () => {
const githubAccessToken = crypto.randomUUID();
const githubUserId = crypto.randomUUID();
const user = await factory.user({ githubAccessToken, githubUserId });
const uaaId = await factory.uaaIdentity({
userId: user.id,
});

const creds = await getUserGHCredentials(uaaId.email);

expect(creds).to.have.keys(['githubAccessToken', 'githubUserId']);
expect(creds.githubAccessToken).to.equal(githubAccessToken);
expect(creds.githubUserId).to.equal(githubUserId);
});

it('Should throw an error if UAA Identity does not exist with the email', async () => {
const nonExistingEmail = '[email protected]';
const user = await factory.user();
await factory.uaaIdentity({
userId: user.id,
});

try {
await getUserGHCredentials(nonExistingEmail);
} catch (error) {
expect(error).to.throw;
expect(error.message).to.equal(
'No UAA Identity exists with that email.'
);
}
});
});

describe('setRepoWebhook', () => {
it('should set a webhook on the repository and return the site instance', async () => {
const oldWebhookId = 90210;
const webhookId = 8675309;
const githubAccessToken = crypto.randomUUID();
const githubUserId = crypto.randomUUID();
const site = await factory.site({ webhookId: oldWebhookId });
const user = await factory.user({ githubAccessToken, githubUserId });
const uaaId = await factory.uaaIdentity({
userId: user.id,
});

githubAPINocks.webhook(
{
accessToken: user.githubAccessToken,
owner: site.owner,
repo: site.repository,
response: 201,
},
{ id: webhookId }
);

const updatedSite = await setRepoWebhook(site, uaaId.email);
expect(updatedSite.webhookId).to.equal(webhookId);
});

it('should resolve if the webhook already exists on the site repository', async () => {
const oldWebhookId = 90210;
const githubAccessToken = crypto.randomUUID();
const githubUserId = crypto.randomUUID();
const site = await factory.site({ webhookId: oldWebhookId });
const user = await factory.user({ githubAccessToken, githubUserId });
const uaaId = await factory.uaaIdentity({
userId: user.id,
});

githubAPINocks.webhook({
accessToken: user.githubAccessToken,
owner: site.owner,
repo: site.repository,
response: [
400,
{
errors: [{ message: 'Hook already exists on this repository' }],
},
],
});

const updatedSite = await setRepoWebhook(site, uaaId.email);
expect(updatedSite.webhookId).to.equal(oldWebhookId);
});
});
});

0 comments on commit 90bc193

Please sign in to comment.