chore: Adding mainFn helpers

This commit is contained in:
BluJ
2023-04-07 11:43:05 -06:00
parent 74e765511e
commit 49277bfc78
35 changed files with 457 additions and 183 deletions

View File

@@ -0,0 +1,4 @@
declare const HealthProof: unique symbol;
export type HealthReceipt = {
[HealthProof]: never;
};

4
lib/health/ReadyProof.ts Normal file
View File

@@ -0,0 +1,4 @@
export declare const ReadyProof: unique symbol;
export type ReadyReceipt = {
[ReadyProof]: never;
};

View File

@@ -0,0 +1,6 @@
import { HealthStatus } from "../../types";
export type CheckResult = {
status: HealthStatus;
message?: string;
};

View File

@@ -0,0 +1,26 @@
import { Effects } from "../../types";
export function containsAddress(x: string, port: number) {
const readPorts = x
.split("\n")
.filter(Boolean)
.splice(1)
.map((x) => x.split(" ").filter(Boolean)[1]?.split(":")?.[1])
.filter(Boolean)
.map((x) => Number.parseInt(x, 16))
.filter(Number.isFinite);
return readPorts.indexOf(port) >= 0;
}
/**
* This is used to check if a port is listening on the system.
* Used during the health check fn or the check main fn.
*/
export async function checkPortListening(effects: Effects, port: number) {
const hasAddress =
containsAddress(await effects.shell("cat /proc/net/tcp"), port) ||
containsAddress(await effects.shell("cat /proc/net/udp"), port);
if (hasAddress) {
return;
}
throw new Error(`Port ${port} is not listening`);
}

View File

@@ -0,0 +1,31 @@
import { Effects } from "../../types";
import { CheckResult } from "./CheckResult";
import { timeoutPromise } from "./index";
/**
* This is a helper function to check if a web url is reachable.
* @param url
* @param createSuccess
* @returns
*/
export const checkWebUrl = async (
url: string,
effects: Effects,
{
timeout = 1000,
successMessage = `Reached ${url}`,
errorMessage = `Error while fetching URL: ${url}`,
} = {}
): Promise<CheckResult> => {
return Promise.race([effects.fetch(url), timeoutPromise(timeout)])
.then((x) => ({
status: "passing" as const,
message: successMessage,
}))
.catch((e) => {
effects.warn(`Error while fetching URL: ${url}`);
effects.error(JSON.stringify(e));
effects.error(e.toString());
return { status: "failing" as const, message: errorMessage };
});
};

View File

@@ -0,0 +1,11 @@
import { runHealthScript } from "./runHealthScript";
export { checkPortListening } from "./checkPortListening";
export { CheckResult } from "./CheckResult";
export { checkWebUrl } from "./checkWebUrl";
export function timeoutPromise(ms: number, { message = "Timed out" } = {}) {
return new Promise<never>((resolve, reject) =>
setTimeout(() => reject(new Error(message)), ms)
);
}
export { runHealthScript };

View File

@@ -0,0 +1,35 @@
import { Effects } from "../../types";
import { CheckResult } from "./CheckResult";
import { timeoutPromise } from "./index";
/**
* Running a health script, is used when we want to have a simple
* script in bash or something like that. It should return something that is useful
* in {result: string} else it is considered an error
* @param param0
* @returns
*/
export const runHealthScript = async (
effects: Effects,
runCommand: Parameters<Effects["runCommand"]>[0],
{
timeout = 30000,
errorMessage = `Error while running command: ${runCommand}`,
message = (res: string) =>
`Have ran script ${runCommand} and the result: ${res}`,
}
): Promise<CheckResult> => {
const res = await Promise.race([
effects.runCommand(runCommand),
timeoutPromise(timeout),
]).catch((e) => {
effects.warn(errorMessage);
effects.warn(JSON.stringify(e));
effects.warn(e.toString());
throw { status: "failing", message: errorMessage } as CheckResult;
});
return {
status: "passing",
message: message(res),
} as CheckResult;
};

View File

