diff options
author | Elizabeth Alexander Hunt <me@liz.coffee> | 2025-05-12 09:40:12 -0700 |
---|---|---|
committer | Elizabeth <me@liz.coffee> | 2025-05-26 14:15:42 -0700 |
commit | d51c9d74857aca3c2f172609297266968bc7f809 (patch) | |
tree | 64327f9cc4219729aa11af32d7d4c70cddfc2292 | |
parent | 30729a0cf707d9022bae0a7baaba77379dc31fd5 (diff) | |
download | ci-d51c9d74857aca3c2f172609297266968bc7f809.tar.gz ci-d51c9d74857aca3c2f172609297266968bc7f809.zip |
The big refactor TM
68 files changed, 2250 insertions, 484 deletions
@@ -1,14 +1,16 @@ #!/usr/bin/env -S deno run --allow-env import { + AnsiblePlaybookJob, BuildDockerImageJob, DefaultGitHookPipelineBuilder, -} from "@liz-ci/model"; -import { AnsiblePlaybookJob, FetchCodeJob } from "../model/job.ts"; + FetchCodeJob, +} from "@emprespresso/ci-model"; const REGISTRY = "oci.liz.coffee"; -const NAMESPACE = "img"; -const IMG = "liz-ci"; +const NAMESPACE = "@emprespresso"; +const IMG = "ci"; +const REMOTE = "ssh://src.liz.coffee:2222"; const getPipeline = () => { const gitHookPipeline = new DefaultGitHookPipelineBuilder(); @@ -21,18 +23,18 @@ const getPipeline = () => { imageTag: branch, }; - const ciPackageBuild: BuildDockerImageJob = { + const baseCiPackageBuild: BuildDockerImageJob = { type: "build_docker_image", arguments: { ...commonBuildArgs, context: gitHookPipeline.getSourceDestination(), - repository: IMG, - buildTarget: IMG, + repository: IMG + "-base", + buildTarget: IMG + "-base", dockerfile: "Dockerfile", }, }; gitHookPipeline.addStage({ - parallelJobs: [ciPackageBuild], + parallelJobs: [baseCiPackageBuild], }); const subPackages = [ @@ -59,7 +61,7 @@ const getPipeline = () => { const fetchAnsibleCode: FetchCodeJob = { type: "fetch_code", arguments: { - remoteUrl: "ssh://src.liz.coffee:2222/infra", + remoteUrl: `${REMOTE}/infra`, checkout: "main", path: "infra", }, @@ -79,5 +81,7 @@ const getPipeline = () => { }; if (import.meta.main) { - console.log(getPipeline().serialize()); + const encoder = new TextEncoder(); + const data = encoder.encode(getPipeline().serialize()); + await Deno.stdout.write(data); } @@ -23,7 +23,7 @@ RUN cmake -B /opt/laminar/build -S /opt/laminar/src -G Ninja \ cmake --build /opt/laminar/build && \ cmake --install /opt/laminar/build --strip -FROM denoland/deno:debian AS liz-ci +FROM denoland/deno:debian AS ci-base RUN apt-get update -yqq && apt-get install -yqq libcapnp-0.9.2 \ libsqlite3-0 zlib1g curl bash @@ -1,4 +1,16 @@ -# LizCI +# lizci (⑅˘꒳˘) -its my very own ci, built on top of [Laminar](https://laminar.ohwg.net/docs.html), -cuz i thought it was coooool. +hi! this is lizci, built on top of [laminar](https://laminar.ohwg.net/docs.html), because while jenkins looks hot and classy, +i prefer a simpler approach that i can extend on myself. + +## how to use? (。•̀ᴗ-)✧ + +add a script that generates the + +1. add a `.ci/ci.json` file to your repo +2. point to your pipeline generator script +3. push your code +4. watch the magic happen! + + +made with love and deno~ @@ -1,3 +1,3 @@ { - "workspace": ["./model", "./worker", "./hooks", "./utils"] + "workspace": ["./model", "./u", "./worker", "./hooks"] } diff --git a/hooks/Dockerfile b/hooks/Dockerfile index c1ebf7f..d7763a1 100644 --- a/hooks/Dockerfile +++ b/hooks/Dockerfile @@ -1,3 +1,5 @@ -FROM oci.liz.coffee/img/liz-ci:release AS hooks +FROM oci.liz.coffee/@emprespresso/ci-base:release AS hooks + +HEALTHCHECK [ "curl --fail http://localhost:9000/health" ] CMD [ "/app/hooks/mod.ts" ] diff --git a/hooks/deno.json b/hooks/deno.json index c4e8fca..cdaf63f 100644 --- a/hooks/deno.json +++ b/hooks/deno.json @@ -1,4 +1,4 @@ { - "name": "@liz-ci/hooks", + "name": "@emprespresso/ci-hooks", "exports": "./mod.ts" } diff --git a/hooks/main.ts b/hooks/main.ts new file mode 100644 index 0000000..21a3f3f --- /dev/null +++ b/hooks/main.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env -S deno run --allow-env --allow-net --allow-run + +import { CiHookServer } from "./server/mod.ts"; + +const server = new CiHookServer(); + +const SERVER_CONFIG = { + host: "0.0.0.0", + port: 9000, +}; + +Deno.serve(SERVER_CONFIG, (request: Request) => server.serve(request)); diff --git a/hooks/mod.ts b/hooks/mod.ts index f858ad0..cc15112 100755..100644 --- a/hooks/mod.ts +++ b/hooks/mod.ts @@ -1,78 +1,2 @@ -#!/usr/bin/env -S deno run --allow-env --allow-net --allow-run - -import { - getRequiredEnv, - getStdout, - loggerWithPrefix, - validateIdentifier, -} from "@liz-ci/utils"; - -const getRequestLogger = (req: Request) => { - const url = new URL(req.url); - const traceId = crypto.randomUUID(); - const getPrefix = () => - `[${ - new Date().toISOString() - }] RequestTrace=[${traceId}] @ [${url.pathname}] -X [${req.method}] |`; - return loggerWithPrefix(getPrefix); -}; - -const addr = { port: 9000, hostname: "0.0.0.0" }; -Deno.serve(addr, async (req) => { - const logger = getRequestLogger(req); - logger.log("start"); - - try { - const { pathname } = new URL(req.url); - if (pathname === "/health") { - try { - getRequiredEnv("LAMINAR_HOST"); - await getStdout(["laminarc", "show-jobs"]); - return new Response("think im healthy!! lets get to work.\n", { - status: 200, - }); - } catch (e) { - logger.error(e); - return new Response("i need to eat more vegetables -.-\n", { - status: 500, - }); - } - } - - if (req.method !== "POST") { - return new Response("invalid method", { - status: 405, - }); - } - - if (pathname === "/checkout_ci") { - const { remote, rev, refname } = await req.json(); - if (![remote, rev, refname].every(validateIdentifier)) { - logger.log("invalid reqwest"); - return new Response("invalid reqwest >:D\n", { - status: 400, - }); - } - - const laminar = await getStdout([ - "laminarc", - "queue", - "checkout_ci", - `remote="${remote}"`, - `rev="${rev}"`, - `refname="${refname}"`, - ]); - logger.log(`successful queue :D`, laminar); - return new Response(laminar + "\n", { - status: 200, - }); - } - - return new Response("idk what that is bro :((\n", { status: 404 }); - } catch (e) { - logger.error("uncaught exception", e); - return new Response("womp womp D:\n", { status: 500 }); - } finally { - logger.log("finish"); - } -}); +export * from "./server/mod.ts"; +export * from "./main.ts"; diff --git a/hooks/server/ci.ts b/hooks/server/ci.ts new file mode 100644 index 0000000..8f06124 --- /dev/null +++ b/hooks/server/ci.ts @@ -0,0 +1,55 @@ +import { + FourOhFourActivityImpl, + getRequiredEnv, + HealthCheckActivityImpl, + type HealthChecker, + type IFourOhFourActivity, + type IHealthCheckActivity, + type ITraceable, + PenguenoRequest, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; +import type { Job } from "@emprespresso/ci-model"; +import { + healthCheck as _healthCheck, + type IJobHookActivity, + type IJobQueuer, + JobHookActivityImpl, + LaminarJobQueuer, +} from "@emprespresso/ci-hooks"; + +export class CiHookServer { + constructor( + healthCheck: HealthChecker = _healthCheck, + jobQueuer: IJobQueuer<ITraceable<Job, ServerTrace>> = new LaminarJobQueuer( + getRequiredEnv("LAMINAR_URL").fold((err, val) => + err ? "https://ci.liz.coffee" : val + ), + ), + private readonly healthCheckActivity: IHealthCheckActivity = + new HealthCheckActivityImpl(healthCheck), + private readonly jobHookActivity: IJobHookActivity = + new JobHookActivityImpl(jobQueuer), + private readonly fourOhFourActivity: IFourOhFourActivity = + new FourOhFourActivityImpl(), + ) {} + + private route(req: ITraceable<PenguenoRequest, ServerTrace>) { + const url = new URL(req.get().url); + if (url.pathname === "/health") { + return this.healthCheckActivity.checkHealth(req); + } + if (url.pathname === "/job") { + return this.jobHookActivity.processHook(req); + } + return this.fourOhFourActivity.fourOhFour(req); + } + + public serve(req: Request): Promise<Response> { + return PenguenoRequest.from(req) + .bimap(TraceUtil.withClassTrace(this)) + .map(this.route) + .get(); + } +} diff --git a/hooks/server/health.ts b/hooks/server/health.ts new file mode 100644 index 0000000..2f67aa4 --- /dev/null +++ b/hooks/server/health.ts @@ -0,0 +1,25 @@ +import { + getRequiredEnv, + getStdout, + type HealthChecker, + type HealthCheckInput, + HealthCheckOutput, + type IEither, + type ITraceable, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +export const healthCheck: HealthChecker = ( + input: ITraceable<HealthCheckInput, ServerTrace>, +): Promise<IEither<Error, HealthCheckOutput>> => + input.bimap(TraceUtil.withFunctionTrace(healthCheck)) + .move(getRequiredEnv("LAMINAR_HOST")) + // ensure LAMINAR_HOST is propagated to getStdout for other procedures + .map((e) => e.get().moveRight(["laminarc", "show-jobs"])) + .map((i) => + i.get().mapRight(i.move.apply) + .flatMapAsync(getStdout.apply) + .then((gotJobs) => gotJobs.moveRight(HealthCheckOutput.YAASSSLAYQUEEN)) + ) + .get(); diff --git a/hooks/server/job/activity.ts b/hooks/server/job/activity.ts new file mode 100644 index 0000000..14ea459 --- /dev/null +++ b/hooks/server/job/activity.ts @@ -0,0 +1,100 @@ +import { + Either, + ErrorSource, + type IActivity, + type IEither, + type ITraceable, + jsonModel, + JsonResponse, + LogLevel, + Metric, + PenguenoError, + type PenguenoRequest, + type ServerTrace, + TraceUtil, + validateExecutionEntries, +} from "@emprespresso/pengueno"; +import { isJob, type Job } from "@emprespresso/ci-model"; +import type { IJobQueuer } from "@emprespresso/ci-hooks"; + +const wellFormedJobMetric = Metric.fromName("Job.WellFormed"); + +const jobJsonTransformer = ( + j: ITraceable<unknown, ServerTrace>, +): IEither<PenguenoError, Job> => + j.bimap(TraceUtil.withMetricTrace(wellFormedJobMetric)) + .map((tJson) => { + if (!isJob(tJson) || !validateExecutionEntries(tJson)) { + const err = "seems like a pwetty mawfomed job \\(-.-)/"; + tJson.trace.addTrace(LogLevel.WARN).trace(err); + return Either.left<PenguenoError, Job>(new PenguenoError(err, 400)); + } + return Either.right<PenguenoError, Job>(tJson); + }) + .peek((tJob) => + tJob.trace.trace( + tJob.get().fold((err) => + err ? wellFormedJobMetric.failure : wellFormedJobMetric.success + ), + ) + ) + .get(); + +export interface IJobHookActivity { + processHook: IActivity; +} + +const jobHookRequestMetric = Metric.fromName("JobHook.process"); +export class JobHookActivityImpl implements IJobHookActivity { + constructor( + private readonly queuer: IJobQueuer<ITraceable<Job, ServerTrace>>, + ) {} + + private trace(r: ITraceable<PenguenoRequest, ServerTrace>) { + return r.bimap(TraceUtil.withClassTrace(this)) + .bimap( + TraceUtil.withMetricTrace(jobHookRequestMetric), + ); + } + + public processHook(r: ITraceable<PenguenoRequest, ServerTrace>) { + return this.trace(r).map(jsonModel(jobJsonTransformer)) + .map(async (tEitherJobJson) => { + const eitherJob = await tEitherJobJson.get(); + return eitherJob.flatMapAsync(async (job) => { + const eitherQueued = await tEitherJobJson.move(job) + .map(this.queuer.queue) + .get(); + return eitherQueued.mapLeft((e) => new PenguenoError(e.message, 500)); + }); + }) + .peek( + TraceUtil.promiseify((tJob) => + tJob.get().fold( + (err: PenguenoError | undefined, _val: string | undefined) => { + if (!err) { + tJob.trace.trace(jobHookRequestMetric.success); + tJob.trace.trace(`all queued up and weady to go :D !! ${_val}`); + return; + } + tJob.trace.trace( + err.source === ErrorSource.SYSTEM + ? jobHookRequestMetric.failure + : jobHookRequestMetric.warn, + ); + tJob.trace.addTrace(err.source).trace(`${err}`); + }, + ) + ), + ) + .map( + TraceUtil.promiseify((tEitherQueuedJob) => + new JsonResponse(r, tEitherQueuedJob.get(), { + status: tEitherQueuedJob.get() + .fold(({ status }, _val) => _val ? 200 : status), + }) + ), + ) + .get(); + } +} diff --git a/hooks/server/job/mod.ts b/hooks/server/job/mod.ts new file mode 100644 index 0000000..6b4ae85 --- /dev/null +++ b/hooks/server/job/mod.ts @@ -0,0 +1,2 @@ +export * from "./activity.ts"; +export * from "./queuer.ts"; diff --git a/hooks/server/job/queuer.ts b/hooks/server/job/queuer.ts new file mode 100644 index 0000000..6094183 --- /dev/null +++ b/hooks/server/job/queuer.ts @@ -0,0 +1,78 @@ +import { + getStdout, + type IEither, + type ITraceable, + LogLevel, + type Mapper, + memoize, + Metric, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; +import type { Job } from "@emprespresso/ci-model"; + +type QueuePosition = string; +export class QueueError extends Error {} +export interface IJobQueuer<TJob> { + queue: Mapper<TJob, Promise<IEither<QueueError, QueuePosition>>>; +} + +export class LaminarJobQueuer + implements IJobQueuer<ITraceable<Job, ServerTrace>> { + constructor( + private readonly queuePositionPrefix: string, + ) {} + + private static GetJobTypeTrace = (jobType: string) => + `LaminarJobQueue.Queue.${jobType}`; + private static JobTypeMetrics = memoize((jobType: string) => + Metric.fromName(LaminarJobQueuer.GetJobTypeTrace(jobType)) + ); + + public queue(j: ITraceable<Job, ServerTrace>) { + const { type: jobType } = j.get(); + const trace = LaminarJobQueuer.GetJobTypeTrace(jobType); + const metric = LaminarJobQueuer.JobTypeMetrics(trace); + + return j + .bimap(TraceUtil.withTrace(trace)) + .bimap(TraceUtil.withMetricTrace(metric)) + .map((j) => { + const { type: jobType, arguments: args } = j.get(); + const laminarCommand = [ + "laminarc", + "queue", + jobType, + ...Object.entries(args).map(([key, val]) => `"${key}"="${val}"`), + ]; + return laminarCommand; + }) + .peek((c) => + c.trace.trace( + `im so excited to see how this queue job will end!! (>ᴗ<): ${c.get().toString()}`, + ) + ) + .map(getStdout) + .peek( + TraceUtil.promiseify((q) => + q.trace.trace( + q.get().fold((err, _val) => err ? metric.failure : metric.success), + ) + ), + ) + .map(TraceUtil.promiseify((q) => + q.get().mapRight((stdout) => { + q.trace.addTrace(LogLevel.DEBUG).trace(`stdout ${stdout}`); + const [jobName, jobId] = stdout.split(":"); + const jobUrl = `${this.queuePositionPrefix}/jobs/${jobName}/${jobId}`; + + q.trace.trace(`all queued up and weady to go~ (˘ω˘) => ${jobUrl}`); + return jobUrl; + }).mapLeft((err) => { + q.trace.addTrace(LogLevel.ERROR).trace(err.toString()); + return err; + }) + )) + .get(); + } +} diff --git a/hooks/server/mod.ts b/hooks/server/mod.ts new file mode 100644 index 0000000..0a520f9 --- /dev/null +++ b/hooks/server/mod.ts @@ -0,0 +1,3 @@ +export * from "./ci.ts"; +export * from "./health.ts"; +export * from "./job/mod.ts"; diff --git a/model/deno.json b/model/deno.json index d22242e..afd9f22 100644 --- a/model/deno.json +++ b/model/deno.json @@ -1,5 +1,5 @@ { - "name": "@liz-ci/model", + "name": "@emprespresso/ci-model", "version": "0.1.0", "exports": "./mod.ts" } diff --git a/model/job.ts b/model/job.ts index 53d548f..a3b52dd 100644 --- a/model/job.ts +++ b/model/job.ts @@ -1,8 +1,13 @@ +import { isObject } from "@emprespresso/pengueno"; + export type JobArgT = Record<string, string>; export interface Job { readonly type: string; readonly arguments: JobArgT; } +export const isJob = (j: unknown): j is Job => + !!(isObject(j) && "arguments" in j && isObject(j.arguments) && + "type" in j && typeof j.type === "string" && j); export interface FetchCodeJobProps extends JobArgT { readonly remoteUrl: string; @@ -40,3 +45,17 @@ export interface AnsiblePlaybookJob extends Job { readonly type: "ansible_playbook"; readonly arguments: AnsiblePlaybookJobProps; } + +export interface CheckoutCiJobProps extends JobArgT { + readonly remote: string; + readonly refname: string; + readonly rev: string; + + readonly run: string; + readonly returnPath: string; +} + +export interface CheckoutCiJob extends Job { + readonly type: "checkout_ci"; + readonly arguments: CheckoutCiJobProps; +} diff --git a/model/pipeline.ts b/model/pipeline.ts index 06361a4..b0db1b7 100644 --- a/model/pipeline.ts +++ b/model/pipeline.ts @@ -1,13 +1,20 @@ -import type { FetchCodeJob, Job } from "./mod.ts"; - -export interface Pipeline { - getStages(): ReadonlyArray<PipelineStage>; - serialize(): string; -} +import { Either, type IEither, isObject } from "@emprespresso/pengueno"; +import { type FetchCodeJob, isJob, type Job } from "@emprespresso/ci-model"; export interface PipelineStage { readonly parallelJobs: Array<Job>; } +export const isPipelineStage = (t: unknown): t is PipelineStage => + isObject(t) && "parallelJobs" in t && Array.isArray(t.parallelJobs) && + t.parallelJobs.every((j) => isJob(j)); + +export interface Pipeline { + readonly serialJobs: Array<PipelineStage>; + serialize(): string; +} +export const isPipeline = (t: unknown): t is Pipeline => + isObject(t) && "serialJobs" in t && Array.isArray(t.serialJobs) && + t.serialJobs.every((p) => isPipelineStage(p)); export interface PipelineBuilder { addStage(stage: PipelineStage): PipelineBuilder; @@ -15,18 +22,22 @@ export interface PipelineBuilder { } export class PipelineImpl implements Pipeline { - constructor(private readonly serialJobs: ReadonlyArray<PipelineStage>) {} - - public getStages() { - return this.serialJobs; - } + constructor(public readonly serialJobs: Array<PipelineStage>) {} public serialize() { return JSON.stringify(this.serialJobs); } - public static from(s: string): Pipeline { - return new PipelineImpl(JSON.parse(s)); + public static from(s: string): IEither<Error, Pipeline> { + return Either.fromFailable<Error, unknown>(() => JSON.parse(s)).flatMap< + Pipeline + >(( + eitherPipelineJson, + ) => + isPipeline(eitherPipelineJson) + ? Either.right(eitherPipelineJson) + : Either.left(new Error("oh noes D: its a bad pipewine :((")) + ).mapRight((pipeline) => new PipelineImpl(pipeline.serialJobs)); } } diff --git a/u/deno.json b/u/deno.json new file mode 100644 index 0000000..26b08bf --- /dev/null +++ b/u/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@emprespresso/pengueno", + "version": "0.1.0", + "exports": "./mod.ts", + "workspace": ["./*"] +} diff --git a/u/fn/callable.ts b/u/fn/callable.ts new file mode 100644 index 0000000..fc6ea81 --- /dev/null +++ b/u/fn/callable.ts @@ -0,0 +1,22 @@ +// deno-lint-ignore no-explicit-any +export interface Callable<T = any, ArgT = any> { + (...args: Array<ArgT>): T; +} + +export interface Supplier<T> extends Callable<T, undefined> { + (): T; +} + +export interface Mapper<T, U> extends Callable<U, T> { + (t: T): U; +} + +export interface BiMapper<T, U, R> extends Callable { + (t: T, u: U): R; +} + +export interface SideEffect<T> extends Mapper<T, void> { +} + +export interface BiSideEffect<T, U> extends BiMapper<T, U, void> { +} diff --git a/u/fn/either.ts b/u/fn/either.ts new file mode 100644 index 0000000..8b233bf --- /dev/null +++ b/u/fn/either.ts @@ -0,0 +1,97 @@ +import type { BiMapper, Mapper, Supplier } from "@emprespresso/pengueno"; +import { isObject } from "../leftpadesque/mod.ts"; + +type IEitherTag = "IEither"; +const iEitherTag: IEitherTag = "IEither"; + +export interface IEither<E, T> { + readonly _tag: IEitherTag; + mapBoth: <Ee, Tt>( + errBranch: Mapper<E, Ee>, + okBranch: Mapper<T, Tt>, + ) => IEither<Ee, Tt>; + fold: <Tt>(folder: BiMapper<E | undefined, T | undefined, Tt>) => Tt; + moveRight: <Tt>(t: Tt) => IEither<E, Tt>; + mapRight: <Tt>(mapper: Mapper<T, Tt>) => IEither<E, Tt>; + mapLeft: <Ee>(mapper: Mapper<E, Ee>) => IEither<Ee, T>; + flatMap: <Tt>(mapper: Mapper<T, IEither<E, Tt>>) => IEither<E, Tt>; + flatMapAsync: <Tt>( + mapper: Mapper<T, Promise<IEither<E, Tt>>>, + ) => Promise<IEither<E, Tt>>; +} + +export class Either<E, T> implements IEither<E, T> { + private constructor( + private readonly err?: E, + private readonly ok?: T, + public readonly _tag: IEitherTag = iEitherTag, + ) {} + + public moveRight<Tt>(t: Tt) { + return this.mapRight(() => t); + } + + public fold<R>(folder: BiMapper<E | undefined, T | undefined, R>): R { + return folder(this.err ?? undefined, this.ok ?? undefined); + } + + public mapBoth<Ee, Tt>( + errBranch: Mapper<E, Ee>, + okBranch: Mapper<T, Tt>, + ): Either<Ee, Tt> { + if (this.err !== undefined) return Either.left(errBranch(this.err)); + return Either.right(okBranch(this.ok!)); + } + + public flatMap<Tt>(mapper: Mapper<T, Either<E, Tt>>): Either<E, Tt> { + if (this.ok !== undefined) return mapper(this.ok); + return Either.left<E, Tt>(this.err!); + } + + public mapRight<Tt>(mapper: Mapper<T, Tt>): IEither<E, Tt> { + if (this.ok !== undefined) return Either.right<E, Tt>(mapper(this.ok)); + return Either.left<E, Tt>(this.err!); + } + + public mapLeft<Ee>(mapper: Mapper<E, Ee>): IEither<Ee, T> { + if (this.err !== undefined) return Either.left<Ee, T>(mapper(this.err)); + return Either.right<Ee, T>(this.ok!); + } + + public async flatMapAsync<Tt>( + mapper: Mapper<T, Promise<IEither<E, Tt>>>, + ): Promise<IEither<E, Tt>> { + if (this.err !== undefined) { + return Promise.resolve(Either.left<E, Tt>(this.err)); + } + return await mapper(this.ok!).catch((err) => Either.left<E, Tt>(err as E)); + } + + static left<E, T>(e: E) { + return new Either<E, T>(e); + } + + static right<E, T>(t: T) { + return new Either<E, T>(undefined, t); + } + + static fromFailable<E, T>(s: Supplier<T>) { + try { + return Either.right<E, T>(s()); + } catch (e) { + return Either.left<E, T>(e as E); + } + } + + static async fromFailableAsync<E, T>(s: Promise<T>) { + try { + return Either.right<E, T>(await s); + } catch (e) { + return Either.left<E, T>(e as E); + } + } +} + +export const isEither = <E, T>(o: unknown): o is IEither<E, T> => { + return isObject(o) && "_tag" in o && o._tag === "IEither"; +}; diff --git a/u/fn/mod.ts b/u/fn/mod.ts new file mode 100644 index 0000000..f0fbe88 --- /dev/null +++ b/u/fn/mod.ts @@ -0,0 +1,2 @@ +export * from "./callable.ts"; +export * from "./either.ts"; diff --git a/u/leftpadesque/debug.ts b/u/leftpadesque/debug.ts new file mode 100644 index 0000000..a9da1f3 --- /dev/null +++ b/u/leftpadesque/debug.ts @@ -0,0 +1,11 @@ +const _hasEnv = !Deno.permissions.querySync({ name: "env" }); + +const _env: "development" | "production" = + _hasEnv && (Deno.env.get("ENVIRONMENT") ?? "").toLowerCase().includes("prod") + ? "production" + : "development"; +export const isProd = () => _env === "production"; + +const _debug = !isProd() || (_hasEnv && + ["y", "t"].some((Deno.env.get("DEBUG") ?? "").toLowerCase().startsWith)); +export const isDebug = () => _debug; diff --git a/u/leftpadesque/memoize.ts b/u/leftpadesque/memoize.ts new file mode 100644 index 0000000..95e6019 --- /dev/null +++ b/u/leftpadesque/memoize.ts @@ -0,0 +1,14 @@ +import type { Callable } from "@emprespresso/pengueno"; + +export const memoize = <R, F extends Callable<R>>(fn: F): F => { + const cache = new Map<string, R>(); + return ((...args: unknown[]): R => { + const key = JSON.stringify(args); + if (cache.has(key)) { + return cache.get(key)!; + } + const res = fn.apply(args); + cache.set(key, res); + return res; + }) as F; +}; diff --git a/u/leftpadesque/mod.ts b/u/leftpadesque/mod.ts new file mode 100644 index 0000000..63d8d7a --- /dev/null +++ b/u/leftpadesque/mod.ts @@ -0,0 +1,4 @@ +export * from "./object.ts"; +export * from "./prepend.ts"; +export * from "./debug.ts"; +export * from "./memoize.ts"; diff --git a/u/leftpadesque/object.ts b/u/leftpadesque/object.ts new file mode 100644 index 0000000..73f7f80 --- /dev/null +++ b/u/leftpadesque/object.ts @@ -0,0 +1,2 @@ +export const isObject = (o: unknown): o is object => + typeof o === "object" && !Array.isArray(o) && !!o; diff --git a/utils/prepend.ts b/u/leftpadesque/prepend.ts index 9b77aff..9b77aff 100644 --- a/utils/prepend.ts +++ b/u/leftpadesque/prepend.ts diff --git a/u/mod.ts b/u/mod.ts new file mode 100644 index 0000000..8397ce6 --- /dev/null +++ b/u/mod.ts @@ -0,0 +1,5 @@ +export * from "./fn/mod.ts"; +export * from "./leftpadesque/mod.ts"; +export * from "./process/mod.ts"; +export * from "./trace/mod.ts"; +export * from "./server/mod.ts"; diff --git a/u/process/env.ts b/u/process/env.ts new file mode 100644 index 0000000..0e41b4f --- /dev/null +++ b/u/process/env.ts @@ -0,0 +1,36 @@ +import { Either, type IEither } from "@emprespresso/pengueno"; + +export const getRequiredEnv = <V extends string>(name: V): IEither<Error, V> => + Either + .fromFailable<Error, V>(() => Deno.env.get(name) as V) // could throw when no permission. + .flatMap((v) => + (v && Either.right(v)) || + Either.left( + new Error(`environment variable "${name}" is required D:`), + ) + ); + +type ObjectFromList<T extends ReadonlyArray<string>, V = string> = { + [K in (T extends ReadonlyArray<infer U> ? U : never)]: V; +}; + +export const getRequiredEnvVars = <V extends string>(vars: ReadonlyArray<V>) => + vars + .map((envVar) => [envVar, getRequiredEnv(envVar)] as [V, IEither<Error, V>]) + .reduce( + ( + acc: IEither<Error, ObjectFromList<typeof vars>>, + x: [V, IEither<Error, V>], + ) => { + const [envVar, eitherVal] = x; + return acc.flatMap((args) => { + return eitherVal.mapRight((envValue) => + ({ + ...args, + [envVar]: envValue, + }) as ObjectFromList<typeof vars> + ); + }); + }, + Either.right({} as ObjectFromList<typeof vars>), + ); diff --git a/utils/mod.ts b/u/process/mod.ts index 4e907df..3f02d46 100644 --- a/utils/mod.ts +++ b/u/process/mod.ts @@ -1,6 +1,3 @@ -export * from "./logger.ts"; export * from "./env.ts"; export * from "./run.ts"; -export * from "./secret.ts"; export * from "./validate_identifier.ts"; -export * from "./prepend.ts"; diff --git a/u/process/run.ts b/u/process/run.ts new file mode 100644 index 0000000..4954438 --- /dev/null +++ b/u/process/run.ts @@ -0,0 +1,64 @@ +import { + Either, + type IEither, + type ITraceable, + LogLevel, + TraceUtil, +} from "@emprespresso/pengueno"; + +export type Command = string[] | string; +type CommandOutputDecoded = { + code: number; + stdoutText: string; + stderrText: string; +}; + +export const getStdout = <Trace>( + c: ITraceable<Command, Trace>, + options: Deno.CommandOptions = {}, +): Promise<IEither<Error, string>> => + c.bimap(TraceUtil.withFunctionTrace(getStdout)) + .map((tCmd) => { + const cmd = tCmd.get(); + tCmd.trace.trace(`:> im gonna run this command! ${cmd}`); + const [exec, ...args] = (typeof cmd === "string") ? cmd.split(" ") : cmd; + return new Deno.Command(exec, { + args, + stdout: "piped", + stderr: "piped", + ...options, + }).output(); + }) + .map((tOut) => + Either.fromFailableAsync<Error, Deno.CommandOutput>(tOut.get()) + ) + .map( + TraceUtil.promiseify((tEitherOut) => + tEitherOut.get().flatMap(({ code, stderr, stdout }) => + Either + .fromFailable<Error, CommandOutputDecoded>(() => { + const stdoutText = new TextDecoder().decode(stdout); + const stderrText = new TextDecoder().decode(stderr); + return { code, stdoutText, stderrText }; + }) + .mapLeft((e) => { + tEitherOut.trace.addTrace(LogLevel.ERROR).trace(`o.o wat ${e}`); + return new Error(`${e}`); + }) + .flatMap((decodedOutput): Either<Error, string> => { + const { code, stdoutText, stderrText } = decodedOutput; + tEitherOut.trace.addTrace(LogLevel.DEBUG).trace( + `stderr hehehe ${stderrText}`, + ); + if (code !== 0) { + const msg = + `i weceived an exit code of ${code} i wanna zewoooo :<`; + tEitherOut.trace.addTrace(LogLevel.ERROR).trace(msg); + return Either.left(new Error(msg)); + } + return Either.right(stdoutText); + }) + ) + ), + ) + .get(); diff --git a/u/process/validate_identifier.ts b/u/process/validate_identifier.ts new file mode 100644 index 0000000..32952a6 --- /dev/null +++ b/u/process/validate_identifier.ts @@ -0,0 +1,24 @@ +import { Either, type IEither } from "@emprespresso/pengueno"; + +export const validateIdentifier = (token: string) => { + return (/^[a-zA-Z0-9_\-:. \/]+$/).test(token) && !token.includes(".."); +}; + +// ensure {@param obj} is a Record<string, string> with stuff that won't +// have the potential for shell injection, just to be super safe. +type InvalidEntry<K, T> = [K, T]; +export const validateExecutionEntries = < + T, + K extends symbol | number | string = symbol | number | string, +>( + obj: Record<K, T>, +): IEither< + Array<InvalidEntry<K, T>>, + Record<string, string> +> => { + const invalidEntries = <Array<InvalidEntry<K, T>>> Object.entries(obj).filter( + (e) => !e.every((x) => typeof x === "string" && validateIdentifier(x)), + ); + if (invalidEntries.length > 0) return Either.left(invalidEntries); + return Either.right(<Record<string, string>> obj); +}; diff --git a/u/server/activity/fourohfour.ts b/u/server/activity/fourohfour.ts new file mode 100644 index 0000000..6449abd --- /dev/null +++ b/u/server/activity/fourohfour.ts @@ -0,0 +1,36 @@ +import { + type IActivity, + type ITraceable, + JsonResponse, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +const messages = [ + "(≧ω≦)ゞ Oopsie! This endpoint has gone a-404-dable!", + "。゚(。ノωヽ。)゚。 Meow-t found! Your API call ran away!", + "404-bidden! But like...in a cute way (・`ω´・) !", + "(=①ω①=) This endpoint is hiss-terically missing!", + "┐(´∀`)┌ Whoopsie fluff! No API here!", + "(つ≧▽≦)つ Your data went on a paw-sible vacation!", + "(ꈍᴗꈍ) Uwu~ not found, but found our hearts instead!", + "ヽ(;▽;)ノ Eep! This route has ghosted you~", +]; +const randomFourOhFour = () => messages[Math.random() * messages.length]; + +export interface IFourOhFourActivity { + fourOhFour: IActivity; +} + +export class FourOhFourActivityImpl implements IFourOhFourActivity { + public fourOhFour( + req: ITraceable<PenguenoRequest, ServerTrace>, + ) { + return req + .move( + new JsonResponse(req, randomFourOhFour(), { status: 404 }), + ) + .map((resp) => Promise.resolve(resp.get())) + .get(); + } +} diff --git a/u/server/activity/health.ts b/u/server/activity/health.ts new file mode 100644 index 0000000..0f54a99 --- /dev/null +++ b/u/server/activity/health.ts @@ -0,0 +1,67 @@ +import { + type IActivity, + type IEither, + type ITraceable, + JsonResponse, + LogLevel, + type Mapper, + Metric, + type PenguenoRequest, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +export enum HealthCheckInput { + CHECK, +} +export enum HealthCheckOutput { + YAASSSLAYQUEEN, +} + +export interface IHealthCheckActivity { + checkHealth: IActivity; +} + +const healthCheckMetric = Metric.fromName("Health"); +export interface HealthChecker extends + Mapper< + ITraceable<HealthCheckInput, ServerTrace>, + Promise<IEither<Error, HealthCheckOutput>> + > {} +export class HealthCheckActivityImpl implements IHealthCheckActivity { + constructor( + private readonly check: HealthChecker, + ) {} + + public checkHealth(req: ITraceable<PenguenoRequest, ServerTrace>) { + return req + .bimap(TraceUtil.withFunctionTrace(this.checkHealth)) + .bimap(TraceUtil.withMetricTrace(healthCheckMetric)) + .flatMap((r) => r.move(HealthCheckInput.CHECK).map(this.check)) + .peek(TraceUtil.promiseify((h) => + h.get().fold((err) => { + if (err) { + h.trace.trace(healthCheckMetric.failure); + h.trace.addTrace(LogLevel.ERROR).trace(`${err}`); + return; + } + h.trace.trace(healthCheckMetric.success); + }) + )) + .map(TraceUtil.promiseify((h) => + h.get() + .mapBoth( + () => "oh no, i need to eat more vegetables (。•́︿•̀。)...", + () => "think im healthy!! (✿˘◡˘) ready to do work~", + ) + .fold((errMsg, okMsg) => + new JsonResponse( + req, + errMsg ?? okMsg, + { status: errMsg ? 500 : 200 }, + ) + ) + )) + .get(); + } +} diff --git a/u/server/activity/mod.ts b/u/server/activity/mod.ts new file mode 100644 index 0000000..82d8ec4 --- /dev/null +++ b/u/server/activity/mod.ts @@ -0,0 +1,13 @@ +import type { + ITraceable, + PenguenoRequest, + PenguenoResponse, + ServerTrace, +} from "@emprespresso/pengueno"; + +export interface IActivity { + (req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse>; +} + +export * from "./health.ts"; +export * from "./fourohfour.ts"; diff --git a/u/server/filter/json.ts b/u/server/filter/json.ts new file mode 100644 index 0000000..4a2961e --- /dev/null +++ b/u/server/filter/json.ts @@ -0,0 +1,51 @@ +import { + Either, + type IEither, + type ITraceable, + LogLevel, + Metric, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +export interface JsonTransformer<R, ParsedJson = unknown> { + (json: ITraceable<ParsedJson, ServerTrace>): IEither<PenguenoError, R>; +} + +const ParseJsonMetric = Metric.fromName("JsonParse"); +export const jsonModel = <MessageT>( + jsonTransformer: JsonTransformer<MessageT>, +): RequestFilter<MessageT> => +(r: ITraceable<PenguenoRequest, ServerTrace>) => + r.bimap(TraceUtil.withMetricTrace(ParseJsonMetric)) + .map((j) => + Either.fromFailableAsync<Error, MessageT>(j.get().json()) + .then((either) => + either.mapLeft((errReason) => { + j.trace.addTrace(LogLevel.WARN).trace(`${errReason}`); + return new PenguenoError( + "seems to be invalid JSON (>//<) can you fix?", + 400, + ); + }) + ) + ) + .peek( + TraceUtil.promiseify((traceableEither) => + traceableEither.get().mapBoth( + () => traceableEither.trace.trace(ParseJsonMetric.failure), + () => traceableEither.trace.trace(ParseJsonMetric.success), + ) + ), + ) + .map( + TraceUtil.promiseify((traceableEitherJson) => + traceableEitherJson.get() + .mapRight(traceableEitherJson.move) + .flatMap(jsonTransformer) + ), + ) + .get(); diff --git a/u/server/filter/method.ts b/u/server/filter/method.ts new file mode 100644 index 0000000..6b0419d --- /dev/null +++ b/u/server/filter/method.ts @@ -0,0 +1,41 @@ +import { + Either, + type ITraceable, + LogLevel, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +type HttpMethod = + | "POST" + | "GET" + | "HEAD" + | "PUT" + | "DELETE" + | "CONNECT" + | "OPTIONS" + | "TRACE" + | "PATCH"; + +export const requireMethod = ( + methods: Array<HttpMethod>, +): RequestFilter<HttpMethod> => +(req: ITraceable<PenguenoRequest, ServerTrace>) => + req.bimap(TraceUtil.withFunctionTrace(requireMethod)) + .move(Promise.resolve(req.get())) + .map(TraceUtil.promiseify((t) => { + const { method: _method } = t.get(); + const method = <HttpMethod> _method; + if (!methods.includes(method)) { + const msg = "that's not how you pet me (⋟﹏⋞)~"; + t.trace.addTrace(LogLevel.WARN).trace(msg); + return Either.left<PenguenoError, HttpMethod>( + new PenguenoError(msg, 405), + ); + } + return Either.right<PenguenoError, HttpMethod>(method); + })) + .get(); diff --git a/u/server/filter/mod.ts b/u/server/filter/mod.ts new file mode 100644 index 0000000..bbf37df --- /dev/null +++ b/u/server/filter/mod.ts @@ -0,0 +1,33 @@ +import { + type IEither, + type ITraceable, + LogLevel, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +export enum ErrorSource { + USER = LogLevel.WARN, + SYSTEM = LogLevel.ERROR, +} + +export class PenguenoError extends Error { + public readonly source: ErrorSource; + constructor(message: string, public readonly status: number) { + super(message); + this.source = Math.floor(status / 100) === 4 + ? ErrorSource.USER + : ErrorSource.SYSTEM; + } +} + +export interface RequestFilter< + T, + Err extends PenguenoError = PenguenoError, + RIn = ITraceable<PenguenoRequest, ServerTrace>, +> { + (req: RIn): Promise<IEither<Err, T>>; +} + +export * from "./method.ts"; +export * from "./json.ts"; diff --git a/u/server/mod.ts b/u/server/mod.ts new file mode 100644 index 0000000..866b5f9 --- /dev/null +++ b/u/server/mod.ts @@ -0,0 +1,7 @@ +import type { LogMetricTraceSupplier } from "@emprespresso/pengueno"; +export type ServerTrace = LogMetricTraceSupplier; + +export * from "./activity/mod.ts"; +export * from "./filter/mod.ts"; +export * from "./response.ts"; +export * from "./request.ts"; diff --git a/u/server/request.ts b/u/server/request.ts new file mode 100644 index 0000000..7aa9917 --- /dev/null +++ b/u/server/request.ts @@ -0,0 +1,57 @@ +import { LogMetricTraceable } from "@emprespresso/pengueno"; + +const greetings = [ + "hewwo :D", + "hiya cutie (✿◠‿◠)", + "boop! ૮・ᴥ・ა", + "sending virtual hugs! (づ。◕‿‿◕。)づ", + "stay pawsitive ₍^..^₎⟆", + "⋆。‧˚❆🐧❆˚‧。⋆", +]; +const penguenoGreeting = () => + greetings[Math.floor(Math.random() * greetings.length)]; + +export class PenguenoRequest extends Request { + private constructor( + _input: URL, + _requestInit: RequestInit, + public readonly id: string, + public readonly at: Date, + ) { + super(_input, _requestInit); + } + + public baseResponseHeaders(): Record<string, string> { + const ServerRequestTime = this.at.getTime(); + const ServerResponseTime = Date.now(); + const DeltaTime = ServerResponseTime - ServerRequestTime; + const RequestId = this.id; + + return Object.entries({ + RequestId, + ServerRequestTime, + ServerResponseTime, + DeltaTime, + Hai: penguenoGreeting(), + }).reduce((acc, [key, val]) => ({ ...acc, [key]: (val.toString()) }), {}); + } + + public static from( + request: Request, + ): LogMetricTraceable<PenguenoRequest> { + const id = crypto.randomUUID(); + const url = new URL(request.url); + const { pathname } = url; + const traceSupplier = () => `[${id} <- ${request.method}'d @ ${pathname}]`; + return LogMetricTraceable + .from( + new PenguenoRequest( + url, + { ...request }, + id, + new Date(), + ), + ) + .bimap((_request) => [_request.get(), traceSupplier]); + } +} diff --git a/u/server/response.ts b/u/server/response.ts new file mode 100644 index 0000000..c21819a --- /dev/null +++ b/u/server/response.ts @@ -0,0 +1,84 @@ +import { + type IEither, + isEither, + type ITraceable, + Metric, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +export type ResponseBody = object | string; +export type TResponseInit = ResponseInit & { + status: number; + headers?: Record<string, string>; +}; + +const getResponse = ( + req: PenguenoRequest, + opts: TResponseInit, +): TResponseInit => { + return { + ...opts, + headers: { + ...(req.baseResponseHeaders()), + ...(opts?.headers), + "Content-Type": (opts?.headers?.["Content-Type"] ?? "text/plain") + + "; charset=utf-8", + }, + }; +}; + +const ResponseCodeMetrics = [1, 2, 3, 4, 5].map((x) => + Metric.fromName(`response.${x}xx`) +); +export const getResponseMetric = (status: number) => { + const index = (Math.floor(status / 100)) + 1; + return ResponseCodeMetrics[index] ?? ResponseCodeMetrics[5 - 1]; +}; + +export class PenguenoResponse extends Response { + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + msg: BodyInit, + opts: TResponseInit, + ) { + const responseOpts = getResponse(req.get(), opts); + const resMetric = getResponseMetric(opts.status); + req.trace.trace(resMetric.count.withValue(1.0)); + responseOpts.headers; + super(msg, responseOpts); + } +} + +export class JsonResponse extends PenguenoResponse { + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + e: BodyInit | IEither<ResponseBody, ResponseBody>, + opts: TResponseInit, + ) { + const optsWithJsonContentType = { + ...opts, + headers: { + ...opts?.headers, + "Content-Type": "application/json", + }, + }; + if (isEither<ResponseBody, ResponseBody>(e)) { + super( + req, + JSON.stringify( + e.fold((err, ok) => err ? ({ error: err! }) : ({ ok: ok! })), + ), + optsWithJsonContentType, + ); + return; + } + super( + req, + JSON.stringify( + (Math.floor(opts.status / 100) < 4) ? { ok: e } : { error: e }, + ), + optsWithJsonContentType, + ); + } +} diff --git a/u/trace/itrace.ts b/u/trace/itrace.ts new file mode 100644 index 0000000..e6189d3 --- /dev/null +++ b/u/trace/itrace.ts @@ -0,0 +1,107 @@ +import type { Mapper, SideEffect, Supplier } from "@emprespresso/pengueno"; + +// the "thing" every Trace writer must "trace()" +type BaseTraceWith = string; +export type ITraceWith<T> = BaseTraceWith | T; +export interface ITrace<TraceWith> { + addTrace: Mapper<ITraceWith<TraceWith>, ITrace<TraceWith>>; + trace: SideEffect<ITraceWith<TraceWith>>; +} + +export type ITraceableTuple<T, TraceWith> = [T, BaseTraceWith | TraceWith]; +export type ITraceableMapper< + T, + U, + TraceWith, + W = ITraceable<T, TraceWith>, +> = ( + w: W, +) => U; + +export interface ITraceable<T, Trace = BaseTraceWith> { + readonly trace: ITrace<Trace>; + get: Supplier<T>; + move: <U>(u: U) => ITraceable<U, Trace>; + map: <U>( + mapper: ITraceableMapper<T, U, Trace>, + ) => ITraceable<U, Trace>; + bimap: <U>( + mapper: ITraceableMapper< + T, + ITraceableTuple<U, Array<Trace> | Trace>, + Trace + >, + ) => ITraceable<U, Trace>; + peek: (peek: ITraceableMapper<T, void, Trace>) => ITraceable<T, Trace>; + flatMap: <U>( + mapper: ITraceableMapper<T, ITraceable<U, Trace>, Trace>, + ) => ITraceable<U, Trace>; + flatMapAsync<U>( + mapper: ITraceableMapper<T, Promise<ITraceable<U, Trace>>, Trace>, + ): ITraceable<Promise<U>, Trace>; +} + +export class TraceableImpl<T, TraceWith> implements ITraceable<T, TraceWith> { + protected constructor( + private readonly item: T, + public readonly trace: ITrace<TraceWith>, + ) {} + + public map<U>( + mapper: ITraceableMapper<T, U, TraceWith>, + ) { + const result = mapper(this); + return new TraceableImpl(result, this.trace); + } + + public flatMap<U>( + mapper: ITraceableMapper< + T, + ITraceable<U, TraceWith>, + TraceWith + >, + ): ITraceable<U, TraceWith> { + return mapper(this); + } + + public flatMapAsync<U>( + mapper: ITraceableMapper< + T, + Promise<ITraceable<U, TraceWith>>, + TraceWith + >, + ): ITraceable<Promise<U>, TraceWith> { + return new TraceableImpl( + mapper(this).then((t) => t.get()), + this.trace, + ); + } + + public peek(peek: ITraceableMapper<T, void, TraceWith>) { + peek(this); + return this; + } + + public move<Tt>(t: Tt): ITraceable<Tt, TraceWith> { + return this.map(() => t); + } + + public bimap<U>( + mapper: ITraceableMapper< + T, + ITraceableTuple<U, Array<TraceWith> | TraceWith>, + TraceWith + >, + ) { + const [item, trace] = mapper(this); + const traces = Array.isArray(trace) ? trace : [trace]; + return new TraceableImpl( + item, + traces.reduce((trace, _trace) => trace.addTrace(_trace), this.trace), + ); + } + + public get() { + return this.item; + } +} diff --git a/u/trace/logger.ts b/u/trace/logger.ts new file mode 100644 index 0000000..a5739c8 --- /dev/null +++ b/u/trace/logger.ts @@ -0,0 +1,108 @@ +import { + isDebug, + type ITrace, + type ITraceWith, + type SideEffect, + type Supplier, +} from "@emprespresso/pengueno"; + +export interface ILogger { + log: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} +export enum LogLevel { + UNKNOWN = "UNKNOWN", + INFO = "INFO", + WARN = "WARN", + DEBUG = "DEBUG", + ERROR = "ERROR", +} +const logLevelOrder: Array<LogLevel> = [ + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, +]; +export const isLogLevel = (l: string): l is LogLevel => + logLevelOrder.some((level) => <string> level === l); + +const defaultAllowedLevels = () => + [ + LogLevel.UNKNOWN, + ...(isDebug() ? [LogLevel.DEBUG] : []), + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, + ] as Array<LogLevel>; + +export const logWithLevel = ( + logger: ILogger, + level: LogLevel, +): SideEffect<unknown> => { + switch (level) { + case LogLevel.UNKNOWN: + case LogLevel.INFO: + return logger.log; + case LogLevel.DEBUG: + return logger.debug; + case LogLevel.WARN: + return logger.warn; + case LogLevel.ERROR: + return logger.error; + } +}; + +export type LogTraceSupplier = ITraceWith<Supplier<string>>; + +const defaultTrace = () => `[${new Date().toISOString()}]`; +export const LoggerImpl = console; +export class LogTrace implements ITrace<LogTraceSupplier> { + constructor( + private readonly logger: ILogger = LoggerImpl, + private readonly traces: Array<LogTraceSupplier> = [defaultTrace], + private readonly allowedLevels: Supplier<Array<LogLevel>> = + defaultAllowedLevels, + private readonly defaultLevel: LogLevel = LogLevel.INFO, + ) { + } + + public addTrace(trace: LogTraceSupplier): ITrace<LogTraceSupplier> { + return new LogTrace( + this.logger, + this.traces.concat(trace), + this.allowedLevels, + this.defaultLevel, + ); + } + + public trace(trace: LogTraceSupplier) { + const { line, level: _level } = this.foldTraces(this.traces.concat(trace)); + if (!this.allowedLevels().includes(_level)) return; + + const level = _level === LogLevel.UNKNOWN ? this.defaultLevel : _level; + logWithLevel(this.logger, level)(`[${level}]${line}`); + } + + private foldTraces(traces: Array<LogTraceSupplier>) { + const { line, level } = traces.reduce( + (acc: { line: string; level: number }, t) => { + const val = typeof t === "function" ? t() : t; + if (isLogLevel(val)) { + return { + ...acc, + level: Math.max(logLevelOrder.indexOf(val), acc.level), + }; + } + const prefix = [ + acc.line, + val, + ].join(" "); + return { ...acc, prefix }; + }, + { line: "", level: -1 }, + ); + return { line, level: logLevelOrder[level] ?? LogLevel.UNKNOWN }; + } +} diff --git a/u/trace/metrics.ts b/u/trace/metrics.ts new file mode 100644 index 0000000..4ddde06 --- /dev/null +++ b/u/trace/metrics.ts @@ -0,0 +1,143 @@ +import { + isObject, + type ITrace, + type ITraceWith, + type Mapper, + type SideEffect, + type Supplier, +} from "@emprespresso/pengueno"; + +export enum Unit { + COUNT, + MILLISECONDS, +} + +export interface IMetric { + readonly count: IEmittableMetric; + readonly time: IEmittableMetric; + readonly failure: IMetric; + readonly success: IMetric; + readonly warn: IMetric; + readonly children: Supplier<Array<IMetric>>; + + readonly _tag: "IMetric"; +} +export const isIMetric = (t: unknown): t is IMetric => + isObject(t) && "_tag" in t && t._tag === "IMetric"; + +export interface IEmittableMetric { + readonly name: string; + readonly unit: Unit; + withValue: Mapper<number, MetricValue>; +} + +export class EmittableMetric implements IEmittableMetric { + constructor(public readonly name: string, public readonly unit: Unit) { + } + + public withValue(value: number): MetricValue { + return { + name: this.name, + unit: this.unit, + emissionTimestamp: Date.now(), + value, + _tag: "MetricValue", + }; + } +} + +export class Metric implements IMetric { + constructor( + public readonly count: IEmittableMetric, + public readonly time: IEmittableMetric, + public readonly failure: Metric, + public readonly success: Metric, + public readonly warn: Metric, + public readonly _tag: "IMetric" = "IMetric", + ) {} + + public children() { + return [this.failure, this.success, this.warn]; + } + + static fromName(name: string): Metric { + return new Metric( + new EmittableMetric(`${name}.count`, Unit.COUNT), + new EmittableMetric(`${name}.elapsed`, Unit.MILLISECONDS), + Metric.fromName(`${name}.failure`), + Metric.fromName(`${name}.success`), + Metric.fromName(`${name}.warn`), + ); + } +} + +export interface MetricValue { + readonly name: string; + readonly unit: Unit; + readonly value: number; + readonly emissionTimestamp: number; + readonly _tag: "MetricValue"; +} +export const isMetricValue = (t: unknown): t is MetricValue => + isObject(t) && "_tag" in t && t._tag === "MetricValue"; + +export const isMetricsTraceSupplier = (t: unknown): t is MetricsTraceSupplier => + isMetricValue(t) || isIMetric(t); + +export type MetricsTraceSupplier = ITraceWith<IMetric | MetricValue>; +type MetricTracingTuple = [IMetric, Date]; +export class MetricsTrace implements ITrace<MetricsTraceSupplier> { + constructor( + private readonly metricConsumer: SideEffect<Array<MetricValue>>, + private readonly tracing: Array<MetricTracingTuple> = [], + private readonly flushed: Set<IMetric> = new Set(), + ) {} + + public addTrace(trace: MetricsTraceSupplier) { + if (isMetricValue(trace) || typeof trace === "string") return this; + return new MetricsTrace(this.metricConsumer)._nowTracing(trace); + } + + public trace(metric: MetricsTraceSupplier) { + if (typeof metric === "string") return this; + if (isMetricValue(metric)) { + this.metricConsumer([metric]); + return this; + } + + const foundMetricValues = this.tracing.flatMap(( + [tracing, startedTracing], + ) => + [tracing, ...tracing.children()] + .filter((_tracing) => metric === _tracing) + .flatMap((metric) => [ + this.addMetric(metric, startedTracing), + this.addMetric(tracing, startedTracing), + ]) + ).flatMap((values) => values); + + if (foundMetricValues.length === 0) { + return this._nowTracing(metric); + } + + this.metricConsumer(foundMetricValues); + return this; + } + + private addMetric(metric: IMetric, startedTracing: Date): Array<MetricValue> { + if (this.flushed.has(metric)) { + return []; + } + + this.flushed.add(metric); + return [ + metric.count.withValue(1.0), + metric.time.withValue(Date.now() - startedTracing.getTime()), + ]; + } + + private _nowTracing(metric: IMetric): MetricsTrace { + this.tracing.push([metric, new Date()]); + return this; + } +} diff --git a/u/trace/mod.ts b/u/trace/mod.ts new file mode 100644 index 0000000..0f9b61b --- /dev/null +++ b/u/trace/mod.ts @@ -0,0 +1,5 @@ +export * from "./itrace.ts"; +export * from "./util.ts"; +export * from "./logger.ts"; +export * from "./metrics.ts"; +export * from "./trace.ts"; diff --git a/u/trace/trace.ts b/u/trace/trace.ts new file mode 100644 index 0000000..e942066 --- /dev/null +++ b/u/trace/trace.ts @@ -0,0 +1,82 @@ +import { + isMetricsTraceSupplier, + type ITrace, + type ITraceWith, + LogTrace, + type LogTraceSupplier, + MetricsTrace, + type MetricsTraceSupplier, + type MetricValue, + TraceableImpl, +} from "@emprespresso/pengueno"; + +export class LogTraceable<T> extends TraceableImpl<T, LogTraceSupplier> { + public static LogTrace = new LogTrace(); + static from<T>(t: T) { + return new LogTraceable(t, LogTraceable.LogTrace); + } +} + +const getEmbeddedMetricConsumer = + (logTrace: LogTrace) => (metrics: Array<MetricValue>) => + logTrace.addTrace("<metrics>").trace( + JSON.stringify(metrics, null, 2) + "</metrics>", + ); +export class EmbeddedMetricsTraceable<T> + extends TraceableImpl<T, MetricsTraceSupplier> { + public static MetricsTrace = new MetricsTrace( + getEmbeddedMetricConsumer(LogTraceable.LogTrace), + ); + + static from<T>(t: T) { + return new EmbeddedMetricsTraceable( + t, + EmbeddedMetricsTraceable.MetricsTrace, + ); + } +} + +export type LogMetricTraceSupplier = ITraceWith< + LogTraceSupplier | MetricsTraceSupplier +>; +export class LogMetricTrace implements ITrace<LogMetricTraceSupplier> { + constructor( + private logTrace: ITrace<LogTraceSupplier>, + private metricsTrace: ITrace<MetricsTraceSupplier>, + ) {} + + public addTrace( + trace: LogTraceSupplier | MetricsTraceSupplier, + ): LogMetricTrace { + if (isMetricsTraceSupplier(trace)) { + this.metricsTrace = this.metricsTrace.addTrace(trace); + return this; + } + this.logTrace = this.logTrace.addTrace(trace); + return this; + } + + public trace(trace: LogTraceSupplier | MetricsTraceSupplier) { + if (isMetricsTraceSupplier(trace)) { + this.metricsTrace.trace(trace); + return this; + } + this.logTrace.trace(trace); + return this; + } +} + +export class LogMetricTraceable<T> + extends TraceableImpl<T, MetricsTraceSupplier | LogTraceSupplier> { + public static LogMetricTrace = new LogMetricTrace( + LogTraceable.LogTrace, + EmbeddedMetricsTraceable.MetricsTrace, + ); + + static from<T>(t: T) { + return new LogMetricTraceable( + t, + LogMetricTraceable.LogMetricTrace, + ); + } +} diff --git a/u/trace/util.ts b/u/trace/util.ts new file mode 100644 index 0000000..302c8e4 --- /dev/null +++ b/u/trace/util.ts @@ -0,0 +1,58 @@ +import type { + Callable, + IMetric, + ITraceableMapper, + ITraceableTuple, + MetricsTraceSupplier, +} from "@emprespresso/pengueno"; + +export class TraceUtil { + static withTrace<T, Trace>( + trace: string, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return (t) => [t.get(), `[${trace}]`]; + } + + static withMetricTrace<T, Trace extends MetricsTraceSupplier>( + metric: IMetric, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return (t) => [t.get(), metric as Trace]; + } + + static withFunctionTrace<F extends Callable, T, Trace>( + f: F, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return TraceUtil.withTrace(f.name); + } + + static withClassTrace<C extends object, T, Trace>( + c: C, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return TraceUtil.withTrace(c.constructor.name); + } + + static promiseify<T, U, Trace>( + mapper: ITraceableMapper<T, U, Trace>, + ): ITraceableMapper<Promise<T>, Promise<U>, Trace> { + return (traceablePromise) => + traceablePromise.flatMapAsync(async (t) => + t.move(await t.get()).map(mapper) + ).get(); + } +} diff --git a/utils/deno.json b/utils/deno.json deleted file mode 100644 index b85c47f..0000000 --- a/utils/deno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@liz-ci/utils", - "version": "0.1.0", - "exports": "./mod.ts" -} diff --git a/utils/env.ts b/utils/env.ts deleted file mode 100644 index c0cf447..0000000 --- a/utils/env.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const getRequiredEnv = (name: string): string => { - const value = Deno.env.get(name); - if (!value) throw new Error(`${name} environment variable is required`); - return value; -}; diff --git a/utils/logger.ts b/utils/logger.ts deleted file mode 100644 index e36d249..0000000 --- a/utils/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const loggerWithPrefix = (prefixSupplier: () => string) => { - return { - log: (...args: unknown[]) => console.log(prefixSupplier(), ...args), - error: (...args: unknown[]) => console.error(prefixSupplier(), ...args), - }; -}; diff --git a/utils/run.ts b/utils/run.ts deleted file mode 100644 index f06ef97..0000000 --- a/utils/run.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const getStdout = async ( - cmd: string[] | string, - options: Deno.CommandOptions = {}, -): Promise<string> => { - const [exec, ...args] = (typeof cmd === "string") ? cmd.split(" ") : cmd; - const command = new Deno.Command(exec, { - args, - stdout: "piped", - stderr: "piped", - ...options, - }); - - const { code, stdout, stderr } = await command.output(); - - const stdoutText = new TextDecoder().decode(stdout); - const stderrText = new TextDecoder().decode(stderr); - - if (code !== 0) throw new Error(`Command failed\n${stderrText}`); - - return stdoutText; -}; diff --git a/utils/secret.ts b/utils/secret.ts deleted file mode 100644 index eb2054b..0000000 --- a/utils/secret.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getRequiredEnv, getStdout, loggerWithPrefix } from "./mod.ts"; - -const logger = loggerWithPrefix(() => - `[${new Date().toISOString()}] [BitwardenSession]` -); -export class BitwardenSession { - private readonly sessionInitializer: Promise<string>; - - constructor(server = getRequiredEnv("BW_SERVER")) { - ["BW_CLIENTID", "BW_CLIENTSECRET"].forEach(getRequiredEnv); - - this.sessionInitializer = getStdout( - `bw config server ${server} --quiet`, - ) - .then(() => { - logger.log("Logging in via API"); - return getStdout(`bw login --apikey --quiet`); - }) - .then(() => { - logger.log("Unlocking vault in session"); - return getStdout(`bw unlock --passwordenv BW_PASSWORD --raw`); - }) - .then((session) => { - logger.log(`Session ${session}`); - return session.trim(); - }); - } - - public async getItem<T extends LoginItem | SecureNote>( - secretName: string, - ): Promise<T> { - logger.log(`Finding secret ${secretName}`); - return await this.sessionInitializer.then((session) => - getStdout(`bw list items`, { - env: { - BW_SESSION: session, - }, - }) - ).then((items) => JSON.parse(items)).then((items) => - items.find(({ name }: { name: string }) => name === secretName) - ).then((item) => { - if (!item) throw new Error("Could not find bitwarden item " + secretName); - logger.log(`Found secret: ${secretName}`); - return item; - }); - } - - async close(): Promise<void> { - return await this.sessionInitializer.then((session) => - getStdout(`bw lock`, { env: { BW_SESSION: session } }) - ).then(() => { - logger.log("Locked session"); - }); - } -} - -export type LoginItem = { - login: { - username: string; - password: string; - }; -}; - -export type SecureNote = { - notes: string; -}; diff --git a/utils/validate_identifier.ts b/utils/validate_identifier.ts deleted file mode 100644 index 0c9242c..0000000 --- a/utils/validate_identifier.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const validateIdentifier = (token: string) => { - return (/^[a-zA-Z0-9_\-:. \/]+$/).test(token) && !token.includes(".."); -}; diff --git a/worker/Dockerfile b/worker/Dockerfile index ea393ed..a3e2e12 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:stable-slim AS cli-dependencies +FROM debian:stable-slim AS worker-dependencies # Define versions as build arguments to improve caching ARG BITWARDEN_VERSION=2025.4.0 @@ -11,15 +11,15 @@ RUN unzip /bw-linux.zip -d / \ RUN curl -L "https://get.docker.com/builds/$(uname -s)/$(uname -m)/docker-latest.tgz" > /docker.tgz RUN tar -xvzf /docker.tgz -FROM oci.liz.coffee/img/liz-ci:release AS worker +FROM oci.liz.coffee/@emprespresso/ci-base:release AS worker RUN apt-get update && apt-get install -yqq git jq RUN groupadd docker RUN useradd --system --home-dir /var/lib/laminar \ --no-user-group --groups users,docker --uid 100 laminar -COPY --from=cli-dependencies /bw /usr/local/bin/ -COPY --from=cli-dependencies /docker/* /usr/local/bin/ +COPY --from=worker-dependencies /bw /usr/local/bin/ +COPY --from=worker-dependencies /docker/* /usr/local/bin/ RUN mkdir -p /var/lib/laminar/cfg RUN cp -r /app/worker/* /var/lib/laminar/cfg diff --git a/worker/deno.json b/worker/deno.json index 5636d0a..77c65de 100644 --- a/worker/deno.json +++ b/worker/deno.json @@ -1,4 +1,4 @@ { - "name": "@liz-ci/worker", - "exports": "./mod.ts" + "name": "@emprespresso/ci-worker", + "exports": "./mod.ts" } diff --git a/worker/executor/job.ts b/worker/executor/job.ts new file mode 100644 index 0000000..76f0e0c --- /dev/null +++ b/worker/executor/job.ts @@ -0,0 +1,42 @@ +import { + getStdout, + type ITraceable, + LogLevel, + type LogMetricTraceSupplier, + memoize, + Metric, + TraceUtil, + validateExecutionEntries, +} from "@emprespresso/pengueno"; +import type { Job } from "@emprespresso/ci-model"; + +const jobTypeMetric = memoize((type: string) => Metric.fromName(`run.${type}`)); +export const executeJob = (tJob: ITraceable<Job, LogMetricTraceSupplier>) => + tJob.bimap(TraceUtil.withMetricTrace(jobTypeMetric(tJob.get().type))) + .peek((tJob) => + tJob.trace.trace( + `let's do this little job ok!! ${tJob.get()}`, + ) + ) + .map((tJob) => + validateExecutionEntries(tJob.get().arguments) + .mapLeft((badEntries) => { + tJob.trace.addTrace(LogLevel.ERROR).trace( + badEntries.toString(), + ); + return new Error("invalid job arguments"); + }) + .flatMapAsync((args) => + getStdout(tJob.move(tJob.get().type), { env: args }) + ) + ) + .peek( + TraceUtil.promiseify((q) => + q.trace.trace( + q.get().fold((err, _val) => + jobTypeMetric(tJob.get().type)[err ? "failure" : "success"] + ), + ) + ), + ) + .get(); diff --git a/worker/executor/mod.ts b/worker/executor/mod.ts new file mode 100644 index 0000000..944ab7d --- /dev/null +++ b/worker/executor/mod.ts @@ -0,0 +1,2 @@ +export * from "./job.ts"; +export * from "./pipeline.ts"; diff --git a/worker/executor/pipeline.ts b/worker/executor/pipeline.ts new file mode 100644 index 0000000..a1aa7c3 --- /dev/null +++ b/worker/executor/pipeline.ts @@ -0,0 +1,53 @@ +import { + Either, + type IEither, + type ITraceable, + type LogMetricTraceSupplier, + Metric, + TraceUtil, +} from "@emprespresso/pengueno"; +import type { Job, JobArgT, Pipeline } from "@emprespresso/ci-model"; +import { executeJob } from "./mod.ts"; + +const pipelinesMetric = Metric.fromName("pipelines"); +export const executePipeline = ( + tPipeline: ITraceable<Pipeline, LogMetricTraceSupplier>, + baseEnv?: JobArgT, +): Promise<IEither<Error, void>> => + tPipeline.bimap(TraceUtil.withFunctionTrace(executePipeline)) + .bimap(TraceUtil.withMetricTrace(pipelinesMetric)) + .map(async (tJobs): Promise<IEither<Error, void>> => { + for (const [i, serialStage] of tJobs.get().serialJobs.entries()) { + tJobs.trace.trace( + `executing stage ${i}. do your best little stage :>\n${serialStage}`, + ); + const jobResults = await Promise.all( + serialStage.parallelJobs.map((job) => + tJobs.bimap((_) => [job, `stage ${i}`]) + .map((tJob) => + <Job> ({ + ...tJob.get(), + arguments: { ...baseEnv, ...tJob.get().arguments }, + }) + ) + .map(executeJob) + .peek( + TraceUtil.promiseify((tEitherJobOutput) => + tEitherJobOutput.get().mapRight((stdout) => + tEitherJobOutput.trace.addTrace("STDOUT").trace(stdout) + ) + ), + ) + .get() + ), + ); + const failures = jobResults.filter((e) => e.fold((err) => !!err)); + if (failures.length > 0) { + tJobs.trace.trace(pipelinesMetric.failure); + return Either.left(new Error(failures.toString())); + } + } + tJobs.trace.trace(pipelinesMetric.success); + return Either.right(undefined); + }) + .get(); diff --git a/worker/jobs/checkout_ci.run b/worker/jobs/checkout_ci.run deleted file mode 100755 index 0945444..0000000 --- a/worker/jobs/checkout_ci.run +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -# usage: laminarc run checkout_ci remote="ssh://src.liz.coffee:2222/cgit" rev="<sha>" \ -# refname="refs/..." - -RUN=`date +%s` -RETURN="$PWD" -WORKING_DIR="$PWD/$RUN" - -export LOG_PREFIX="[checkout_ci.$RUN]" - -log "starting checkout_ci job $remote @ $refname - $rev in $WORKING_DIR" -mkdir -p "$WORKING_DIR" && cd "$WORKING_DIR" - -CODE="$WORKING_DIR/src" -checkout="$rev" path="$CODE" fetch_code - -CI_WORKFLOW="$CODE/.ci/ci.json" -if [[ ! -e "$CI_WORKFLOW" ]]; then - log "no CI configuration found" - exit 0 -fi - -PIPELINE_GENERATOR_PATH=$(jq -r '.pipeline' "$CI_WORKFLOW") -if [[ "$PIPELINE_GENERATOR_PATH" == *".."* ]]; then - log "no '..'" - exit 1 -fi - -log "building the pipeline..." -PIPELINE="$WORKING_DIR/pipeline.json" -docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges \ - -e refname="$refname" -e rev="$rev" -e remote="$remote" \ - -v "$CODE/$PIPELINE_GENERATOR_PATH:/pipeline_generator" \ - oci.liz.coffee/img/liz-ci:release /pipeline_generator \ - > "$PIPELINE" - -pipeline="$PIPELINE" run_pipeline - -log "cleaning up working directory" -cd "$RETURN" && rm -rf "$WORKING_DIR" - -log "checkout_ci run done" diff --git a/worker/jobs/ci_pipeline.run b/worker/jobs/ci_pipeline.run new file mode 100644 index 0000000..337bd53 --- /dev/null +++ b/worker/jobs/ci_pipeline.run @@ -0,0 +1,166 @@ +import { + type Command, + Either, + getRequiredEnvVars, + getStdout, + isObject, + LogMetricTraceable, + Metric, + prependWith, + TraceUtil, +} from "@emprespresso/pengueno"; +import { + type CheckoutCiJob, + type FetchCodeJob, + PipelineImpl, +} from "@emprespresso/ci-model"; +import { executeJob, executePipeline } from "@emprespresso/ci-worker"; + +const run = Date.now().toString(); +const eitherJob = getRequiredEnvVars(["remote", "refname", "rev"]) + .mapRight((baseArgs) => ( + <CheckoutCiJob> { + type: "checkout_ci", + arguments: { + ...baseArgs, + run, + returnPath: Deno.cwd(), + }, + } + )); + +const ciRunMetric = Metric.fromName("checkout_ci.run"); +const trace = `checkout_ci.${run}`; +await LogMetricTraceable.from(eitherJob).bimap(TraceUtil.withTrace(trace)) + .bimap(TraceUtil.withMetricTrace(ciRunMetric)) + .map((tEitherJob) => + tEitherJob.get().flatMapAsync((ciJob) => { + const wd = getWorkingDirectoryForCiJob(ciJob); + const fetchPackageJob = <FetchCodeJob> { + type: "fetch_code", + arguments: { + remoteUrl: ciJob.arguments.remote, + checkout: ciJob.arguments.rev, + path: getSrcDirectoryForCiJob(ciJob), + }, + }; + return Either.fromFailableAsync<Error, CheckoutCiJob>( + Deno.mkdir(wd).then(() => Deno.chdir(wd)) + .then(() => tEitherJob.move(fetchPackageJob).map(executeJob).get()) + .then(() => ciJob), + ); + }) + ) + .map((tEitherCiJob) => + tEitherCiJob.get().then((eitherCiJob) => + eitherCiJob.flatMapAsync<{ cmd: Command; job: CheckoutCiJob }>((ciJob) => + Either.fromFailableAsync<Error, string>( + Deno.readTextFile( + `${getSrcDirectoryForCiJob(ciJob)}/${CI_WORKFLOW_FILE}`, + ), + ).then((eitherWorkflowJson) => + eitherWorkflowJson.flatMap( + (json) => Either.fromFailable<Error, unknown>(JSON.parse(json)), + ).flatMap((eitherWorkflowParse) => { + if (isCiWorkflow(eitherWorkflowParse)) { + return Either.right({ + cmd: getPipelineGenerationCommand( + ciJob, + eitherWorkflowParse.workflow, + ), + job: ciJob, + }); + } + return Either.left( + new Error( + "couldn't find any valid ci configuration (。•́︿•̀。), that's okay~", + ), + ); + }) + ) + ) + ) + ) + .map(async (tEitherPipelineGenerationCommand) => { + const eitherJobCommand = await tEitherPipelineGenerationCommand.get(); + const eitherPipeline = await eitherJobCommand + .flatMapAsync((jobCommand) => + tEitherPipelineGenerationCommand.move(jobCommand.cmd) + .map(getStdout) + .get() + ); + return eitherPipeline + .flatMap(PipelineImpl.from) + .flatMap((pipeline) => + eitherJobCommand.mapRight(({ job }) => ({ job, pipeline })) + ); + }) + .peek( + TraceUtil.promiseify((tEitherPipeline) => + tEitherPipeline.get() + .mapRight((val) => val.pipeline.serialize()) + .mapRight((pipeline) => + `built the pipeline~ (◕ᴗ◕✿) let's make something amazing! ${pipeline}` + ) + .mapRight((msg) => tEitherPipeline.trace.trace(msg)) + ), + ) + .map( + async (tEitherPipeline) => { + const eitherPipeline = await tEitherPipeline.get(); + return eitherPipeline.flatMapAsync(({ pipeline, job }) => + tEitherPipeline.move(pipeline) + .map((p) => + executePipeline(p, { + HOME: getWorkingDirectoryForCiJob(job), + }) + ) + .get() + ); + }, + ) + .get() + .then((e) => + e.flatMap(() => eitherJob).fold((err, val) => { + if (err) throw err; + return Deno.remove(getWorkingDirectoryForCiJob(val), { recursive: true }); + }) + ); + +const getWorkingDirectoryForCiJob = (job: CheckoutCiJob) => + `${job.arguments.returnPath}/${job.arguments.run}`; + +const getSrcDirectoryForCiJob = (job: CheckoutCiJob) => + `${job.arguments.returnPath}/${job.arguments.run}/src`; + +const _runFlags = ("--rm --network none --cap-drop ALL" + + "--security-opt no-new-privileges").split(" "); +const _image = "oci.liz.coffee/img/ci-worker:release"; +const getPipelineGenerationCommand = ( + job: CheckoutCiJob, + pipelineGeneratorPath: string, + image = _image, + runFlags = _runFlags, +) => [ + "docker", + "run", + ...runFlags, + ...prependWith( + Object.entries(job.arguments).map(([key, val]) => `"${key}"="${val}"`), + "-e", + ), + "-v", + `${ + getSrcDirectoryForCiJob(job) + }/${pipelineGeneratorPath}:/pipeline_generator`, + image, + "/pipeline_generator", +]; + +export interface CiWorkflow { + workflow: string; +} +export const isCiWorkflow = (t: unknown): t is CiWorkflow => + isObject(t) && "workflow" in t && typeof t.workflow === "string" && + !t.workflow.includes(".."); +const CI_WORKFLOW_FILE = ".ci/ci.json"; diff --git a/worker/mod.ts b/worker/mod.ts index e69de29..affcb2c 100644 --- a/worker/mod.ts +++ b/worker/mod.ts @@ -0,0 +1,2 @@ +export * from "./secret/mod.ts"; +export * from "./executor/mod.ts"; diff --git a/worker/scripts/ansible_playbook b/worker/scripts/ansible_playbook index 062680d..e9e967c 100755 --- a/worker/scripts/ansible_playbook +++ b/worker/scripts/ansible_playbook @@ -1,78 +1,113 @@ #!/usr/bin/env -S deno run --allow-env --allow-net --allow-run --allow-read --allow-write import { - BitwardenSession, - getRequiredEnv, + Either, + getRequiredEnvVars, getStdout, - loggerWithPrefix, + type IEither, + LogMetricTraceable, + Metric, prependWith, - type SecureNote, -} from "@liz-ci/utils"; -import type { AnsiblePlaybookJobProps } from "@liz-ci/model"; + TraceUtil, +} from "@emprespresso/pengueno"; +import type { AnsiblePlaybookJob } from "@emprespresso/ci-model"; +import { Bitwarden, type SecureNote } from "@emprespresso/ci-worker"; -const args: AnsiblePlaybookJobProps = { - path: getRequiredEnv("path"), - playbooks: getRequiredEnv("playbooks"), -}; -const logger = loggerWithPrefix(() => - `[${new Date().toISOString()}] [ansible_playbook.'${args.playbooks}']` -); +const eitherJob = getRequiredEnvVars([ + "path", + "playbooks", +]) + .mapRight((baseArgs) => ( + <AnsiblePlaybookJob> { + type: "ansible_playbook", + arguments: baseArgs, + } + )); -const run = async () => { - logger.log("Starting Ansible playbook job"); +const eitherVault = Bitwarden.getConfigFromEnvironment() + .mapRight((config) => new Bitwarden(config)); - const bitwardenSession = new BitwardenSession(); - const secretFiles = await Promise.all( - ["ansible_secrets", "ssh_key"] - .map((secretName) => - bitwardenSession - .getItem<SecureNote>(secretName) - .then(async ({ notes: recoveredSecret }) => { - const tempFile = await Deno.makeTempFile(); - await Deno.writeTextFile(tempFile, recoveredSecret); - logger.log(secretName, "stored at", tempFile); - return tempFile; - }) - ), - ); - const [ansibleSecrets, sshKey] = secretFiles; +const playbookMetric = Metric.fromName("ansiblePlaybook.playbook"); +await LogMetricTraceable.from(eitherJob) + .bimap(TraceUtil.withTrace("ansible_playbook")) + .bimap(TraceUtil.withMetricTrace(playbookMetric)) + .peek((tEitherJob) => + tEitherJob.trace.trace("starting ansible playbook job! (⑅˘꒳˘)") + ) + .map((tEitherJob) => + tEitherJob.get().flatMapAsync((job) => + eitherVault.flatMapAsync(async (vault) => { + const eitherKey = await vault.unlock(tEitherJob); + return eitherKey.mapRight((key) => ({ job, key, vault })); + }) + ) + ) + .map(async (tEitherJobVault) => { + tEitherJobVault.trace.trace( + "getting ansible secwets uwu~", + ); + const eitherJobVault = await tEitherJobVault.get(); - try { - const volumes = [ - `${args.path}:/ansible`, - `${sshKey}:/root/id_rsa`, - `${ansibleSecrets}:/ansible/secrets.yml`, - ]; + const eitherSshKey = await eitherJobVault + .flatMapAsync(({ key, vault }) => + vault.fetchSecret<SecureNote>(tEitherJobVault, key, "ssh_key") + ); + const eitherSshKeyFile = await eitherSshKey.mapRight(({ notes }) => notes) + .flatMapAsync(saveToTempFile); + const eitherAnsibleSecrets = await eitherJobVault + .flatMapAsync(({ key, vault }) => + vault.fetchSecret<SecureNote>(tEitherJobVault, key, "ansible_playbooks") + ); + const eitherAnsibleSecretsFile = await eitherAnsibleSecrets.mapRight(( + { notes }, + ) => notes).flatMapAsync(saveToTempFile); - const playbookCmd = `ansible-playbook -e @secrets.yml ${args.playbooks}`; - const deployCmd = [ - "docker", - "run", - ...prependWith(volumes, "-v"), - "willhallonline/ansible:latest", - ...playbookCmd.split(" "), - ]; - logger.log("deploying...", deployCmd); - await getStdout(deployCmd); - } finally { - await Promise.allSettled( - [bitwardenSession.close()].concat( - secretFiles.map((p) => { - logger.log(`cleanup`, p); - return Deno.remove(p); - }), - ), + return eitherJobVault.flatMapAsync(async ({ job, vault, key }) => { + const eitherLocked = await vault.lock(tEitherJobVault, key); + return eitherLocked.flatMap((_locked) => + eitherSshKeyFile.flatMap((sshKeyFile) => + eitherAnsibleSecretsFile.mapRight((secretsFile) => ({ + job, + sshKeyFile, + secretsFile, + })) + ) + ); + }); + }) + .map(async (tEitherJobAndSecrets) => { + const eitherJobAndSecrets = await tEitherJobAndSecrets.get(); + return eitherJobAndSecrets.flatMapAsync( + ({ job, sshKeyFile, secretsFile }) => { + const volumes = [ + `${job.arguments.path}:/ansible`, + `${sshKeyFile}:/root/id_rsa`, + `${secretsFile}:/ansible/secrets.yml`, + ]; + const playbookCmd = + `ansible-playbook -e @secrets.yml ${job.arguments.playbooks}`; + const deployCmd = [ + "docker", + "run", + ...prependWith(volumes, "-v"), + "willhallonline/ansible:latest", + ...playbookCmd.split(" "), + ]; + tEitherJobAndSecrets.trace.trace( + `running ansible magic~ (◕ᴗ◕✿) ${deployCmd}`, + ); + return tEitherJobAndSecrets.move(deployCmd).map(getStdout).get(); + }, ); - } + }) + .get(); - logger.log("ansible playbook job completed"); -}; - -if (import.meta.main) { - try { - await run(); - } catch (e) { - logger.error("womp womp D:", e); - throw e; - } -} +const saveToTempFile = (text: string): Promise<IEither<Error, string>> => + Either.fromFailableAsync( + Deno.makeTempDir({ dir: Deno.cwd() }) + .then((dir) => Deno.makeTempFile({ dir })) + .then(async (f) => { + await Deno.writeTextFile(f, text); + return f; + }), + ); diff --git a/worker/scripts/build_docker_image b/worker/scripts/build_docker_image new file mode 100755 index 0000000..1dd0c3d --- /dev/null +++ b/worker/scripts/build_docker_image @@ -0,0 +1,161 @@ +#!/usr/bin/env -S deno run --allow-env --allow-net --allow-run + +import { + getRequiredEnvVars, + getStdout, + LogLevel, + LogMetricTraceable, + Metric, + TraceUtil, +} from "@emprespresso/pengueno"; +import type { + BuildDockerImageJob, + BuildDockerImageJobProps, +} from "@emprespresso/ci-model"; +import { Bitwarden, type LoginItem } from "@emprespresso/ci-worker"; + +const eitherJob = getRequiredEnvVars([ + "registry", + "namespace", + "repository", + "imageTag", + "context", + "dockerfile", + "buildTarget", +]) + .mapRight((baseArgs) => ( + <BuildDockerImageJob> { + type: "build_docker_image", + arguments: baseArgs, + } + )); +const eitherVault = Bitwarden.getConfigFromEnvironment() + .mapRight((config) => new Bitwarden(config)); + +const buildImageMetric = Metric.fromName("dockerImage.build"); +const loginMetric = Metric.fromName("dockerRegistry.login"); +await LogMetricTraceable.from(eitherJob) + .bimap( + (tEitherJob) => { + const trace = "build_docker_image." + + tEitherJob.get().fold((_, v) => v?.buildTarget ?? ""); + return [tEitherJob.get(), trace]; + }, + ) + .bimap(TraceUtil.withMetricTrace(buildImageMetric)) + .bimap(TraceUtil.withMetricTrace(loginMetric)) + .peek((tEitherJob) => + tEitherJob.trace.trace("starting docker image build job! (⑅˘꒳˘)") + ) + .map((tEitherJob) => + tEitherJob.get() + .flatMapAsync((job) => + eitherVault.flatMapAsync(async (vault) => { + const eitherKey = await vault.unlock(tEitherJob); + return eitherKey.mapRight((key) => ({ job, key, vault })); + }) + ) + ) + .map(async (tEitherJobVault) => { + tEitherJobVault.trace.trace("logging into the wegistwy uwu~"); + const eitherJobVault = await tEitherJobVault.get(); + const eitherDockerRegistryLoginItem = await eitherJobVault.flatMapAsync(( + { job, key, vault }, + ) => + vault.fetchSecret<LoginItem>(tEitherJobVault, key, job.arguments.registry) + .finally(() => vault.lock(tEitherJobVault, key)) + ); + return eitherDockerRegistryLoginItem.flatMapAsync(({ login }) => + eitherJobVault.flatMapAsync(async ({ job }) => { + const loginCommand = getDockerLoginCommand( + login.username, + job.arguments.registry, + ); + const eitherLoggedIn = await tEitherJobVault.move(loginCommand).map(( + tLoginCmd, + ) => + getStdout(tLoginCmd, { env: { REGISTRY_PASSWORD: login.password } }) + ).get(); + return eitherLoggedIn.moveRight(job); + }) + ); + }) + .peek(async (tEitherWithAuthdRegistry) => { + const eitherWithAuthdRegistry = await tEitherWithAuthdRegistry.get(); + return tEitherWithAuthdRegistry.trace.trace( + eitherWithAuthdRegistry.fold((err, _val) => + loginMetric[err ? "failure" : "success"] + ), + ); + }) + .map(async (tEitherWithAuthdRegistryBuildJob) => { + const eitherWithAuthdRegistryBuildJob = + await tEitherWithAuthdRegistryBuildJob.get(); + tEitherWithAuthdRegistryBuildJob.trace.trace( + "finally building the image~ (◕ᴗ◕✿)", + ); + const eitherBuiltImage = await eitherWithAuthdRegistryBuildJob.flatMapAsync( + (job) => + tEitherWithAuthdRegistryBuildJob + .move(getBuildCommand(job.arguments)) + .map((tBuildCmd) => + getStdout(tBuildCmd, { + env: {}, + clearEnv: true, + }) + ) + .get(), + ); + return eitherBuiltImage.flatMap((buildOutput) => + eitherWithAuthdRegistryBuildJob.mapRight((job) => ({ buildOutput, job })) + ); + }) + .peek(async (tEitherWithBuiltImage) => { + const eitherWithBuiltImage = await tEitherWithBuiltImage.get(); + eitherWithBuiltImage.fold((err, val) => { + tEitherWithBuiltImage.trace.trace( + buildImageMetric[err ? "failure" : "success"], + ); + if (err) { + tEitherWithBuiltImage.trace.addTrace(LogLevel.ERROR).trace( + `oh nyoo we couldn't buiwd the img :(( ${err}`, + ); + return; + } + tEitherWithBuiltImage.trace.addTrace("buildOutput").trace(val); + }); + }) + .map(async (tEitherWithBuiltImage) => { + const eitherWithBuiltImage = await tEitherWithBuiltImage.get(); + return eitherWithBuiltImage + .mapRight(({ job }) => + tEitherWithBuiltImage.move(getPushCommand(job.arguments.imageTag)) + ) + .flatMapAsync((tPushCommand) => getStdout(tPushCommand)); + }) + .get(); + +const getDockerLoginCommand = (username: string, registry: string) => + `docker login --username ${username} --password $REGISTRY_PASSWORD ${registry}` + .split(" "); + +const getBuildCommand = ( + { + buildTarget, + imageTag, + dockerfile, + context, + }: BuildDockerImageJobProps, +) => [ + "docker", + "build", + "--target", + buildTarget, + "-t", + imageTag, + "-f", + dockerfile, + context, +]; + +const getPushCommand = (tag: string) => ["docker", "push", tag]; diff --git a/worker/scripts/build_image b/worker/scripts/build_image deleted file mode 100755 index 07c07c9..0000000 --- a/worker/scripts/build_image +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env -S deno run --allow-env --allow-net --allow-run - -import type { BuildDockerImageJobProps } from "@liz-ci/model"; -import { - BitwardenSession, - getRequiredEnv, - getStdout, - loggerWithPrefix, - type LoginItem, -} from "@liz-ci/utils"; - -const args: BuildDockerImageJobProps = { - registry: getRequiredEnv("registry"), - namespace: getRequiredEnv("namespace"), - repository: getRequiredEnv("repository"), - imageTag: getRequiredEnv("imageTag"), - - context: getRequiredEnv("context"), - dockerfile: getRequiredEnv("dockerfile"), - buildTarget: getRequiredEnv("buildTarget"), -}; - -const logger = loggerWithPrefix(() => - `[${ - new Date().toISOString() - }] [build_image.${args.repository}.${args.imageTag}]` -); - -const run = async () => { - logger.log("Starting Docker image build job"); - - const bitwardenSession = new BitwardenSession(); - const { username: registryUsername, password: registryPassword } = - (await bitwardenSession.getItem<LoginItem>(args.registry))?.login ?? {}; - if (!(registryUsername && registryPassword)) { - throw new Error("where's the login info bruh"); - } - - logger.log(`Logging in to Docker registry: ${args.registry}`); - await getStdout( - [ - "docker", - "login", - "--username", - registryUsername, - "--password", - registryPassword, - args.registry, - ], - ); - - const tag = - `${args.registry}/${args.namespace}/${args.repository}:${args.imageTag}`; - const buildCmd = [ - "docker", - "build", - "--target", - args.buildTarget, - "-t", - tag, - "-f", - `${args.dockerfile}`, - `${args.context}`, - ]; - - logger.log(`building`, tag, buildCmd); - await getStdout( - buildCmd, - { - clearEnv: true, - env: {}, - }, - ); - - const pushCmd = [ - "docker", - "push", - tag, - ]; - logger.log(`pushing`, pushCmd); - await getStdout(pushCmd); -}; - -if (import.meta.main) { - try { - await run(); - } catch (e) { - logger.error("womp womp D:", e); - throw e; - } -} diff --git a/worker/scripts/fetch_code b/worker/scripts/fetch_code index d3af763..cc2d561 100755 --- a/worker/scripts/fetch_code +++ b/worker/scripts/fetch_code @@ -2,15 +2,15 @@ export LOG_PREFIX="[fetch_code $remote @ $checkout -> $path]" -log "fetch!" +log "getting the codez~ time to fetch!" git clone "$remote" "$path" if [ ! $? -eq 0 ]; then - log "D: failed to clone" + log "D: oh nyo! couldn't clone the repo" exit 1 fi cd "$path" -log "checkout $checkout" +log "switching to $checkout~" git reset --hard "$checkout" if [ ! $? -eq 0 ]; then log "D: can't reset to $checkout" diff --git a/worker/scripts/run_pipeline b/worker/scripts/run_pipeline deleted file mode 100755 index 9991001..0000000 --- a/worker/scripts/run_pipeline +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env -S deno run --allow-env --allow-net --allow-run --allow-read --allow-write - -import { type Job, PipelineImpl } from "@liz-ci/model"; -import { - getRequiredEnv, - getStdout, - loggerWithPrefix, - validateIdentifier, -} from "@liz-ci/utils"; - -const pipelinePath = getRequiredEnv("pipeline"); -const logger = loggerWithPrefix(() => - `[${new Date().toISOString()}] [run_pipeline.${pipelinePath}]` -); - -const jobValidForExecution = (job: Job) => { - return Object - .entries(job.arguments) - .filter((e) => { - if (e.every(validateIdentifier)) return true; - logger.error(`job of type ${job.type} has invalid args ${e}`); - return false; - }) - .length === 0; -}; - -const run = async () => { - logger.log("starting pipeline execution"); - - const stages = await (Deno.readTextFile(pipelinePath)) - .then(PipelineImpl.from) - .then((pipeline) => pipeline.getStages()); - - for (const stage of stages) { - logger.log("executing stage", stage); - - await Promise.all( - stage.parallelJobs.map(async (job, jobIdx) => { - logger.log(`executing job ${jobIdx}`, job); - if (!jobValidForExecution(job)) throw new Error("invalid job"); - - const result = await getStdout(job.type, { env: job.arguments }); - logger.log(jobIdx, "outputs", { result }); - }), - ); - } - - logger.log("ok! yay!"); -}; - -if (import.meta.main) { - try { - await run(); - } catch (e) { - logger.error("womp womp D:", e); - throw e; - } -} diff --git a/worker/secret/bitwarden.ts b/worker/secret/bitwarden.ts new file mode 100644 index 0000000..0172a1b --- /dev/null +++ b/worker/secret/bitwarden.ts @@ -0,0 +1,153 @@ +import { + Either, + getRequiredEnvVars, + getStdout, + type IEither, + type ITraceable, + type LogMetricTraceSupplier, + Metric, + TraceUtil, +} from "@emprespresso/pengueno"; +import type { IVault, SecretItem } from "./mod.ts"; + +type TClient = ITraceable<unknown, LogMetricTraceSupplier>; +type TKey = string; +type TItemId = string; +export class Bitwarden implements IVault<TClient, TKey, TItemId> { + constructor(private readonly config: BitwardenConfig) {} + + public unlock(client: TClient) { + return client.move(this.config) + .bimap(TraceUtil.withMetricTrace(Bitwarden.loginMetric)) + .flatMap((tConfig) => + tConfig.move(`bw config server ${tConfig.get().server}`).map(getStdout) + ) + .map(async (tEitherWithConfig) => { + const eitherWithConfig = await tEitherWithConfig.get(); + tEitherWithConfig.trace.trace("logging in~ ^.^"); + return eitherWithConfig.flatMapAsync((_) => + tEitherWithConfig.move("bw login --apikey --quiet").map(getStdout) + .get() + ); + }) + .peek(async (tEitherWithAuthd) => { + const eitherWithAuthd = await tEitherWithAuthd.get(); + return tEitherWithAuthd.trace.trace( + eitherWithAuthd.fold((err, _val) => + Bitwarden.loginMetric[err ? "failure" : "success"] + ), + ); + }) + .map(async (tEitherWithAuthd) => { + const eitherWithAuthd = await tEitherWithAuthd.get(); + tEitherWithAuthd.trace.trace("unlocking the secret vault~ (◕ᴗ◕✿)"); + return eitherWithAuthd.flatMapAsync((_) => + tEitherWithAuthd.move("bw unlock --passwordenv BW_PASSWORD --raw") + .map(getStdout) + .get() + ); + }) + .peek(async (tEitherWithSession) => { + const eitherWithAuthd = await tEitherWithSession.get(); + return tEitherWithSession.trace.trace( + eitherWithAuthd.fold((err, _val) => + Bitwarden.unlockVaultMetric[err ? "failure" : "success"] + ), + ); + }) + .get(); + } + + public fetchSecret<T extends SecretItem>( + client: TClient, + key: string, + item: string, + ): Promise<IEither<Error, T>> { + return client.move(key) + .bimap(TraceUtil.withMetricTrace(Bitwarden.fetchSecretMetric)) + .peek((tSession) => + tSession.trace.trace(`looking for your secret ${item} (⑅˘꒳˘)`) + ) + .flatMap((tSession) => + tSession.move("bw list items").map((listCmd) => + getStdout(listCmd, { env: { BW_SESSION: tSession.get() } }) + ) + ) + .map( + TraceUtil.promiseify((tEitherItemsJson) => + tEitherItemsJson.get() + .flatMap((itemsJson): IEither<Error, Array<T & { name: string }>> => + Either.fromFailable(() => JSON.parse(itemsJson)) + ) + .flatMap((itemsList): IEither<Error, T> => { + const secret = itemsList.find(({ name }) => name === item); + if (!secret) { + return Either.left( + new Error(`couldn't find the item ${item} (。•́︿•̀。)`), + ); + } + return Either.right(secret); + }) + ), + ) + .peek(async (tEitherWithSecret) => { + const eitherWithSecret = await tEitherWithSecret.get(); + return tEitherWithSecret.trace.trace( + eitherWithSecret.fold((err, _val) => + Bitwarden.fetchSecretMetric[err ? "failure" : "success"] + ), + ); + }) + .get(); + } + + public lock(client: TClient, key: TKey) { + return client.move(key) + .bimap(TraceUtil.withMetricTrace(Bitwarden.lockVaultMetric)) + .peek((tSession) => + tSession.trace.trace(`taking care of locking the vault :3`) + ) + .flatMap((tSession) => + tSession.move("bw lock").map((lockCmd) => + getStdout(lockCmd, { env: { BW_SESSION: tSession.get() } }) + ) + ) + .peek(async (tEitherWithLocked) => { + const eitherWithLocked = await tEitherWithLocked.get(); + return eitherWithLocked.fold((err, _val) => { + tEitherWithLocked.trace.trace( + Bitwarden.lockVaultMetric[err ? "failure" : "success"], + ); + if (err) return; + tEitherWithLocked.trace.trace( + "all locked up and secure now~ (。•̀ᴗ-)✧", + ); + }); + }) + .get(); + } + + public static getConfigFromEnvironment(): IEither<Error, BitwardenConfig> { + return getRequiredEnvVars([ + "BW_SERVER", + "BW_CLIENTSECRET", + "BW_CLIENTID", + "BW_PASSWORD", + ]).mapRight(({ BW_SERVER, BW_CLIENTSECRET, BW_CLIENTID }) => ({ + clientId: BW_CLIENTID, + secret: BW_CLIENTSECRET, + server: BW_SERVER, + })); + } + + private static loginMetric = Metric.fromName("Bitwarden.login"); + private static unlockVaultMetric = Metric.fromName("Bitwarden.unlockVault"); + private static fetchSecretMetric = Metric.fromName("Bitwarden.fetchSecret"); + private static lockVaultMetric = Metric.fromName("Bitwarden.lock"); +} + +export interface BitwardenConfig { + server: string; + secret: string; + clientId: string; +} diff --git a/worker/secret/ivault.ts b/worker/secret/ivault.ts new file mode 100644 index 0000000..e101e56 --- /dev/null +++ b/worker/secret/ivault.ts @@ -0,0 +1,24 @@ +import type { IEither } from "@emprespresso/pengueno"; + +export interface LoginItem { + login: { + username: string; + password: string; + }; +} + +export interface SecureNote { + notes: string; +} + +export type SecretItem = LoginItem | SecureNote; +export interface IVault<TClient, TKey, TItemId> { + unlock: (client: TClient) => Promise<IEither<Error, TKey>>; + lock: (client: TClient, key: TKey) => Promise<IEither<Error, TKey>>; + + fetchSecret: <T extends SecretItem>( + client: TClient, + key: TKey, + item: TItemId, + ) => Promise<IEither<Error, T>>; +} diff --git a/worker/secret/mod.ts b/worker/secret/mod.ts new file mode 100644 index 0000000..70a1ea9 --- /dev/null +++ b/worker/secret/mod.ts @@ -0,0 +1,2 @@ +export * from "./ivault.ts"; +export * from "./bitwarden.ts"; |