stub out most tests, basic docs
This commit is contained in:
parent
515ecae4ad
commit
cfd72a3efd
|
@ -3,6 +3,8 @@
|
|||
This is a tool to lock a cluster or service, in order to prevent people from deploying changes during test automation or
|
||||
restarting pods during an infrastructure incident.
|
||||
|
||||
![readme banner top](docs/banner-top.png)
|
||||
|
||||
## Contents
|
||||
|
||||
- [Deploy Lock](#deploy-lock)
|
||||
|
@ -200,6 +202,7 @@ Friendly strings for `type`:
|
|||
> deploy-lock check apps && deploy-lock check apps/staging && deploy-lock check apps/staging/a && deploy-lock check apps/staging/a/auth-app
|
||||
> deploy-lock check apps/staging/a/auth-app --recursive=false # only checks the leaf node
|
||||
|
||||
> deploy-lock list
|
||||
> deploy-lock list apps/staging # list all locks within the apps/staging path
|
||||
|
||||
> deploy-lock lock apps/staging --type automation --duration 60m
|
||||
|
@ -238,6 +241,10 @@ Friendly strings for `type`:
|
|||
|
||||
#### Lock Data Options
|
||||
|
||||
- `--allow`
|
||||
- array, strings
|
||||
- check types to allow while this lock is active
|
||||
- this does not allow creating other locks at the same path, it only changes whether the check returns allowed
|
||||
- `--author`
|
||||
- string
|
||||
- defaults to `$GITLAB_USER_EMAIL` if `$GITLAB_CI` is set
|
||||
|
@ -352,6 +359,8 @@ Friendly strings for `type`:
|
|||
- `/ok`
|
||||
- health check
|
||||
|
||||
![readme bottom banner](docs/banner-bottom.png)
|
||||
|
||||
## Development
|
||||
|
||||
### Features
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 406 KiB |
Binary file not shown.
After Width: | Height: | Size: 256 KiB |
|
@ -0,0 +1,27 @@
|
|||
# Concepts
|
||||
|
||||
Explain the following:
|
||||
|
||||
## Contents
|
||||
|
||||
- [Concepts](#concepts)
|
||||
- [Contents](#contents)
|
||||
- [Deploy Path](#deploy-path)
|
||||
- [Exclusive and Partial Locks](#exclusive-and-partial-locks)
|
||||
- [Multi-Party Locks](#multi-party-locks)
|
||||
|
||||
## Deploy Path
|
||||
|
||||
Anything that can be deployed or locked needs to be identified by a consistent path.
|
||||
|
||||
Paths are user-defined and slash-delimited.
|
||||
|
||||
## Exclusive and Partial Locks
|
||||
|
||||
Some locks allow certain types, some locks don't allow anything. Why is that?
|
||||
|
||||
## Multi-Party Locks
|
||||
|
||||
Idea: make `LockData.author` an array, with commands to add/remove an author to an existing lock.
|
||||
|
||||
Why: there may be more than one person/team who needs to sign off on an incident before it is closed.
|
|
@ -0,0 +1,48 @@
|
|||
# Getting Started
|
||||
|
||||
## Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Contents](#contents)
|
||||
- [Why use this tool?](#why-use-this-tool)
|
||||
- [How to use this tool](#how-to-use-this-tool)
|
||||
- [Setup and Prerequisites](#setup-and-prerequisites)
|
||||
- [Install and first run](#install-and-first-run)
|
||||
- [Lock something](#lock-something)
|
||||
- [Check and unlock](#check-and-unlock)
|
||||
- [Daily workflow](#daily-workflow)
|
||||
- [As a Devops](#as-a-devops)
|
||||
- [As a Release Manager](#as-a-release-manager)
|
||||
- [Using in CI](#using-in-ci)
|
||||
- [Automation](#automation)
|
||||
- [Deploy](#deploy)
|
||||
- [Using in Kubernetes](#using-in-kubernetes)
|
||||
- [Validating Admission Controller](#validating-admission-controller)
|
||||
|
||||
## Why use this tool?
|
||||
|
||||
## How to use this tool
|
||||
|
||||
### Setup and Prerequisites
|
||||
|
||||
### Install and first run
|
||||
|
||||
### Lock something
|
||||
|
||||
### Check and unlock
|
||||
|
||||
## Daily workflow
|
||||
|
||||
### As a Devops
|
||||
|
||||
### As a Release Manager
|
||||
|
||||
## Using in CI
|
||||
|
||||
### Automation
|
||||
|
||||
### Deploy
|
||||
|
||||
## Using in Kubernetes
|
||||
|
||||
### Validating Admission Controller
|
15
src/args.ts
15
src/args.ts
|
@ -4,7 +4,7 @@ import yargs from 'yargs';
|
|||
import { CommandName } from './command/index.js';
|
||||
import { LOCK_TYPES, LockType } from './lock.js';
|
||||
import { STORAGE_TYPES, StorageType } from './storage/index.js';
|
||||
import { dateToSeconds, parseTime } from './utils.js';
|
||||
import { dateToSeconds, parseTime } from './utils/time.js';
|
||||
|
||||
/**
|
||||
* CLI options.
|
||||
|
@ -49,6 +49,10 @@ export interface ParsedArgs {
|
|||
storage: StorageType;
|
||||
region: string;
|
||||
table: string;
|
||||
|
||||
// admission options
|
||||
'admission-base': string;
|
||||
'admission-type': LockType;
|
||||
}
|
||||
|
||||
export const APP_NAME = 'deploy-lock';
|
||||
|
@ -171,6 +175,15 @@ export async function parseArgs(argv: Array<string>): Promise<ParsedArgs> {
|
|||
'ci-job': {
|
||||
type: 'string',
|
||||
},
|
||||
'admission-base': {
|
||||
type: 'string',
|
||||
default: 'kube',
|
||||
},
|
||||
'admission-type': {
|
||||
type: 'string',
|
||||
choices: Object.keys(LOCK_TYPES) as ReadonlyArray<LockType>,
|
||||
default: 'deploy' as LockType,
|
||||
},
|
||||
})
|
||||
.env(ENV_NAME)
|
||||
.help()
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { doesExist } from '@apextoaster/js-utils';
|
||||
import { doesExist, mustDefault } from '@apextoaster/js-utils';
|
||||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
import { LockData } from '../lock.js';
|
||||
import { printLock, walkPath } from '../utils.js';
|
||||
import { LockData, LockType } from '../lock.js';
|
||||
import { printLock } from '../utils/lock.js';
|
||||
import { walkPath } from '../utils/path.js';
|
||||
import { CommandContext } from './index.js';
|
||||
|
||||
export async function checkCommand(context: CommandContext) {
|
||||
|
@ -15,7 +15,14 @@ export async function checkCommand(context: CommandContext) {
|
|||
return allowed;
|
||||
}
|
||||
|
||||
export function getCheckPaths(args: ParsedArgs): Array<string> {
|
||||
export interface CheckOptions {
|
||||
path: string;
|
||||
type: LockType;
|
||||
now: number;
|
||||
recursive: boolean;
|
||||
}
|
||||
|
||||
export function getCheckPaths(args: CheckOptions): Array<string> {
|
||||
if (args.recursive) {
|
||||
return walkPath(args.path);
|
||||
} else {
|
||||
|
@ -25,12 +32,10 @@ export function getCheckPaths(args: ParsedArgs): Array<string> {
|
|||
|
||||
/**
|
||||
* This is the core of the check logic, including the path recursion and allow type logic.
|
||||
*
|
||||
* @todo take a limited form of `args` to make this easier to use in the Express API/tests
|
||||
*/
|
||||
export async function checkArgsPath(context: CommandContext): Promise<[boolean, Array<LockData>]> {
|
||||
const { args, logger, storage } = context;
|
||||
const { now, type } = args;
|
||||
export async function checkArgsPath(context: CommandContext, overrideArgs?: CheckOptions): Promise<[boolean, Array<LockData>]> {
|
||||
const { logger, storage } = context;
|
||||
const args = mustDefault(overrideArgs, context.args);
|
||||
|
||||
const locks: Array<LockData> = [];
|
||||
const paths = getCheckPaths(args);
|
||||
|
@ -38,14 +43,14 @@ export async function checkArgsPath(context: CommandContext): Promise<[boolean,
|
|||
const lock = await storage.get(path);
|
||||
|
||||
if (doesExist(lock)) {
|
||||
if (lock.expires_at < now) {
|
||||
if (lock.expires_at < args.now) {
|
||||
logger.debug({ lock, path }, 'found expired lock');
|
||||
} else {
|
||||
const friendly = printLock(path, lock);
|
||||
logger.debug({ lock, friendly, path }, 'found active lock');
|
||||
locks.push(lock);
|
||||
|
||||
if (lock.allow.includes(type)) {
|
||||
if (lock.allow.includes(args.type)) {
|
||||
logger.warn({ lock, friendly, path }, 'found existing lock, check type is allowed');
|
||||
} else {
|
||||
logger.warn({ lock, friendly, path }, 'found existing lock, check type is not allowed');
|
||||
|
@ -54,6 +59,6 @@ export async function checkArgsPath(context: CommandContext): Promise<[boolean,
|
|||
}
|
||||
}
|
||||
|
||||
const allowed = locks.every((lock) => lock.allow.includes(type));
|
||||
const allowed = locks.every((lock) => lock.allow.includes(args.type));
|
||||
return [allowed, locks];
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { printLock } from '../utils.js';
|
||||
import { printLock } from '../utils/lock.js';
|
||||
import { CommandContext } from './index.js';
|
||||
|
||||
export async function listCommand(context: CommandContext) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { buildLock } from '../utils.js';
|
||||
import { buildLock } from '../utils/lock.js';
|
||||
import { checkCommand } from './check.js';
|
||||
import { CommandContext } from './index.js';
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import express, { Express, Request, Response } from 'express';
|
|||
import { APP_NAME } from '../args.js';
|
||||
import { checkArgsPath } from '../command/check.js';
|
||||
import { LockData, LockType } from '../lock.js';
|
||||
import { dateToSeconds } from '../utils.js';
|
||||
import { dateToSeconds } from '../utils/time.js';
|
||||
import { AdmissionRequest, buildAdmissionResponse, getAdmissionPath } from './admission.js';
|
||||
import { ServerContext } from './index.js';
|
||||
|
||||
|
@ -39,16 +39,16 @@ export async function expressIndex(context: ServerContext, app: Express, req: Re
|
|||
|
||||
export async function expressAdmission(context: ServerContext, req: Request, res: Response): Promise<void> {
|
||||
const admissionRequest = req.body as AdmissionRequest; // TODO: validate requests
|
||||
const path = getAdmissionPath('kube', admissionRequest); // TODO: take admission base from context args
|
||||
context.logger.info({ path }, 'express admission request');
|
||||
const path = getAdmissionPath(context.args['admission-base'], admissionRequest); // TODO: take admission base from context args
|
||||
const now = dateToSeconds(new Date()); // TODO: Date needs to be injected to test this
|
||||
|
||||
const [allowed] = await checkArgsPath({
|
||||
...context,
|
||||
args: {
|
||||
...context.args,
|
||||
path,
|
||||
type: 'deploy', // TODO: add a way to define this?
|
||||
}
|
||||
context.logger.info({ now, path }, 'express admission request');
|
||||
|
||||
const [allowed] = await checkArgsPath(context, {
|
||||
now,
|
||||
path,
|
||||
recursive: true,
|
||||
type: context.args['admission-type'],
|
||||
});
|
||||
|
||||
const admission = buildAdmissionResponse(allowed, admissionRequest.request.uid);
|
||||
|
@ -59,17 +59,15 @@ export async function expressAdmission(context: ServerContext, req: Request, res
|
|||
export async function expressCheck(context: ServerContext, req: Request, res: Response): Promise<void> {
|
||||
const path = req.params[0];
|
||||
const type = mustDefault(req.query.type as LockType, 'deploy');
|
||||
const now = dateToSeconds(new Date()); // TODO: Date needs to be injected to test this
|
||||
|
||||
context.logger.info({ path }, 'express check request');
|
||||
context.logger.info({ now, path, type }, 'express check request');
|
||||
|
||||
const [allowed, locks] = await checkArgsPath({
|
||||
...context,
|
||||
args: {
|
||||
...context.args,
|
||||
// TODO: is this a hack?
|
||||
path,
|
||||
type,
|
||||
}
|
||||
const [allowed, locks] = await checkArgsPath(context, {
|
||||
now,
|
||||
path,
|
||||
recursive: true,
|
||||
type,
|
||||
});
|
||||
sendLocks(res, locks, allowed);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
/* eslint-disable camelcase */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable sonarjs/no-duplicate-string, camelcase */
|
||||
import { doesExist, mustExist } from '@apextoaster/js-utils';
|
||||
import {
|
||||
AttributeValue,
|
||||
|
@ -11,7 +10,7 @@ import {
|
|||
} from '@aws-sdk/client-dynamodb';
|
||||
|
||||
import { LockCI, LockData, LockType } from '../lock.js';
|
||||
import { splitEmpty } from '../utils.js';
|
||||
import { splitEmpty } from '../utils/string.js';
|
||||
import { Storage, StorageContext } from './index.js';
|
||||
|
||||
export async function dynamoDelete(context: StorageContext, client: DynamoDBClient, path: string): Promise<LockData | undefined> {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { doesExist, NotImplementedError } from '@apextoaster/js-utils';
|
||||
import { doesExist } from '@apextoaster/js-utils';
|
||||
|
||||
import { LockData } from '../lock.js';
|
||||
import { buildLock } from '../utils.js';
|
||||
import { buildLock } from '../utils/lock.js';
|
||||
import { Storage, StorageContext } from './index.js';
|
||||
|
||||
export type Cache = Map<string, LockData>;
|
||||
|
@ -52,17 +52,18 @@ export async function memorySet(context: StorageContext, cache: Cache, data: Loc
|
|||
export function buildCache(context: StorageContext): Cache {
|
||||
const cache: Cache = new Map<string, LockData>();
|
||||
|
||||
for (const lock of Array.from(context.args.fake)) {
|
||||
const match = /^([-a-z/]+):({.+})$/.exec(lock);
|
||||
for (const fake of Array.from(context.args.fake)) {
|
||||
const match = /^([-a-z/]+):({.+})$/.exec(fake);
|
||||
if (doesExist(match)) {
|
||||
const [_full, path, rawData] = Array.from(match);
|
||||
const base = buildLock(context.args); // TODO: require full records?
|
||||
const data = JSON.parse(rawData) as LockData;
|
||||
const lock = buildLock(context.args);
|
||||
Object.assign(lock, data);
|
||||
|
||||
context.logger.info({ path, data }, 'assuming lock exists');
|
||||
cache.set(path, Object.assign(base, data));
|
||||
context.logger.info({ path, data, lock }, 'assuming lock exists');
|
||||
cache.set(path, lock);
|
||||
} else {
|
||||
context.logger.warn({ lock }, 'invalid lock in assume');
|
||||
context.logger.warn({ lock: fake }, 'invalid lock in assume');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
175
src/utils.ts
175
src/utils.ts
|
@ -1,175 +0,0 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { doesExist, InvalidArgumentError, mustDefault } from '@apextoaster/js-utils';
|
||||
|
||||
import { ParsedArgs } from './args.js';
|
||||
import { LOCK_TYPES, LockCI, LockData } from './lock.js';
|
||||
|
||||
export const MILLIS_PER_SECOND = 1000;
|
||||
export const SECONDS_PER_MINUTE = 60;
|
||||
export const MINUTES_PER_HOUR = 60;
|
||||
export const HOURS_PER_DAY = 24;
|
||||
export const DAYS_PER_YEAR = 365;
|
||||
|
||||
export function matchPath(baseStr: string, otherStr: string): boolean {
|
||||
const base = splitPath(baseStr);
|
||||
const other = splitPath(otherStr);
|
||||
|
||||
if (other.length < base.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i <= base.length; ++i) {
|
||||
if (base[i] !== other[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function splitPath(path: string): Array<string> {
|
||||
return path.toLowerCase().split('/').map((part) => part.trim()).filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
export function walkPath(path: string, start = 0): Array<string> {
|
||||
const segments = splitPath(path);
|
||||
|
||||
const paths = [];
|
||||
for (let i = (start + 1); i <= segments.length; ++i) {
|
||||
const next = segments.slice(start, i).join('/');
|
||||
paths.push(next);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
export const ENV_UNSET = 'not set';
|
||||
|
||||
export function buildCI(args: ParsedArgs, env: typeof process.env): LockCI | undefined {
|
||||
if (doesExist(env.GITLAB_CI)) {
|
||||
return {
|
||||
project: mustDefault(args['ci-project'], env.CI_PROJECT_PATH, ENV_UNSET),
|
||||
ref: mustDefault(args['ci-ref'], env.CI_COMMIT_REF_SLUG, ENV_UNSET),
|
||||
commit: mustDefault(args['ci-commit'], env.CI_COMMIT_SHA, ENV_UNSET),
|
||||
pipeline: mustDefault(args['ci-pipeline'], env.CI_PIPELINE_ID, ENV_UNSET),
|
||||
job: mustDefault(args['ci-job'], env.CI_JOB_ID, ENV_UNSET),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAuthor(args: ParsedArgs, env: typeof process.env): string {
|
||||
if (doesExist(env.GITLAB_CI)) {
|
||||
return mustDefault(args.author, env.GITLAB_USER_EMAIL, env.USER);
|
||||
}
|
||||
|
||||
return mustDefault(args.author, env.USER);
|
||||
}
|
||||
|
||||
export function buildLinks(args: ParsedArgs): Record<string, string> {
|
||||
const links: Record<string, string> = {};
|
||||
|
||||
for (const link of args.links) {
|
||||
const match = /^([-a-z]+):(.+)$/.exec(link);
|
||||
if (doesExist(match)) {
|
||||
const [_full, name, url] = Array.from(match);
|
||||
links[name] = url;
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function buildLock(args: ParsedArgs, env = process.env): LockData {
|
||||
return {
|
||||
type: mustDefault(args.type, 'deploy'),
|
||||
path: args.path,
|
||||
author: buildAuthor(args, env),
|
||||
links: buildLinks(args),
|
||||
allow: args.allow,
|
||||
created_at: args.now,
|
||||
updated_at: args.now,
|
||||
expires_at: calculateExpires(args),
|
||||
source: args.source,
|
||||
ci: buildCI(args, env),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateExpires(args: ParsedArgs): number {
|
||||
if (doesExist(args.until)) {
|
||||
return parseTime(args.until);
|
||||
}
|
||||
|
||||
if (doesExist(args.duration)) {
|
||||
return args.now + parseTime(args.duration);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentError('must provide either duration or until');
|
||||
}
|
||||
|
||||
export const TIME_MULTIPLIERS: Array<[RegExp, number]> = [
|
||||
[/^(\d+)$/, 1], // integer seconds
|
||||
[/^(\d+)s$/, 1], // human seconds
|
||||
[/^(\d+)m$/, SECONDS_PER_MINUTE], // human minutes
|
||||
[/^(\d+)h$/, SECONDS_PER_MINUTE * MINUTES_PER_HOUR], // human hours
|
||||
[/^(\d+)d$/, SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY], // human days
|
||||
[/^(\d+)y$/, SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY * DAYS_PER_YEAR], // human years
|
||||
];
|
||||
|
||||
export const TIME_ISO_MATCH = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?/;
|
||||
|
||||
/**
|
||||
* Convert a string of the form `12345` or `15m` into seconds.
|
||||
*/
|
||||
export function parseTime(time: number | string): number {
|
||||
if (typeof time === 'number') {
|
||||
return time;
|
||||
}
|
||||
|
||||
if (TIME_ISO_MATCH.test(time)) {
|
||||
return dateToSeconds(new Date(time));
|
||||
}
|
||||
|
||||
for (const [regex, mult] of TIME_MULTIPLIERS) {
|
||||
const match = time.match(regex);
|
||||
if (doesExist(match)) {
|
||||
const [_full, digits] = Array.from(match);
|
||||
return parseInt(digits, 10) * mult;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentError('invalid time');
|
||||
}
|
||||
|
||||
export function printLinks(links: Record<string, string>): Array<string> {
|
||||
return Object.keys(links).map((name) => `${name}: ${links[name]}`);
|
||||
}
|
||||
|
||||
export function printTime(time: number): string {
|
||||
return new Date(time * MILLIS_PER_SECOND).toLocaleString();
|
||||
}
|
||||
|
||||
export function printLock(path: string, data: LockData): string {
|
||||
const friendlyExpires = printTime(data.expires_at);
|
||||
const friendlyLinks = printLinks(data.links);
|
||||
const friendlyType = LOCK_TYPES[data.type];
|
||||
|
||||
const base = `${path} is locked until ${friendlyExpires} by ${friendlyType} in ${data.source}`;
|
||||
return [
|
||||
base,
|
||||
...friendlyLinks,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
export function splitEmpty(val: string, delimiter = ','): Array<string> {
|
||||
if (val.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return val.split(delimiter).map((it) => it.trim()).filter((it) => it.length > 0);
|
||||
}
|
||||
|
||||
export function dateToSeconds(date: Date): number {
|
||||
return Math.round(date.getTime() / MILLIS_PER_SECOND);
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { doesExist, mustDefault } from '@apextoaster/js-utils';
|
||||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
import { LOCK_TYPES, LockCI, LockData } from '../lock.js';
|
||||
import { calculateExpires, printTime } from './time.js';
|
||||
|
||||
export const ENV_UNSET = 'not set';
|
||||
|
||||
export function buildCI(args: ParsedArgs, env: typeof process.env): LockCI | undefined {
|
||||
if (doesExist(env.GITLAB_CI)) {
|
||||
return {
|
||||
project: mustDefault(args['ci-project'], env.CI_PROJECT_PATH, ENV_UNSET),
|
||||
ref: mustDefault(args['ci-ref'], env.CI_COMMIT_REF_SLUG, ENV_UNSET),
|
||||
commit: mustDefault(args['ci-commit'], env.CI_COMMIT_SHA, ENV_UNSET),
|
||||
pipeline: mustDefault(args['ci-pipeline'], env.CI_PIPELINE_ID, ENV_UNSET),
|
||||
job: mustDefault(args['ci-job'], env.CI_JOB_ID, ENV_UNSET),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAuthor(args: ParsedArgs, env: typeof process.env): string {
|
||||
if (doesExist(env.GITLAB_CI)) {
|
||||
return mustDefault(args.author, env.GITLAB_USER_EMAIL, env.USER);
|
||||
}
|
||||
|
||||
return mustDefault(args.author, env.USER);
|
||||
}
|
||||
|
||||
export function buildLinks(args: ParsedArgs): Record<string, string> {
|
||||
const links: Record<string, string> = {};
|
||||
|
||||
for (const link of args.links) {
|
||||
const match = /^([-a-z]+):(.+)$/.exec(link);
|
||||
if (doesExist(match)) {
|
||||
const [_full, name, url] = Array.from(match);
|
||||
links[name] = url;
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function buildLock(args: ParsedArgs, env = process.env): LockData {
|
||||
return {
|
||||
type: mustDefault(args.type, 'deploy'),
|
||||
path: args.path,
|
||||
author: buildAuthor(args, env),
|
||||
links: buildLinks(args),
|
||||
allow: args.allow,
|
||||
created_at: args.now,
|
||||
updated_at: args.now,
|
||||
expires_at: calculateExpires(args),
|
||||
source: args.source,
|
||||
ci: buildCI(args, env),
|
||||
};
|
||||
}
|
||||
|
||||
export function printLinks(links: Record<string, string>): Array<string> {
|
||||
return Object.keys(links).map((name) => `${name}: ${links[name]}`);
|
||||
}
|
||||
|
||||
export function printLock(path: string, data: LockData): string {
|
||||
const friendlyExpires = printTime(data.expires_at);
|
||||
const friendlyLinks = printLinks(data.links);
|
||||
const friendlyType = LOCK_TYPES[data.type];
|
||||
|
||||
const base = `${path} is locked until ${friendlyExpires} by ${friendlyType} in ${data.source}`;
|
||||
return [
|
||||
base,
|
||||
...friendlyLinks,
|
||||
].join(', ');
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { splitEmpty } from './string.js';
|
||||
|
||||
export const PATH_DELIMITER = '/';
|
||||
|
||||
export function matchPath(baseStr: string, otherStr: string): boolean {
|
||||
const base = splitPath(baseStr);
|
||||
const other = splitPath(otherStr);
|
||||
|
||||
if (other.length < base.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i <= base.length; ++i) {
|
||||
if (base[i] !== other[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function splitPath(path: string): Array<string> {
|
||||
return splitEmpty(path.toLowerCase(), PATH_DELIMITER);
|
||||
}
|
||||
|
||||
export function walkPath(path: string, start = 0): Array<string> {
|
||||
const segments = splitPath(path);
|
||||
|
||||
const paths = [];
|
||||
for (let i = (start + 1); i <= segments.length; ++i) {
|
||||
const next = segments.slice(start, i).join(PATH_DELIMITER);
|
||||
paths.push(next);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export function splitEmpty(val: string, delimiter = ','): Array<string> {
|
||||
if (val.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return val.split(delimiter).map((it) => it.trim()).filter((it) => it.length > 0);
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { doesExist, InvalidArgumentError } from '@apextoaster/js-utils';
|
||||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
|
||||
export const MILLIS_PER_SECOND = 1000;
|
||||
export const SECONDS_PER_MINUTE = 60;
|
||||
export const MINUTES_PER_HOUR = 60;
|
||||
export const HOURS_PER_DAY = 24;
|
||||
export const DAYS_PER_YEAR = 365;
|
||||
|
||||
export const TIME_MULTIPLIERS: Array<[RegExp, number]> = [
|
||||
[/^(\d+)$/, 1], // integer seconds
|
||||
[/^(\d+)s$/, 1], // human seconds
|
||||
[/^(\d+)m$/, SECONDS_PER_MINUTE], // human minutes
|
||||
[/^(\d+)h$/, SECONDS_PER_MINUTE * MINUTES_PER_HOUR], // human hours
|
||||
[/^(\d+)d$/, SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY], // human days
|
||||
[/^(\d+)y$/, SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY * DAYS_PER_YEAR], // human years
|
||||
];
|
||||
|
||||
export const TIME_ISO_MATCH = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?/;
|
||||
|
||||
export function dateToSeconds(date: Date): number {
|
||||
return Math.round(date.getTime() / MILLIS_PER_SECOND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string of the form `12345` or `15m` into seconds.
|
||||
*/
|
||||
export function parseTime(time: number | string): number {
|
||||
if (typeof time === 'number') {
|
||||
return time;
|
||||
}
|
||||
|
||||
if (TIME_ISO_MATCH.test(time)) {
|
||||
return dateToSeconds(new Date(time));
|
||||
}
|
||||
|
||||
for (const [regex, mult] of TIME_MULTIPLIERS) {
|
||||
const match = time.match(regex);
|
||||
if (doesExist(match)) {
|
||||
const [_full, digits] = Array.from(match);
|
||||
return parseInt(digits, 10) * mult;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentError('invalid time');
|
||||
}
|
||||
|
||||
export function printTime(time: number): string {
|
||||
return new Date(time * MILLIS_PER_SECOND).toLocaleString();
|
||||
}
|
||||
|
||||
export function calculateExpires(args: ParsedArgs): number {
|
||||
if (doesExist(args.until)) {
|
||||
return parseTime(args.until);
|
||||
}
|
||||
|
||||
if (doesExist(args.duration)) {
|
||||
return args.now + parseTime(args.duration);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentError('must provide either duration or until');
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
describe('check command', () => {
|
||||
it('should check locks');
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
describe('list command', () => {
|
||||
it('should list locks');
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
describe('listen command', () => {
|
||||
it('should listen on the REST API');
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
describe('lock command', () => {
|
||||
it('should create locks');
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
describe('prune command', () => {
|
||||
it('should delete expired locks');
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
describe('unlock command', () => {
|
||||
it('should delete locks');
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export const REQUIRED_TEST_ARGS = ['--source=test/unit', '--type=deploy'];
|
|
@ -0,0 +1,3 @@
|
|||
describe('admission controller', () => {
|
||||
it('should admit things');
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
describe('DynamoDB storage', () => {
|
||||
it('should store things in DDB');
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
import { expect } from 'chai';
|
||||
import { ConsoleLogger } from 'noicejs';
|
||||
|
||||
import { parseArgs } from '../../src/args.js';
|
||||
import { buildCache, Cache, memoryDelete, memoryGet, memoryList, memorySet } from '../../src/storage/memory.js';
|
||||
import { buildLock } from '../../src/utils/lock.js';
|
||||
import { REQUIRED_TEST_ARGS } from '../helpers.js';
|
||||
|
||||
describe('in-memory storage', () => {
|
||||
it('should list all locks in the cache', async () => {
|
||||
const args = await parseArgs(REQUIRED_TEST_ARGS);
|
||||
const logger = ConsoleLogger.global;
|
||||
const cache: Cache = new Map();
|
||||
|
||||
const locks = await memoryList({ args, logger }, cache);
|
||||
expect(locks.length).to.equal(cache.size);
|
||||
});
|
||||
|
||||
it('should remove locks from the cache', async () => {
|
||||
const args = await parseArgs(REQUIRED_TEST_ARGS);
|
||||
const logger = ConsoleLogger.global;
|
||||
const cache: Cache = new Map();
|
||||
|
||||
await memoryDelete({ args, logger }, cache, 'test');
|
||||
expect(cache.size).to.equal(0);
|
||||
});
|
||||
|
||||
it('should get locks from the cache', async () => {
|
||||
const args = await parseArgs(REQUIRED_TEST_ARGS);
|
||||
const logger = ConsoleLogger.global;
|
||||
const cache: Cache = new Map();
|
||||
|
||||
const lock = await memoryGet({ args, logger }, cache, 'test');
|
||||
expect(lock).to.not.equal(undefined);
|
||||
});
|
||||
|
||||
it('should add a lock to the cache', async () => {
|
||||
const args = await parseArgs([
|
||||
...REQUIRED_TEST_ARGS,
|
||||
'--duration=5m',
|
||||
]);
|
||||
const logger = ConsoleLogger.global;
|
||||
const cache: Cache = new Map();
|
||||
|
||||
await memorySet({ args, logger }, cache, buildLock(args));
|
||||
expect(cache.size).to.equal(1);
|
||||
});
|
||||
|
||||
it('should prime the cache with fake locks', async () => {
|
||||
const args = await parseArgs([
|
||||
...REQUIRED_TEST_ARGS,
|
||||
'--fake=test:{}',
|
||||
]);
|
||||
const logger = ConsoleLogger.global;
|
||||
|
||||
const cache = buildCache({ args, logger });
|
||||
expect(cache.size).to.equal(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
describe('build CI helper', () => {
|
||||
it('should build a CI object from the environment');
|
||||
});
|
||||
|
||||
describe('build author helper', () => {
|
||||
it('should build an author object from the environment');
|
||||
});
|
||||
|
||||
describe('build links helper', () => {
|
||||
it('should split link name and URL into a map');
|
||||
});
|
||||
|
||||
describe('build lock helper', () => {
|
||||
it('should build a lock object from the environment');
|
||||
});
|
||||
|
||||
describe('print links helper', () => {
|
||||
it('should print each link with name and URL');
|
||||
});
|
||||
|
||||
describe('print lock helper', () => {
|
||||
it('should include links');
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
describe('match path helper', () => {
|
||||
it('should match initial segments');
|
||||
it('should never match if the other path is shorter than the base path');
|
||||
});
|
||||
|
||||
describe('split path helper', () => {
|
||||
it('should split paths on forward slashes');
|
||||
it('should normalize paths');
|
||||
});
|
||||
|
||||
describe('walk path helper', () => {
|
||||
it('should return every path from base to leaf');
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
describe('split empty helper', () => {
|
||||
it('should trim segments');
|
||||
it('should remove empty segments');
|
||||
it('should return an empty array for an empty string');
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
describe('calculate expires helper', () => {
|
||||
it('should base duration on now');
|
||||
it('should ignore now for until');
|
||||
});
|
||||
|
||||
describe('parse time helper', () => {
|
||||
it('should parse ISO dates');
|
||||
it('should parse numeric seconds');
|
||||
it('should parse human seconds');
|
||||
it('should parse human minutes');
|
||||
it('should parse human hours');
|
||||
it('should parse human days');
|
||||
it('should parse human years');
|
||||
});
|
||||
|
||||
describe('print time helper', () => {
|
||||
it('should print timestamps');
|
||||
});
|
||||
|
||||
describe('date to seconds helper', () => {
|
||||
it('should convert a date into integer seconds');
|
||||
});
|
Loading…
Reference in New Issue