@@ -1,87 +0,0 @@
import { Types } from "..";
import { object, string } from "ts-matches";
export type HealthCheck = (
effects: Types.Effects,
dateMs: number
) => Promise<HealthResult>;
export type HealthResult =
| { success: string }
| { failure: string }
| { disabled: null }
| { starting: null }
| { loading: string };
const hasMessage = object({ message: string }).test;
function safelyStringify(e: unknown) {
if (hasMessage(e)) return e.message;
if (string.test(e)) return e;
try {
return JSON.stringify(e);
} catch (e) {
return "unknown";
}
}
async function timeoutHealth(
effects: Types.Effects,
timeMs: number
): Promise<HealthResult> {
await effects.sleep(timeMs);
return { failure: "Timed out " };
}
/**
* Health runner is usually used during the main function, and will be running in a loop.
* This health check then will be run every intervalS seconds.
* The return needs a create()
* then from there we need a start().
* The stop function is used to stop the health check.
*/
export default function healthRunner(name: string, fn: HealthCheck) {
return {
/**
* All values in seconds. Defaults):
*
* interval: 60s
*
* timeout: 10s
*
* delay: 10s
*/
create(
effects: Types.Effects,
options = { interval: 60, timeout: 10, delay: 10 }
) {
let running: any;
function startFn() {
clearInterval(running);
setTimeout(() => {
running = setInterval(async () => {
const result = await Promise.race([
timeoutHealth(effects, options.timeout * 1000),
fn(effects, 123),
]).catch((e) => {
return { failure: safelyStringify(e) };
});
(effects as any).setHealthStatus({
name,
result,
});
}, options.interval * 1000);
}, options.delay * 1000);
}
const self = {
stop() {
clearInterval(running);
return self;
},
start() {
startFn();
return self;
},
};
return self;
},
};
}

View File

@@ -1,2 +1,7 @@
export * from "./util";
export { default as healthRunner } from "./healthRunner";
export * as checkFns from "./checkFns";
export * as trigger from "./trigger";
export { TriggerInput } from "./trigger/TriggerInput";
export { HealthReceipt } from "./HealthReceipt";
export { readyCheck } from "./readyCheck";
export { ReadyProof } from "./ReadyProof";

55
lib/health/readyCheck.ts Normal file
View File

@@ -0,0 +1,55 @@
import { InterfaceReceipt } from "../mainFn/interfaceReceipt";
import { Daemon, Effects } from "../types";
import { CheckResult } from "./checkFns/CheckResult";
import { ReadyReceipt } from "./ReadyProof";
import { HealthReceipt } from "./HealthReceipt";
import { Trigger } from "./trigger";
import { TriggerInput } from "./trigger/TriggerInput";
import { defaultTrigger } from "./trigger/defaultTrigger";
function readReciptOf<A extends { daemon: Daemon }>(a: A) {
return a as A & ReadyReceipt;
}
export function readyCheck(o: {
effects: Effects;
started(onTerm: () => void): null;
interfaceReceipt: InterfaceReceipt;
healthReceipts: Iterable<HealthReceipt>;
daemonReceipt: Daemon;
name: string;
trigger?: Trigger;
fn(): Promise<CheckResult> | CheckResult;
onFirstSuccess?: () => () => Promise<unknown> | unknown;
}) {
new Promise(async () => {
const trigger = (o.trigger ?? defaultTrigger)();
let currentValue: TriggerInput = {
lastResult: null,
hadSuccess: false,
};
for (
let res = await trigger.next(currentValue);
!res.done;
res = await trigger.next(currentValue)
) {
try {
const { status, message } = await o.fn();
if (!currentValue.hadSuccess) {
await o.started(o?.onFirstSuccess ?? (() => o.daemonReceipt.term()));
}
await o.effects.setHealth({
name: o.name,
status,
message,
});
currentValue.hadSuccess = true;
currentValue.lastResult = "success";
} catch (_) {
currentValue.lastResult = "failure";
}
}
});
return readReciptOf({
daemon: o.daemonReceipt,
});
}

View File

@@ -0,0 +1,4 @@
export type TriggerInput = {
lastResult: "success" | "failure" | null;
hadSuccess: boolean;
};

View File

