Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions examples/gitlab-aws-alb-op/ops/alb-deploy.op.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* ALB multi-service deploy Op.
*
* Demonstrates the Op pattern: a named, phased Temporal workflow declared
* as infrastructure code. Run `chant build ops/ -o dist/` to generate
* dist/ops/alb-deploy/workflow.ts, worker.ts, and activities.ts.
*
* Phases:
* 1. Build (parallel) — build all three services concurrently
* 2. Deploy — apply manifests sequentially (ordered by dependency)
* 3. Verify — wait for rollout, then snapshot state
*/
import { Op, phase, build, kubectlApply, waitForStack, stateSnapshot } from "@intentius/chant-lexicon-temporal";

export default Op({
name: "alb-deploy",
overview: "Build and deploy the ALB multi-service stack to the target environment",
taskQueue: "alb-deploy",

phases: [
phase("Build", [
build("examples/gitlab-aws-alb-infra"),
build("examples/gitlab-aws-alb-api"),
build("examples/gitlab-aws-alb-ui"),
], { parallel: true }),

phase("Deploy", [
kubectlApply("dist/alb-infra.yaml"),
kubectlApply("dist/alb-api.yaml"),
kubectlApply("dist/alb-ui.yaml"),
]),

phase("Verify", [
waitForStack("alb-api", { namespace: "alb" }),
waitForStack("alb-ui", { namespace: "alb" }),
stateSnapshot("staging"),
]),
],
});
3 changes: 2 additions & 1 deletion lexicons/temporal/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@intentius/chant-lexicon-temporal",
"version": "0.1.5",
"version": "0.1.6",
"description": "Temporal lexicon for chant — server deployment, namespaces, search attributes, and schedules",
"license": "Apache-2.0",
"type": "module",
Expand All @@ -11,6 +11,7 @@
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts",
"./op/activities": "./src/op/activities/index.ts",
"./manifest": "./dist/manifest.json",
"./meta": "./dist/meta.json",
"./types": "./dist/types/index.d.ts"
Expand Down
17 changes: 17 additions & 0 deletions lexicons/temporal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,20 @@ export { TemporalDevStack } from "./composites/dev-stack";
export type { TemporalDevStackConfig, TemporalDevStackResources } from "./composites/dev-stack";
export { TemporalCloudStack } from "./composites/cloud-stack";
export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack";

// Op builders (re-exported from core for single-import convenience)
export {
Op,
phase,
activity,
gate,
build,
kubectlApply,
helmInstall,
waitForStack,
gitlabPipeline,
stateSnapshot,
shell,
teardown,
} from "@intentius/chant/op";
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "@intentius/chant/op";
23 changes: 23 additions & 0 deletions lexicons/temporal/src/op/activities/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

export interface ChantBuildArgs {
path: string;
/** Optional extra env vars to pass to the build command. */
env?: Record<string, string>;
}

/**
* Run `npm run build` in the given project directory.
* Uses fastIdempotent profile — 5m timeout, 3 retries.
*/
export async function chantBuild(args: ChantBuildArgs): Promise<void> {
const { stdout, stderr } = await execAsync("npm run build", {
cwd: args.path,
env: { ...process.env, ...args.env },
});
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
}
56 changes: 56 additions & 0 deletions lexicons/temporal/src/op/activities/gitlab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Context } from "@temporalio/activity";

const execAsync = promisify(exec);

export interface GitlabPipelineArgs {
/** GitLab project name or path (e.g. "group/project"). */
name: string;
/** Git ref to run the pipeline on. Default: current branch. */
ref?: string;
/** Poll interval in ms. Default: 30000. */
intervalMs?: number;
}

/**
* Trigger a GitLab CI pipeline and wait for it to complete successfully.
* Requires `glab` CLI authenticated in the environment.
* Uses longInfra profile — 20m timeout, heartbeat every 60s.
*/
export async function gitlabPipeline(args: GitlabPipelineArgs): Promise<void> {
const ref = args.ref ?? "HEAD";
const interval = args.intervalMs ?? 30_000;

// Trigger
const { stdout: triggerOut } = await execAsync(
`glab ci run --project ${args.name} --ref ${ref}`,
);
console.log(triggerOut);

// Poll status
let attempt = 0;
while (true) {
attempt++;
Context.current().heartbeat({ step: "gitlabPipeline", project: args.name, attempt });

const { stdout } = await execAsync(
`glab ci status --project ${args.name} --format json`,
);

let status: string | undefined;
try {
const parsed = JSON.parse(stdout) as { status?: string }[];
status = parsed[0]?.status;
} catch {
// Non-JSON output — keep polling
}

if (status === "success") return;
if (status === "failed" || status === "canceled") {
throw new Error(`GitLab pipeline for ${args.name} ended with status: ${status}`);
}

await new Promise((r) => setTimeout(r, interval));
}
}
41 changes: 41 additions & 0 deletions lexicons/temporal/src/op/activities/helm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Context } from "@temporalio/activity";

const execAsync = promisify(exec);

export interface HelmInstallArgs {
/** Helm release name. */
name: string;
/** Chart reference (local path or `repo/chart`). */
chart: string;
/** Path to a values file. */
values?: string;
/** Kubernetes namespace. */
namespace?: string;
/** Additional --set arguments. */
set?: Record<string, string>;
}

