summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ci/ci.ts24
-rw-r--r--Dockerfile2
-rw-r--r--README.md18
-rw-r--r--deno.json2
-rw-r--r--hooks/Dockerfile4
-rw-r--r--hooks/deno.json2
-rw-r--r--hooks/main.ts12
-rw-r--r--[-rwxr-xr-x]hooks/mod.ts80
-rw-r--r--hooks/server/ci.ts55
-rw-r--r--hooks/server/health.ts25
-rw-r--r--hooks/server/job/activity.ts100
-rw-r--r--hooks/server/job/mod.ts2
-rw-r--r--hooks/server/job/queuer.ts78
-rw-r--r--hooks/server/mod.ts3
-rw-r--r--model/deno.json2
-rw-r--r--model/job.ts19
-rw-r--r--model/pipeline.ts37
-rw-r--r--u/deno.json6
-rw-r--r--u/fn/callable.ts22
-rw-r--r--u/fn/either.ts97
-rw-r--r--u/fn/mod.ts2
-rw-r--r--u/leftpadesque/debug.ts11
-rw-r--r--u/leftpadesque/memoize.ts14
-rw-r--r--u/leftpadesque/mod.ts4
-rw-r--r--u/leftpadesque/object.ts2
-rw-r--r--u/leftpadesque/prepend.ts (renamed from utils/prepend.ts)0
-rw-r--r--u/mod.ts5
-rw-r--r--u/process/env.ts36
-rw-r--r--u/process/mod.ts (renamed from utils/mod.ts)3
-rw-r--r--u/process/run.ts64
-rw-r--r--u/process/validate_identifier.ts24
-rw-r--r--u/server/activity/fourohfour.ts36
-rw-r--r--u/server/activity/health.ts67
-rw-r--r--u/server/activity/mod.ts13
-rw-r--r--u/server/filter/json.ts51
-rw-r--r--u/server/filter/method.ts41
-rw-r--r--u/server/filter/mod.ts33
-rw-r--r--u/server/mod.ts7
-rw-r--r--u/server/request.ts57
-rw-r--r--u/server/response.ts84
-rw-r--r--u/trace/itrace.ts107
-rw-r--r--u/trace/logger.ts108
-rw-r--r--u/trace/metrics.ts143
-rw-r--r--u/trace/mod.ts5
-rw-r--r--u/trace/trace.ts82
-rw-r--r--u/trace/util.ts58
-rw-r--r--utils/deno.json5
-rw-r--r--utils/env.ts5
-rw-r--r--utils/logger.ts6
-rw-r--r--utils/run.ts21
-rw-r--r--utils/secret.ts66
-rw-r--r--utils/validate_identifier.ts3
-rw-r--r--worker/Dockerfile8
-rw-r--r--worker/deno.json4
-rw-r--r--worker/executor/job.ts42
-rw-r--r--worker/executor/mod.ts2
-rw-r--r--worker/executor/pipeline.ts53
-rwxr-xr-xworker/jobs/checkout_ci.run42
-rw-r--r--worker/jobs/ci_pipeline.run166
-rw-r--r--worker/mod.ts2
-rwxr-xr-xworker/scripts/ansible_playbook167
-rwxr-xr-xworker/scripts/build_docker_image161
-rwxr-xr-xworker/scripts/build_image91
-rwxr-xr-xworker/scripts/fetch_code6
-rwxr-xr-xworker/scripts/run_pipeline58
-rw-r--r--worker/secret/bitwarden.ts153
-rw-r--r--worker/secret/ivault.ts24
-rw-r--r--worker/secret/mod.ts2
68 files changed, 2250 insertions, 484 deletions
diff --git a/.ci/ci.ts b/.ci/ci.ts
index b79a830..985b9ee 100644
--- a/.ci/ci.ts
+++ b/.ci/ci.ts
@@ -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);
}
diff --git a/Dockerfile b/Dockerfile
index 7bb81cf..5984aa1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index d242cee..4495934 100644
--- a/README.md
+++ b/README.md
@@ -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~
diff --git a/deno.json b/deno.json
index 1dd298e..66bfe32 100644
--- a/deno.json
+++ b/deno.json
@@ -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";