@@ -0,0 +1,28 @@
import { TriggerInput } from "./TriggerInput";
import { Trigger } from "./index";
export function changeOnFirstSuccess(o: {
beforeFirstSuccess: Trigger;
afterFirstSuccess: Trigger;
}) {
return async function* () {
const beforeFirstSuccess = o.beforeFirstSuccess();
let currentValue: TriggerInput = yield;
beforeFirstSuccess.next(currentValue);
for (
let res = await beforeFirstSuccess.next(currentValue);
currentValue?.lastResult !== "success" && !res.done;
res = await beforeFirstSuccess.next(currentValue)
) {
currentValue = yield;
}
const afterFirstSuccess = o.afterFirstSuccess();
for (
let res = await afterFirstSuccess.next(currentValue);
!res.done;
res = await afterFirstSuccess.next(currentValue)
) {
currentValue = yield;
}
};
}

View File

@@ -0,0 +1,8 @@
export function cooldownTrigger(timeMs: number) {
return async function* () {
while (true) {
await new Promise((resolve) => setTimeout(resolve, timeMs));
yield;
}
};
}

View File

@@ -0,0 +1,7 @@
import { cooldownTrigger } from "./cooldownTrigger";
import { changeOnFirstSuccess } from "./changeOnFirstSuccess";
export const defaultTrigger = changeOnFirstSuccess({
beforeFirstSuccess: cooldownTrigger(0),
afterFirstSuccess: cooldownTrigger(30000),
});

View File

@@ -0,0 +1,5 @@
import { TriggerInput } from "./TriggerInput";
export { changeOnFirstSuccess } from "./changeOnFirstSuccess";
export { cooldownTrigger } from "./cooldownTrigger";
export type Trigger = () => AsyncIterator<unknown, unknown, TriggerInput>;

View File

@@ -1,77 +0,0 @@
import { Effects } from "../types";
import { isKnownError } from "../util";
import { HealthResult } from "./healthRunner";
/**
* This is a helper function to check if a web url is reachable.
* @param url
* @param createSuccess
* @returns
*/
export const checkWebUrl: (
url: string,
createSuccess?: null | ((response?: string | null) => string)
) => (effects: Effects, duration: number) => Promise<HealthResult> = (
url,
createSuccess = null
) => {
return async (effects, duration) => {
const errorValue = guardDurationAboveMinimum({
duration,
minimumTime: 5000,
});
if (!!errorValue) {
return errorValue;
}
return await effects
.fetch(url)
.then((x) => ({
success: createSuccess?.(x.body) ?? `Successfully fetched URL: ${url}`,
}))
.catch((e) => {
effects.warn(`Error while fetching URL: ${url}`);
effects.error(JSON.stringify(e));
effects.error(e.toString());
return { failure: `Error while fetching URL: ${url}` };
});
};
};
/**
* Running a health script, is used when we want to have a simple
* script in bash or something like that. It should return something that is useful
* in {result: string} else it is considered an error
* @param param0
* @returns
*/
export const runHealthScript =
({
command,
args,
message,
}: {
command: string;
args: string[];
message: ((result: unknown) => string) | null;
}) =>
async (effects: Effects, _duration: number): Promise<HealthResult> => {
const res = await effects.runCommand({ command, args });
return {
success:
message?.(res) ?? `Have ran script ${command} and the result: ${res}`,
};
};
// Ensure the starting duration is pass a minimum
export const guardDurationAboveMinimum = (input: {
duration: number;
minimumTime: number;
}): null | HealthResult =>
input.duration <= input.minimumTime ? { starting: null } : null;
export const catchError = (effects: Effects) => (e: unknown) => {
if (isKnownError(e)) return e;
effects.error(`Health check failed: ${e}`);
return { failure: "Error while running health check" };
};

View File

@@ -1,12 +1,13 @@
export * as matches from "ts-matches";
export * as TOML from "@iarna/toml";
export * as YAML from "yaml";
export * as Types from "./types";
export * as T from "./types";
export * as healthUtil from "./health/util";
export * as util from "./util";
export * as configBuilder from "./config/builder";
export * as backup from "./backup";
export * as configTypes from "./config/configTypes";
export * as config from "./config";
export * as configBuilder from "./config/builder";
export * as configTypes from "./config/configTypes";
export * as health from "./health";
export * as healthUtil from "./health/checkFns";
export * as mainFn from "./mainFn";
export * as matches from "ts-matches";
export * as T from "./types";
export * as TOML from "@iarna/toml";
export * as Types from "./types";
export * as util from "./util";
export * as YAML from "yaml";

View File