/**
* Run `helm upgrade --install <name> <chart>`.
* Uses longInfra profile — 20m timeout, heartbeat every 60s.
*/
export async function helmInstall(args: HelmInstallArgs): Promise<void> {
const parts = ["helm", "upgrade", "--install", "--wait", args.name, args.chart];
if (args.namespace) parts.push("--namespace", args.namespace, "--create-namespace");
if (args.values) parts.push("-f", args.values);
for (const [k, v] of Object.entries(args.set ?? {})) parts.push("--set", `${k}=${v}`);

const heartbeatInterval = setInterval(() => {
Context.current().heartbeat({ step: "helm install", release: args.name });
}, 15_000);

try {
const { stdout, stderr } = await execAsync(parts.join(" "));
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
} finally {
clearInterval(heartbeatInterval);
}
}
23 changes: 23 additions & 0 deletions lexicons/temporal/src/op/activities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export { chantBuild } from "./build";
export type { ChantBuildArgs } from "./build";

export { kubectlApply } from "./kubectl";
export type { KubectlApplyArgs } from "./kubectl";

export { helmInstall } from "./helm";
export type { HelmInstallArgs } from "./helm";

export { waitForStack } from "./wait";
export type { WaitForStackArgs } from "./wait";

export { gitlabPipeline } from "./gitlab";
export type { GitlabPipelineArgs } from "./gitlab";

export { shellCmd } from "./shell";
export type { ShellCmdArgs } from "./shell";

export { stateSnapshot } from "./state";
export type { StateSnapshotArgs } from "./state";

export { chantTeardown } from "./teardown";
export type { ChantTeardownArgs } from "./teardown";
32 changes: 32 additions & 0 deletions lexicons/temporal/src/op/activities/kubectl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Context } from "@temporalio/activity";

const execAsync = promisify(exec);

export interface KubectlApplyArgs {
manifest: string;
/** kubectl context name. Uses current context if omitted. */
context?: string;
}

/**
* Run `kubectl apply -f <manifest>`.
* Uses longInfra profile — 20m timeout, heartbeat every 60s.
*/
export async function kubectlApply(args: KubectlApplyArgs): Promise<void> {
const ctx = args.context ? `--context ${args.context}` : "";
const heartbeatInterval = setInterval(() => {
Context.current().heartbeat({ step: "kubectl apply", manifest: args.manifest });
}, 15_000);

try {
const { stdout, stderr } = await execAsync(
`kubectl apply -f ${args.manifest} ${ctx} --wait=true`,
);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
} finally {
clearInterval(heartbeatInterval);
}
}
25 changes: 25 additions & 0 deletions lexicons/temporal/src/op/activities/shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

export interface ShellCmdArgs {
cmd: string;
/** Additional environment variables. */
env?: Record<string, string>;
/** Working directory. Default: process.cwd(). */
cwd?: string;
}

/**
* Run an arbitrary shell command.
* Uses fastIdempotent profile — 5m timeout, 3 retries.
*/
export async function shellCmd(args: ShellCmdArgs): Promise<string> {
const { stdout, stderr } = await execAsync(args.cmd, {
cwd: args.cwd,
env: { ...process.env, ...args.env },
});
if (stderr) console.error(stderr);
return stdout.trim();
}
19 changes: 19 additions & 0 deletions lexicons/temporal/src/op/activities/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

export interface StateSnapshotArgs {
/** Environment name (e.g. "dev", "staging", "prod"). */
env: string;
}

/**
* Take a chant state snapshot for the given environment.
* Uses fastIdempotent profile — 5m timeout, 3 retries.
*/
export async function stateSnapshot(args: StateSnapshotArgs): Promise<void> {
const { stdout, stderr } = await execAsync(`chant state snapshot ${args.env}`);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
}
21 changes: 21 additions & 0 deletions lexicons/temporal/src/op/activities/teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

export interface ChantTeardownArgs {
/** Path to the chant project to tear down. */
path: string;
}

/**
* Run `chant teardown` in the given project path.
* Uses longInfra profile — 20m timeout, heartbeat every 60s.
*/
export async function chantTeardown(args: ChantTeardownArgs): Promise<void> {
const { stdout, stderr } = await execAsync("npm run teardown", {
cwd: args.path,
});
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
}
52 changes: 52 additions & 0 deletions lexicons/temporal/src/op/activities/wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Context } from "@temporalio/activity";

const execAsync = promisify(exec);

export interface WaitForStackArgs {
/** Stack name — used to locate the kubectl deployment/statefulset to poll. */
name: string;
/** Kubernetes namespace. */
namespace?: string;
/** kubectl context. */
context?: string;
/** Poll interval in ms. Default: 10000. */
intervalMs?: number;
}

/**
* Poll until a Kubernetes Deployment or StatefulSet named `name` is fully rolled out.
* Uses k8sWait profile — 15m timeout, heartbeat every 60s.
*/
export async function waitForStack(args: WaitForStackArgs): Promise<void> {
const ns = args.namespace ? `-n ${args.namespace}` : "";
const ctx = args.context ? `--context ${args.context}` : "";
const interval = args.intervalMs ?? 10_000;
let attempt = 0;

while (true) {
attempt++;
Context.current().heartbeat({ step: "waitForStack", stack: args.name, attempt });

try {
await execAsync(
`kubectl rollout status deployment/${args.name} ${ns} ${ctx} --timeout=30s`,
);
return;
} catch {
// Not ready yet — wait and retry
}

try {
await execAsync(
`kubectl rollout status statefulset/${args.name} ${ns} ${ctx} --timeout=30s`,
);
return;
} catch {
// Not ready yet
}

await new Promise((r) => setTimeout(r, interval));
}
}
Loading
Loading