Skip to content

Commit a909229

Browse files
committed
save state lock requests in their own model
1 parent 94ae954 commit a909229

7 files changed

Lines changed: 175 additions & 97 deletions

File tree

src/controllers/ControllerV1.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
Tags,
1414
TsoaResponse,
1515
} from 'tsoa';
16-
import { StateLockRequest } from '../models/interfaces';
16+
import { StateLockRequest } from '../models/interfaces/StateLockRequest';
1717
import { GithubService } from '../services/GithubService';
1818
import { StateService } from '../services/StateService';
1919

@@ -44,7 +44,8 @@ export class ControllerV1 extends Controller {
4444
@Body() state: any,
4545
@Res() res: TsoaResponse<200, void>,
4646
): Promise<void> {
47-
const identity = await this.githubService.getIdentity(request);
47+
const stateLockRequest = await this.stateService.getRequest(id);
48+
const identity = await this.githubService.getIdentity(request, stateLockRequest);
4849
await this.stateService.saveState(identity, id, state);
4950
const response = res(200);
5051
return response;
@@ -53,22 +54,24 @@ export class ControllerV1 extends Controller {
5354
@Put('lock')
5455
public async lockState(
5556
@Request() request: HttpRequest,
56-
@Body() stateLockRequest: StateLockRequest,
57+
@Body() lockRequest: StateLockRequest,
5758
@Res() res: TsoaResponse<200, boolean>,
5859
): Promise<boolean> {
59-
const identity = await this.githubService.getIdentity(request);
60-
await this.stateService.lockState(identity, stateLockRequest, 30);
60+
const stateLockRequest = await this.stateService.saveRequest(lockRequest);
61+
const identity = await this.githubService.getIdentity(request, stateLockRequest);
62+
await this.stateService.lockState(identity, stateLockRequest);
6163
const response = res(200, true);
6264
return response;
6365
}
6466

6567
@Delete('lock')
6668
public async unlockState(
6769
@Request() request: HttpRequest,
68-
@Body() stateLockRequest: StateLockRequest,
70+
@Body() lockRequest: StateLockRequest,
6971
@Res() res: TsoaResponse<200, boolean>,
7072
): Promise<boolean> {
71-
const identity = await this.githubService.getIdentity(request);
73+
const stateLockRequest = await this.stateService.getRequest(lockRequest.ID);
74+
const identity = await this.githubService.getIdentity(request, stateLockRequest);
7275
await this.stateService.unlockState(identity, stateLockRequest);
7376
const response = res(200, true);
7477
return response;

src/controllers/HealthController.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import packageJson from '../../package.json';
33

44
export type HealthResponse = {
55
name: string;
6-
healty: boolean;
6+
healthy: boolean;
77
now: Date;
88
version: string;
99
};
@@ -15,7 +15,7 @@ export class HealthController extends Controller {
1515
public async get(): Promise<HealthResponse> {
1616
return {
1717
name: packageJson.name,
18-
healty: true,
18+
healthy: true,
1919
now: new Date(),
2020
version: packageJson.version,
2121
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
Joi,
3+
Model,
4+
SERVICE_NAME,
5+
STAGE,
6+
Table,
7+
unmarshallDynamoDBImage,
8+
} from '@scaffoldly/serverless-util';
9+
import { StreamRecord } from 'aws-lambda';
10+
import { StateLockRequest } from './interfaces/StateLockRequest';
11+
import { stateLockRequest } from './schemas/StateLockRequest';
12+
13+
const TABLE_SUFFIX = '';
14+
15+
export class StateLockRequestModel {
16+
public readonly table: Table<StateLockRequest>;
17+
18+
public readonly model: Model<StateLockRequest>;
19+
20+
constructor() {
21+
this.table = new Table(TABLE_SUFFIX, SERVICE_NAME, STAGE, stateLockRequest, 'pk', 'sk', [
22+
{ hashKey: 'sk', rangeKey: 'pk', name: 'sk-pk-index', type: 'global' },
23+
]);
24+
25+
this.model = this.table.model;
26+
}
27+
28+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
29+
static prefix = (col: 'pk' | 'sk', value?: any): string => {
30+
if (col === 'pk') {
31+
return `lock_${value || ''}`;
32+
}
33+
return `statelock`;
34+
};
35+
36+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
37+
static isStateLock = (record: StreamRecord): boolean => {
38+
if (!record) {
39+
return false;
40+
}
41+
42+
const check = unmarshallDynamoDBImage(record.Keys) as { pk: string; sk: string };
43+
44+
if (!check.pk || !check.sk || typeof check.pk !== 'string' || typeof check.sk !== 'string') {
45+
return false;
46+
}
47+
48+
const { pk, sk } = check;
49+
50+
try {
51+
Joi.assert(pk, stateLockRequest.pk);
52+
Joi.assert(sk, stateLockRequest.sk);
53+
} catch (e) {
54+
return false;
55+
}
56+
57+
return true;
58+
};
59+
}

src/models/schemas/StateLock.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
import Joi from 'joi';
22

3-
export const stateLockRequest = Joi.object({
4-
ID: Joi.string().required(),
5-
Operation: Joi.string().required(),
6-
Info: Joi.string().allow('').optional(),
7-
Who: Joi.string().required(),
8-
Version: Joi.string().required(),
9-
Created: Joi.string().required(),
10-
Path: Joi.string().allow('').required(),
11-
}).label('StateLockRequest');
12-
133
export const stateLock = {
144
pk: Joi.string()
155
.regex(/github_(.*)/) // github_${orgId}
@@ -25,8 +15,6 @@ export const stateLock = {
2515
id: Joi.string().required(),
2616
path: Joi.string().allow('').required(),
2717
lockedBy: Joi.string().required(),
28-
request: stateLockRequest.required(),
29-
expires: Joi.number().required(),
3018
};
3119

3220
export const stateLockSchema = Joi.object(stateLock).label('StateLock');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Joi from 'joi';
2+
3+
export const stateLockRequest = {
4+
pk: Joi.string()
5+
.regex(/lock_(.*)/) // lock_${ID}
6+
.optional(),
7+
sk: Joi.string()
8+
.regex(/statelock/) // statelock
9+
.optional(),
10+
ID: Joi.string().required(),
11+
Operation: Joi.string().required(),
12+
Info: Joi.string().allow('').optional(),
13+
Who: Joi.string().required(),
14+
Version: Joi.string().required(),
15+
Created: Joi.string().required(),
16+
Path: Joi.string().allow('').required(),
17+
stateLock: Joi.object({
18+
pk: Joi.string().required(),
19+
sk: Joi.string().required(),
20+
}).optional(),
21+
identity: Joi.object({
22+
pk: Joi.string().required(),
23+
sk: Joi.string().required(),
24+
}),
25+
};
26+
27+
export const stateLockRequestSchema = Joi.object(stateLockRequest).label('StateLockRequest');

src/services/GithubService.ts

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TerraformError } from '../interfaces/errors';
44
import { Identity } from '../models/interfaces';
55
import crypto from 'crypto';
66
import { IdentityModel } from '../models/IdentityModel';
7+
import { StateLockRequest } from '../models/interfaces/StateLockRequest';
78
// import seedrandom from 'seedrandom';
89

910
export type IdentityWithToken = Identity & {
@@ -25,7 +26,10 @@ export class GithubService {
2526
this.identityModel = new IdentityModel();
2627
}
2728

28-
public getIdentity = async (request: HttpRequest): Promise<IdentityWithToken> => {
29+
public getIdentity = async (
30+
request: HttpRequest,
31+
stateLockRequest?: StateLockRequest,
32+
): Promise<IdentityWithToken> => {
2933
const { authorization } = request.headers;
3034
if (!authorization) {
3135
throw new TerraformError(401);
@@ -46,11 +50,6 @@ export class GithubService {
4650

4751
const [username, password] = decoded.split(':');
4852

49-
// Unauthenticated state storage for localstack
50-
if (username === 'localstack' && password === 'localstack') {
51-
return this.inferLocalstackIdentity();
52-
}
53-
5453
let owner: string | undefined;
5554
let repo: string | undefined;
5655
let workspace: string | undefined;
@@ -80,7 +79,7 @@ export class GithubService {
8079
}
8180

8281
try {
83-
const identity = await this.inferIdentity(password, owner, repo, workspace);
82+
const identity = await this.inferIdentity(password, owner, repo, workspace, stateLockRequest);
8483

8584
console.log(
8685
`Using identity: ${identity.owner}/${identity.repo} [${identity.ownerId}/${identity.repoId}]`,
@@ -101,24 +100,31 @@ export class GithubService {
101100
owner?: string,
102101
repo?: string,
103102
workspace?: string,
103+
stateLockRequest?: StateLockRequest,
104104
): Promise<Identity> => {
105105
const tokenSha = crypto.createHash('sha256').update(auth).digest().toString('base64');
106106

107107
console.log(
108-
`Inferring identity (auth: ${auth} sha: ${tokenSha} owner: ${owner}, repo: ${repo}, workspace: ${workspace})`,
108+
`Inferring identity (auth: ${auth} sha: ${tokenSha} owner: ${owner}, repo: ${repo}, workspace: ${workspace}, stateLockRequest; ${stateLockRequest})`,
109109
);
110110

111-
// TODO Expire stored identities
112-
const storedIdentity = await this.identityModel.model.get(
113-
IdentityModel.prefix('pk', tokenSha),
114-
IdentityModel.prefix('sk'),
115-
);
111+
let storedIdentity: Identity | undefined;
112+
113+
if (stateLockRequest && stateLockRequest.identity) {
114+
const { pk, sk } = stateLockRequest.identity;
116115

117-
if (storedIdentity) {
118-
// Terraform planfiles contain backend configurations from plan operations
119-
// Return the previously known identy from the plan operation
116+
const identity = await this.identityModel.model.get(pk, sk);
117+
118+
if (identity) {
119+
storedIdentity = identity.attrs;
120+
}
121+
}
122+
123+
if (storedIdentity && stateLockRequest && stateLockRequest.Operation === 'OperationTypeApply') {
124+
// Terraform planfiles contain credentials from plan operations
125+
// Return the previously known identity from the plan operation
120126
console.log(`Found previously known identity (sha: ${tokenSha})`);
121-
return { ...storedIdentity.attrs, workspace: workspace || 'default' };
127+
return { ...storedIdentity, workspace: workspace || 'default' };
122128
}
123129

124130
const octokit = new Octokit({ auth });
@@ -212,33 +218,4 @@ export class GithubService {
212218
`Unable to determine owner and/or repository from token privileges. Ensure \`username\` is in the format of \`{owner}/{repository}\`, and the provided \`password\` (a GitHub token) has access to that repository.`,
213219
);
214220
};
215-
216-
// TODO: support 'who' from State Lock Request
217-
private inferLocalstackIdentity = (who = 'unknown@unknown'): IdentityWithToken => {
218-
const tokenSha = crypto.createHash('sha256').update(who).digest().toString('base64');
219-
220-
const [username, host] = who.split('@');
221-
if (!username || !host) {
222-
throw new Error(`Invalid format for \`Who\` on state lock request`);
223-
}
224-
225-
// Set IDs as negative so they're clearly out of valid range
226-
// const ownerId = seedrandom(host).int32() * -1;
227-
// const repoId = seedrandom(username).int32() * -1;
228-
229-
return {
230-
pk: IdentityModel.prefix('pk', tokenSha),
231-
sk: IdentityModel.prefix('sk'),
232-
owner: host,
233-
ownerId: -1,
234-
repo: username,
235-
repoId: -1,
236-
token: who,
237-
tokenSha: tokenSha,
238-
workspace: 'default',
239-
meta: {
240-
name: 'localstack',
241-
},
242-
};
243-
};
244221
}

0 commit comments

Comments
 (0)