@@ -0,0 +1,4 @@
declare const AddressProof: unique symbol;
export type AddressReceipt = {
[AddressProof]: never;
};

View File

@@ -0,0 +1,11 @@
import { Origin } from "./Origin";
export class LocalBinding {
constructor(readonly localAddress: string, readonly ipAddress: string) {}
createOrigins(protocol: string) {
return {
local: new Origin(protocol, this.localAddress),
ip: new Origin(protocol, this.ipAddress),
};
}
}

13
lib/mainFn/LocalPort.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Effects } from "../types";
import { LocalBinding } from "./LocalBinding";
export class LocalPort {
constructor(readonly id: string, readonly effects: Effects) {}
async bindLan(internalPort: number) {
const [localAddress, ipAddress] = await this.effects.bindLan({
internalPort,
name: this.id,
});
return new LocalBinding(localAddress, ipAddress);
}
}

View File

@@ -0,0 +1,17 @@
import { Effects } from "../types";
import { LocalPort } from "./LocalPort";
import { TorHostname } from "./TorHostname";
export class NetworkBuilder {
static of(effects: Effects) {
return new NetworkBuilder(effects);
}
private constructor(private effects: Effects) {}
getTorHostName(id: string) {
return new TorHostname(id, this.effects);
}
getPort(id: string) {
return new LocalPort(id, this.effects);
}
}

View File

@@ -0,0 +1,38 @@
import { Effects } from "../types";
import { AddressReceipt } from "./AddressReceipt";
import { Origin } from "./Origin";
export class NetworkInterfaceBuilder {
constructor(
readonly options: {
effects: Effects;
name: string;
id: string;
description: string;
ui: boolean;
basic?: null | { password: string; username: string };
path?: string;
search?: Record<string, string>;
}
) {}
async exportAddresses(addresses: Iterable<Origin>) {
const { name, description, id, ui, path, search } = this.options;
// prettier-ignore
const urlAuth = !!(this.options?.basic) ? `${this.options.basic.username}:${this.options.basic.password}@` :
'';
for (const origin of addresses) {
const address = `${origin.protocol}://${urlAuth}${origin.address}`;
await this.options.effects.exportAddress({
name,
description,
address,
id,
ui,
path,
search,
});
}
return {} as AddressReceipt;
}
}

3
lib/mainFn/Origin.ts Normal file
View File

@@ -0,0 +1,3 @@
export class Origin {
constructor(readonly protocol: string, readonly address: string) {}
}

View File

@@ -0,0 +1,7 @@
import { Daemon } from "../types";
import { ReadyProof } from "../health/ReadyProof";
export type RunningMainRet = {
[ReadyProof]: never;
daemon: Daemon;
};

8
lib/mainFn/TorBinding.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Origin } from "./Origin";
export class TorBinding {
constructor(readonly address: string) {}
createOrigin(protocol: string) {
return new Origin(protocol, this.address);
}
}

14
lib/mainFn/TorHostname.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Effects } from "../types";
import { TorBinding } from "./TorBinding";
export class TorHostname {
constructor(readonly id: string, readonly effects: Effects) {}
async bindTor(internalPort: number, externalPort: number) {
const address = await this.effects.bindTor({
internalPort,
name: this.id,
externalPort,
});
return new TorBinding(address);
}
}

View File

@@ -0,0 +1,8 @@
import { AddressReceipt } from "./AddressReceipt";
import { InterfaceReceipt } from "./interfaceReceipt";
export const exportInterfaces = (
_firstProof: AddressReceipt,
..._rest: AddressReceipt[]
) => ({} as InterfaceReceipt);
export default exportInterfaces;

22
lib/mainFn/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { RunningMainRet } from "./RunningMainRet";
import { Effects, ExpectedExports } from "../types";
export * as network from "./exportInterfaces";
export { LocalBinding } from "./LocalBinding";
export { LocalPort } from "./LocalPort";
export { NetworkBuilder } from "./NetworkBuilder";
export { NetworkInterfaceBuilder } from "./NetworkInterfaceBuilder";
export { Origin } from "./Origin";
export { TorBinding } from "./TorBinding";
export { TorHostname } from "./TorHostname";
export const runningMain: (
fn: (o: {
effects: Effects;
started(onTerm: () => void): null;
}) => Promise<RunningMainRet>
) => ExpectedExports.main = (fn) => {
return async (options) => {
const { daemon } = await fn(options);
daemon.wait();
};
};

