1
0
Fork 0

stub out most tests, basic docs

This commit is contained in:
Sean Sube 2023-01-04 09:48:26 -06:00
parent 515ecae4ad
commit cfd72a3efd
32 changed files with 474 additions and 221 deletions

View File

@ -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

BIN
docs/banner-bottom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
docs/banner-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

27
docs/concepts.md Normal file
View File

@ -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.

48
docs/getting-started.md Normal file
View File

@ -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

View File

@ -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()

View File

@ -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];
}

View File

@ -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) {

View File

@ -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';

View File

@ -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);
}

View File

@ -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> {

View File

@ -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');
}
}

View File

@ -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);
}

75
src/utils/lock.ts Normal file
View File

@ -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(', ');
}

36
src/utils/path.ts Normal file
View File

@ -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;
}

7
src/utils/string.ts Normal file
View File

@ -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);
}

63
src/utils/time.ts Normal file
View File

@ -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');
}

View File

@ -0,0 +1,3 @@
describe('check command', () => {
it('should check locks');
});

3
test/command/TestList.ts Normal file
View File

@ -0,0 +1,3 @@
describe('list command', () => {
it('should list locks');
});

View File

@ -0,0 +1,3 @@
describe('listen command', () => {
it('should listen on the REST API');
});

3
test/command/TestLock.ts Normal file
View File

@ -0,0 +1,3 @@
describe('lock command', () => {
it('should create locks');
});

View File

@ -0,0 +1,3 @@
describe('prune command', () => {
it('should delete expired locks');
});

View File

@ -0,0 +1,3 @@
describe('unlock command', () => {
it('should delete locks');
});

1
test/helpers.ts Normal file
View File

@ -0,0 +1 @@
export const REQUIRED_TEST_ARGS = ['--source=test/unit', '--type=deploy'];

View File

@ -0,0 +1,3 @@
describe('admission controller', () => {
it('should admit things');
});

View File

View File

@ -0,0 +1,3 @@
describe('DynamoDB storage', () => {
it('should store things in DDB');
});

View File

@ -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);
});
});

23
test/utils/TestLock.ts Normal file
View File

@ -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');
});

13
test/utils/TestPath.ts Normal file
View File

@ -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');
});

5
test/utils/TestString.ts Normal file
View File

@ -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');
});

22
test/utils/TestTime.ts Normal file
View File

@ -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');
});