View File

@@ -0,0 +1,4 @@
declare const InterfaceProof: unique symbol;
export type InterfaceReceipt = {
[InterfaceProof]: never;
};

View File

@@ -0,0 +1,17 @@
import { containsAddress } from "../health/checkFns/checkPortListening";
describe("Health ready check", () => {
it("Should be able to parse an example information", () => {
let input = `
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634478 1 0000000000000000 100 0 0 10 0
1: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634477 1 0000000000000000 100 0 0 10 0
2: 0B00007F:9671 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21635458 1 0000000000000000 100 0 0 10 0
3: 00000000:0D73 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634479 1 0000000000000000 100 0 0 10 0
`;
expect(containsAddress(input, 80)).toBe(true);
expect(containsAddress(input, 1234)).toBe(false);
});
});

View File

@@ -85,6 +85,19 @@ export type ConfigRes = {
/** Shape that is describing the form in the ui */
spec: InputSpec;
};
declare const DaemonProof: unique symbol;
export type DaemonReceipt = {
[DaemonProof]: never;
};
export type Daemon = {
wait(): Promise<string>;
term(): Promise<void>;
[DaemonProof]: never;
};
export type HealthStatus = "passing" | "warning" | "failing" | "disabled";
/** Used to reach out from the pure js runtime */
export type Effects = {
/** Usable when not sandboxed */
@@ -125,6 +138,10 @@ export type Effects = {
args?: string[];
timeoutMillis?: number;
}): Promise<string>;
runShellDaemon(command: string): {
wait(): Promise<string>;
term(): Promise<void>;
};
runDaemon(input: { command: string; args?: string[] }): {
wait(): Promise<string>;
term(): Promise<void>;
@@ -154,11 +171,7 @@ export type Effects = {
/** Check that a file exists or not */
exists(input: { volumeId: string; path: string }): Promise<boolean>;
/** Declaring that we are opening a interface on some protocal for local network */
bindLocal(options: {
internalPort: number;
name: string;
externalPort: number;
}): Promise<string>;
bindLan(options: { internalPort: number; name: string }): Promise<string[]>;
/** Declaring that we are opening a interface on some protocal for tor network */
bindTor(options: {
internalPort: number;
@@ -244,6 +257,20 @@ export type Effects = {
* ui interface
*/
ui?: boolean;
/**
* The id is that a path will create a link in the ui that can go to specific pages, like
* admin, or settings, or something like that.
* Default = ''
*/
path?: string;
/**
* This is the query params in the url, and is a map of key value pairs
* Default = {}
* if empty then will not be added to the url
*/
search?: Record<string, string>;
}): Promise<string>;
/**
@@ -291,6 +318,12 @@ export type Effects = {
* @returns PEM encoded ssl key (ecdsa)
*/
getSslKey: (packageId: string, algorithm?: "ecdsa" | "ed25519") => string;
setHealth(o: {
name: string;
status: HealthStatus;
message?: string;
}): Promise<void>;
};
/* rsync options: https://linux.die.net/man/1/rsync

View File

@@ -3,6 +3,7 @@ import * as T from "../types";
export { guardAll, typeFromProps } from "./propertiesMatcher";
export { default as nullIfEmpty } from "./nullIfEmpty";
export { FileHelper } from "./fileHelper";
export { sh } from "./shell";
/** Used to check if the file exists before hand */
export const exists = (

8
lib/util/shell.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Effects } from "../types";
export function sh(shellCommand: string) {
return {
command: "sh",
args: ["-c", shellCommand],
} as Partial<Parameters<Effects["runCommand"]>[0]>;
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "start-sdk",
"version": "0.4.0-lib0.charlie2",
"version": "0.4.0-lib0.charlie3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "start-sdk",
"version": "0.4.0-lib0.charlie2",
"version": "0.4.0-lib0.charlie3",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",

View File

@@ -1,6 +1,6 @@
{
"name": "start-sdk",
"version": "0.4.0-lib0.charlie2",
"version": "0.4.0-lib0.charlie3",
"description": "For making the patterns that are wanted in making services for the startOS.",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",