@oisincoveney/pipeline 3.21.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ import { runCreateExperiment } from "../factory/create-experiment.js";
2
+ import { runTemplateUpdate, summarizeTemplateUpdate } from "../factory/template-update.js";
3
+ import { Option } from "commander";
4
+ //#region src/cli/factory-commands.ts
5
+ function registerFactoryCommands(program) {
6
+ program.command("create-experiment").description("Birth a fleet experiment: copier-stamp momokaya-template, create+push the org repo, register it in infra's fleet registry").requiredOption("--name <name>", "app name (kebab-case)").addOption(new Option("--flavor <flavor>", "app flavor").choices(["web", "expo-web"]).default("web")).option("--no-db", "skip the database surface").option("--no-previews", "skip per-PR preview environments").option("--org <org>", "GitHub org for the new repo").option("--template-src <source>", "copier template source").option("--template-ref <ref>", "template tag/ref (default: latest tag)").option("--infra-repo-url <url>", "infra repo the registry entry lands in").action(async (flags) => {
7
+ const result = await runCreateExperiment({
8
+ db: flags.db,
9
+ flavor: flags.flavor,
10
+ ...flags.infraRepoUrl ? { infraRepoUrl: flags.infraRepoUrl } : {},
11
+ name: flags.name,
12
+ ...flags.org ? { org: flags.org } : {},
13
+ previews: flags.previews,
14
+ ...flags.templateRef ? { templateRef: flags.templateRef } : {},
15
+ ...flags.templateSrc ? { templateSource: flags.templateSrc } : {}
16
+ });
17
+ console.log(`Experiment born: ${result.repoUrl} (registry ${result.registryPath} @ infra ${result.infraCommitSha})`);
18
+ });
19
+ program.command("template-update").description("Fan copier-update PRs out across repos stamped from momokaya-template").option("--repos <repos>", "comma-separated repo list (skips fleet-registry discovery)").option("--org <org>", "GitHub org the stamped repos live in").option("--template-match <substring>", "answers-file _src_path filter for stamp detection").option("--template-ref <ref>", "template tag/ref (default: latest tag)").option("--infra-repo-url <url>", "infra repo used for discovery").action(async (flags) => {
20
+ const { results } = await runTemplateUpdate({
21
+ ...flags.infraRepoUrl ? { infraRepoUrl: flags.infraRepoUrl } : {},
22
+ ...flags.org ? { org: flags.org } : {},
23
+ ...flags.repos ? { repos: flags.repos.split(",").map((repo) => repo.trim()).filter((repo) => repo.length > 0) } : {},
24
+ ...flags.templateMatch ? { templateMatch: flags.templateMatch } : {},
25
+ ...flags.templateRef ? { templateRef: flags.templateRef } : {}
26
+ });
27
+ const { failed, opened } = summarizeTemplateUpdate(results);
28
+ console.log(`template-update: ${opened} PR(s) opened, ${failed} error(s)`);
29
+ if (failed > 0) process.exitCode = 1;
30
+ });
31
+ }
32
+ //#endregion
33
+ export { registerFactoryCommands };
@@ -7,6 +7,7 @@ import { registerTicketCommand } from "../commands/ticket-command.js";
7
7
  import { addMokaSubmitOptions, runMokaSubmitFromCli } from "./submit-options.js";
8
8
  import { registerRunControlCommands } from "../run-control/commands.js";
9
9
  import { registerBootstrapCommands } from "./bootstrap-commands.js";
10
+ import { registerFactoryCommands } from "./factory-commands.js";
10
11
  import { registerLoopCommand } from "./loop-commands.js";
11
12
  import { registerMcpGatewayCommands } from "./mcp-gateway-commands.js";
12
13
  import { registerPlanCommands } from "./plan-commands.js";
@@ -34,6 +35,7 @@ function registerApplicationCommands(program, options) {
34
35
  registerMcpGatewayCommands(program);
35
36
  registerSubmitCommand(program);
36
37
  registerLoopCommand(program);
38
+ registerFactoryCommands(program);
37
39
  registerRunnerCommandCommand(program);
38
40
  registerBenchCommand(program);
39
41
  registerTicketCommand(program, {
@@ -10,7 +10,9 @@ const BUILTIN_PIPE_COMMANDS = /* @__PURE__ */ new Set([
10
10
  "submit",
11
11
  "argo",
12
12
  "runner-command",
13
- "ticket"
13
+ "ticket",
14
+ "create-experiment",
15
+ "template-update"
14
16
  ]);
15
17
  var EntrypointCommandService = class extends Context.Service()("EntrypointCommandService") {};
16
18
  const createEntrypointCommandServiceLive = (runEntrypoint) => Layer.succeed(EntrypointCommandService, { runEntrypoint: (entrypoint, task, opts) => Effect.tryPromise({
@@ -0,0 +1,163 @@
1
+ import { DEFAULT_RUNNER_COMMAND_GIT_COMMITTER } from "../config/schema/catalog.js";
2
+ import { resolveFactorySeams } from "./exec.js";
3
+ import { existsSync, mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { copyFile, mkdir } from "node:fs/promises";
7
+ //#region src/factory/create-experiment.ts
8
+ /**
9
+ * create-experiment lane (INFRA-087.12): one deterministic run births a
10
+ * deployable fleet experiment.
11
+ *
12
+ * 1. headless `copier copy` stamps the app tree from momokaya-template;
13
+ * 2. `gh repo create <org>/<name> --private` + authenticated push publish it;
14
+ * 3. the stamped `infra-registry/config/<name>.yaml` is committed to the
15
+ * infra repo's fleet registry (`k8s/apps/platform-fleet/config/`), where
16
+ * the platform-fleet chart renders its previews ApplicationSet
17
+ * (`lifecycle: experiment` renders previews only, no prod Application).
18
+ *
19
+ * Ordered data-driven steps; each failure surfaces with the step name. No
20
+ * automatic rollback: a partially-born experiment is reported, and retirement
21
+ * (registry `lifecycle: retired` + repo deletion) is the documented cleanup.
22
+ */
23
+ const EXPERIMENT_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
24
+ const DEFAULT_ORG = "oisin-ee";
25
+ const DEFAULT_TEMPLATE_SOURCE = "gh:oisin-ee/momokaya-template";
26
+ const DEFAULT_INFRA_REPO_URL = "https://github.com/oisin-ee/infra.git";
27
+ const FLEET_REGISTRY_DIR = "k8s/apps/platform-fleet/config";
28
+ const STAMPED_REGISTRY_DIR = "infra-registry/config";
29
+ function buildCopierCopyArgs(options) {
30
+ return [
31
+ "copy",
32
+ "--trust",
33
+ "--defaults",
34
+ ...options.templateRef ? ["--vcs-ref", options.templateRef] : [],
35
+ "--data",
36
+ `name=${options.name}`,
37
+ "--data",
38
+ `flavor=${options.flavor}`,
39
+ "--data",
40
+ `db=${options.db}`,
41
+ "--data",
42
+ `previews=${options.previews}`,
43
+ options.templateSource,
44
+ options.destination
45
+ ];
46
+ }
47
+ function committerConfigArgs() {
48
+ return [
49
+ "-c",
50
+ `user.name=${DEFAULT_RUNNER_COMMAND_GIT_COMMITTER.name}`,
51
+ "-c",
52
+ `user.email=${DEFAULT_RUNNER_COMMAND_GIT_COMMITTER.email}`
53
+ ];
54
+ }
55
+ async function runCreateExperiment(options) {
56
+ const { exec, git, log } = resolveFactorySeams(options);
57
+ const name = options.name;
58
+ if (!EXPERIMENT_NAME_PATTERN.test(name)) throw new Error(`create-experiment: name must be kebab-case (got ${JSON.stringify(name)})`);
59
+ const org = options.org ?? DEFAULT_ORG;
60
+ const flavor = options.flavor ?? "web";
61
+ const db = options.db ?? true;
62
+ const previews = options.previews ?? true;
63
+ const templateSource = options.templateSource ?? DEFAULT_TEMPLATE_SOURCE;
64
+ const infraRepoUrl = options.infraRepoUrl ?? DEFAULT_INFRA_REPO_URL;
65
+ const workRoot = options.workRoot ?? mkdtempSync(join(tmpdir(), "create-experiment-"));
66
+ const stampDir = resolve(workRoot, name);
67
+ const repoUrl = `https://github.com/${org}/${name}`;
68
+ log(`create-experiment: birthing ${org}/${name} (flavor=${flavor} db=${db} previews=${previews})`);
69
+ await assertRepoAbsent({
70
+ exec,
71
+ name,
72
+ org
73
+ });
74
+ log(`create-experiment: stamping ${templateSource} -> ${stampDir}`);
75
+ await exec("copier", buildCopierCopyArgs({
76
+ db,
77
+ destination: stampDir,
78
+ flavor,
79
+ name,
80
+ previews,
81
+ ...options.templateRef ? { templateRef: options.templateRef } : {},
82
+ templateSource
83
+ }));
84
+ const stampedRegistryEntry = join(stampDir, STAMPED_REGISTRY_DIR, `${name}.yaml`);
85
+ if (!existsSync(stampedRegistryEntry)) throw new Error(`create-experiment: stamp is missing the registry entry ${STAMPED_REGISTRY_DIR}/${name}.yaml — template contract changed?`);
86
+ log("create-experiment: committing the stamped tree");
87
+ await git(stampDir, ["init", "--initial-branch=main"]);
88
+ await git(stampDir, ["add", "--all"]);
89
+ await git(stampDir, [
90
+ ...committerConfigArgs(),
91
+ "commit",
92
+ "-m",
93
+ `feat: initial stamp from ${templateSource}`
94
+ ]);
95
+ log(`create-experiment: creating ${repoUrl} (private)`);
96
+ await exec("gh", [
97
+ "repo",
98
+ "create",
99
+ `${org}/${name}`,
100
+ "--private"
101
+ ]);
102
+ await git(stampDir, [
103
+ "remote",
104
+ "add",
105
+ "origin",
106
+ `${repoUrl}.git`
107
+ ]);
108
+ await git(stampDir, [
109
+ "push",
110
+ "-u",
111
+ "origin",
112
+ "main"
113
+ ]);
114
+ log(`create-experiment: registering ${name} in the fleet registry`);
115
+ const infraDir = resolve(workRoot, "infra");
116
+ await git(workRoot, [
117
+ "clone",
118
+ "--depth",
119
+ "1",
120
+ "--single-branch",
121
+ infraRepoUrl,
122
+ infraDir
123
+ ]);
124
+ const registryPath = `${FLEET_REGISTRY_DIR}/${name}.yaml`;
125
+ await mkdir(join(infraDir, FLEET_REGISTRY_DIR), { recursive: true });
126
+ await copyFile(stampedRegistryEntry, join(infraDir, registryPath));
127
+ await git(infraDir, [
128
+ "add",
129
+ "--",
130
+ registryPath
131
+ ]);
132
+ await git(infraDir, [
133
+ ...committerConfigArgs(),
134
+ "commit",
135
+ "-m",
136
+ `feat(fleet): register experiment ${name} (create-experiment lane)`
137
+ ]);
138
+ await git(infraDir, [
139
+ "push",
140
+ "origin",
141
+ "HEAD:main"
142
+ ]);
143
+ const infraCommitSha = (await git(infraDir, ["rev-parse", "HEAD"])).trim();
144
+ log(`create-experiment: done — repo=${repoUrl} registry=${registryPath} infraCommit=${infraCommitSha}`);
145
+ return {
146
+ infraCommitSha,
147
+ registryPath,
148
+ repoUrl,
149
+ stampDir
150
+ };
151
+ }
152
+ async function assertRepoAbsent(input) {
153
+ const slug = `${input.org}/${input.name}`;
154
+ if (await input.exec("gh", [
155
+ "repo",
156
+ "view",
157
+ slug,
158
+ "--json",
159
+ "name"
160
+ ]).then(() => true).catch(() => false)) throw new Error(`create-experiment: repo ${slug} already exists — pick another name or retire the old experiment first`);
161
+ }
162
+ //#endregion
163
+ export { EXPERIMENT_NAME_PATTERN, buildCopierCopyArgs, committerConfigArgs, runCreateExperiment };
@@ -0,0 +1,17 @@
1
+ import { runAuthenticatedGit } from "../run-state/git-refs.js";
2
+ import { execa } from "execa";
3
+ //#region src/factory/exec.ts
4
+ const defaultFactoryExec = (command, args, options) => execa(command, [...args], {
5
+ ...options?.cwd ? { cwd: options.cwd } : {},
6
+ stdin: "ignore"
7
+ });
8
+ const defaultFactoryGit = (cwd, args) => runAuthenticatedGit(cwd, args);
9
+ function resolveFactorySeams(seams = {}) {
10
+ return {
11
+ exec: seams.exec ?? defaultFactoryExec,
12
+ git: seams.git ?? defaultFactoryGit,
13
+ log: seams.log ?? ((line) => console.log(line))
14
+ };
15
+ }
16
+ //#endregion
17
+ export { defaultFactoryExec, defaultFactoryGit, resolveFactorySeams };
@@ -0,0 +1,28 @@
1
+ import { parse } from "yaml";
2
+ import { z } from "zod";
3
+ //#region src/factory/stamp-answers.ts
4
+ /**
5
+ * `.copier-answers.yml` is copier's stamp receipt: `_src_path` records the
6
+ * template a repo was generated from and `_commit` the template version.
7
+ *
8
+ * The org has MULTIPLE copier templates (e.g. the @oisincoveney/dev scaffold
9
+ * also writes `.copier-answers.yml`), so the marker file alone does NOT mean
10
+ * "momokaya-template stamp" — template-update must filter on `_src_path`
11
+ * before fanning a `copier update` PR out to a repo.
12
+ */
13
+ const copierAnswersSchema = z.object({
14
+ _commit: z.string().optional(),
15
+ _src_path: z.string().optional()
16
+ }).passthrough();
17
+ function parseCopierAnswers(source) {
18
+ const parsed = copierAnswersSchema.parse(parse(source));
19
+ return {
20
+ ...parsed._commit === void 0 ? {} : { commit: parsed._commit },
21
+ ...parsed._src_path === void 0 ? {} : { srcPath: parsed._src_path }
22
+ };
23
+ }
24
+ function isStampOf(receipt, templateMatch) {
25
+ return receipt.srcPath?.includes(templateMatch) ?? false;
26
+ }
27
+ //#endregion
28
+ export { isStampOf, parseCopierAnswers };
@@ -0,0 +1,167 @@
1
+ import { resolveFactorySeams } from "./exec.js";
2
+ import { committerConfigArgs } from "./create-experiment.js";
3
+ import { isStampOf, parseCopierAnswers } from "./stamp-answers.js";
4
+ import { parse } from "yaml";
5
+ import { z } from "zod";
6
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { basename, join, resolve } from "node:path";
9
+ //#region src/factory/template-update.ts
10
+ /**
11
+ * template-update lane (INFRA-087.12): when momokaya-template ships a new tag,
12
+ * fan `copier update` PRs out across every repo stamped from it — one PR per
13
+ * repo, never a direct push to an app repo's default branch.
14
+ *
15
+ * Discovery = fleet registry entries (the infra repo's
16
+ * `k8s/apps/platform-fleet/config/*.yaml` → `repo:`) probed for a
17
+ * `.copier-answers.yml` whose `_src_path` matches the template (the marker
18
+ * alone is ambiguous across the org's copier templates — see
19
+ * stamp-answers.ts).
20
+ */
21
+ const DEFAULT_ORG = "oisin-ee";
22
+ const DEFAULT_TEMPLATE_MATCH = "momokaya-template";
23
+ const DEFAULT_INFRA_REPO_URL = "https://github.com/oisin-ee/infra.git";
24
+ const FLEET_REGISTRY_DIR = "k8s/apps/platform-fleet/config";
25
+ const ANSWERS_FILE = ".copier-answers.yml";
26
+ const registryEntryRepoSchema = z.object({ repo: z.string() }).passthrough();
27
+ function summarizeTemplateUpdate(results) {
28
+ return {
29
+ failed: results.filter((entry) => entry.status === "error").length,
30
+ opened: results.filter((entry) => entry.status === "pr-opened").length
31
+ };
32
+ }
33
+ async function runTemplateUpdate(options) {
34
+ const seams = resolveFactorySeams(options);
35
+ const { log } = seams;
36
+ const org = options.org ?? DEFAULT_ORG;
37
+ const templateMatch = options.templateMatch ?? DEFAULT_TEMPLATE_MATCH;
38
+ const workRoot = options.workRoot ?? mkdtempSync(join(tmpdir(), "template-update-"));
39
+ const repos = options.repos && options.repos.length > 0 ? [...options.repos] : await discoverRegistryRepos({
40
+ git: seams.git,
41
+ infraRepoUrl: options.infraRepoUrl ?? DEFAULT_INFRA_REPO_URL,
42
+ workRoot
43
+ });
44
+ log(`template-update: candidates [${repos.join(", ")}]`);
45
+ const results = [];
46
+ for (const repo of repos) results.push(await updateRepo({
47
+ org,
48
+ repo,
49
+ seams,
50
+ ...options.templateRef ? { templateRef: options.templateRef } : {},
51
+ templateMatch,
52
+ workRoot
53
+ }));
54
+ for (const entry of results) log(`template-update: ${entry.repo} -> ${entry.status}${entry.prUrl ? ` ${entry.prUrl}` : ""}${entry.message ? ` (${entry.message})` : ""}`);
55
+ return { results };
56
+ }
57
+ async function discoverRegistryRepos(input) {
58
+ const infraDir = resolve(input.workRoot, "infra-discovery");
59
+ await input.git(input.workRoot, [
60
+ "clone",
61
+ "--depth",
62
+ "1",
63
+ "--single-branch",
64
+ input.infraRepoUrl,
65
+ infraDir
66
+ ]);
67
+ const registryDir = join(infraDir, FLEET_REGISTRY_DIR);
68
+ const repos = readdirSync(registryDir).filter((file) => file.endsWith(".yaml")).map((file) => {
69
+ return registryEntryRepoSchema.parse(parse(readFileSync(join(registryDir, file), "utf8"))).repo;
70
+ });
71
+ return [...new Set(repos)].sort((left, right) => left.localeCompare(right));
72
+ }
73
+ async function updateRepo(input) {
74
+ const { exec, git } = input.seams;
75
+ const { repo } = input;
76
+ try {
77
+ const cloneDir = resolve(input.workRoot, `update-${repo}`);
78
+ await git(input.workRoot, [
79
+ "clone",
80
+ `https://github.com/${input.org}/${repo}.git`,
81
+ cloneDir
82
+ ]);
83
+ const answersPath = join(cloneDir, ANSWERS_FILE);
84
+ if (!existsSync(answersPath)) return {
85
+ repo,
86
+ status: "not-stamped"
87
+ };
88
+ const receipt = parseCopierAnswers(readFileSync(answersPath, "utf8"));
89
+ if (!isStampOf(receipt, input.templateMatch)) return {
90
+ message: `stamped from ${receipt.srcPath ?? "unknown"}, not ${input.templateMatch}`,
91
+ repo,
92
+ status: "not-stamped"
93
+ };
94
+ await git(cloneDir, [
95
+ "checkout",
96
+ "-b",
97
+ "template-update/pending"
98
+ ]);
99
+ await exec("copier", [
100
+ "update",
101
+ "--trust",
102
+ "--defaults",
103
+ ...input.templateRef ? ["--vcs-ref", input.templateRef] : []
104
+ ], { cwd: cloneDir });
105
+ const status = await git(cloneDir, ["status", "--porcelain"]);
106
+ if (status.trim().length === 0) return {
107
+ repo,
108
+ status: "up-to-date",
109
+ ...receipt.commit === void 0 ? {} : { version: receipt.commit }
110
+ };
111
+ const version = parseCopierAnswers(readFileSync(answersPath, "utf8")).commit ?? "unknown";
112
+ const branch = `chore/template-update-${version}`;
113
+ const rejects = listRejectFiles(cloneDir, status);
114
+ await git(cloneDir, [
115
+ "branch",
116
+ "-m",
117
+ branch
118
+ ]);
119
+ await git(cloneDir, ["add", "--all"]);
120
+ await git(cloneDir, [
121
+ ...committerConfigArgs(),
122
+ "commit",
123
+ "-m",
124
+ `chore: copier update to ${version}`
125
+ ]);
126
+ await git(cloneDir, [
127
+ "push",
128
+ "-u",
129
+ "origin",
130
+ branch
131
+ ]);
132
+ const prBody = [`Automated \`copier update\` to momokaya-template ${version} (template-update lane).`, ...rejects.length > 0 ? [
133
+ "",
134
+ "WARNING — conflict rejects need manual resolution:",
135
+ ...rejects.map((file) => `- \`${file}\``)
136
+ ] : []].join("\n");
137
+ const { stdout } = await exec("gh", [
138
+ "pr",
139
+ "create",
140
+ "--repo",
141
+ `${input.org}/${repo}`,
142
+ "--head",
143
+ branch,
144
+ "--title",
145
+ `chore: copier update to ${version}`,
146
+ "--body",
147
+ prBody
148
+ ], { cwd: cloneDir });
149
+ return {
150
+ prUrl: stdout.trim(),
151
+ repo,
152
+ status: "pr-opened",
153
+ version
154
+ };
155
+ } catch (error) {
156
+ return {
157
+ message: error instanceof Error ? error.message : String(error),
158
+ repo,
159
+ status: "error"
160
+ };
161
+ }
162
+ }
163
+ function listRejectFiles(cloneDir, porcelainStatus) {
164
+ return porcelainStatus.split("\n").map((line) => line.slice(3).trim()).filter((file) => file.endsWith(".rej") && existsSync(join(cloneDir, file))).map((file) => basename(file));
165
+ }
166
+ //#endregion
167
+ export { runTemplateUpdate, summarizeTemplateUpdate };
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/factory/factory-lane.d.ts
4
+ /**
5
+ * Kubernetes Job manifest builder for factory lanes (create-experiment /
6
+ * template-update). The console front door creates these Jobs directly
7
+ * (batch/v1, NOT an Argo Workflow): a lane is a single deterministic `moka`
8
+ * subcommand with no payload repo, no schedule and no node graph, so the
9
+ * runner-command DAG machinery does not apply. The runner image ENTRYPOINT is
10
+ * `entrypoint-preflight.sh moka`; the Job overrides only the container args,
11
+ * so `args: ["create-experiment", ...]` runs `moka create-experiment ...`.
12
+ *
13
+ * Credential mounts replicate the runner workflow's secret storage shapes
14
+ * (src/remote/argo/storage.ts): the git credential store dir at
15
+ * /etc/pipeline/git-credentials (consumed by runAuthenticatedGit) and the gh
16
+ * hosts.yml at /root/.config/gh/hosts.yml.
17
+ */
18
+ declare const factoryLaneJobOptionsSchema: z.ZodObject<{
19
+ activeDeadlineSeconds: z.ZodDefault<z.ZodNumber>;
20
+ argv: z.ZodArray<z.ZodString>;
21
+ generateName: z.ZodDefault<z.ZodString>;
22
+ gitCredentialsSecretName: z.ZodString;
23
+ githubAuthSecretName: z.ZodString;
24
+ image: z.ZodString;
25
+ imagePullPolicy: z.ZodOptional<z.ZodString>;
26
+ imagePullSecretName: z.ZodOptional<z.ZodString>;
27
+ labels: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
28
+ namespace: z.ZodString;
29
+ resources: z.ZodOptional<z.ZodObject<{
30
+ limits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
31
+ requests: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
32
+ }, z.core.$strip>>;
33
+ serviceAccountName: z.ZodOptional<z.ZodString>;
34
+ ttlSecondsAfterFinished: z.ZodDefault<z.ZodNumber>;
35
+ }, z.core.$strip>;
36
+ type FactoryLaneJobOptionsInput = z.input<typeof factoryLaneJobOptionsSchema>;
37
+ type FactoryLaneJobOptions = z.output<typeof factoryLaneJobOptionsSchema>;
38
+ declare const FACTORY_LANE_LABEL = "pipeline.oisin.dev/factory-lane";
39
+ declare function buildFactoryLaneJob(input: FactoryLaneJobOptionsInput): {
40
+ apiVersion: string;
41
+ kind: string;
42
+ metadata: {
43
+ generateName: string;
44
+ labels: {
45
+ "pipeline.oisin.dev/factory-lane": string;
46
+ };
47
+ namespace: string;
48
+ };
49
+ spec: {
50
+ activeDeadlineSeconds: number;
51
+ backoffLimit: number;
52
+ template: {
53
+ metadata: {
54
+ labels: {
55
+ "pipeline.oisin.dev/factory-lane": string;
56
+ };
57
+ };
58
+ spec: {
59
+ containers: {
60
+ args: string[];
61
+ image: string;
62
+ imagePullPolicy?: string | undefined;
63
+ name: string;
64
+ resources?: {
65
+ limits?: Record<string, string> | undefined;
66
+ requests?: Record<string, string> | undefined;
67
+ } | undefined;
68
+ volumeMounts: ({
69
+ mountPath: string;
70
+ name: string;
71
+ readOnly: boolean;
72
+ subPath?: undefined;
73
+ } | {
74
+ mountPath: string;
75
+ name: string;
76
+ readOnly: boolean;
77
+ subPath: string;
78
+ })[];
79
+ }[];
80
+ imagePullSecrets?: {
81
+ name: string;
82
+ }[] | undefined;
83
+ restartPolicy: string;
84
+ serviceAccountName?: string | undefined;
85
+ volumes: ({
86
+ name: string;
87
+ secret: {
88
+ defaultMode: number;
89
+ secretName: string;
90
+ items?: undefined;
91
+ };
92
+ } | {
93
+ name: string;
94
+ secret: {
95
+ defaultMode?: undefined;
96
+ items: {
97
+ key: string;
98
+ path: string;
99
+ }[];
100
+ secretName: string;
101
+ };
102
+ })[];
103
+ };
104
+ };
105
+ };
106
+ };
107
+ //#endregion
108
+ export { FACTORY_LANE_LABEL, FactoryLaneJobOptions, FactoryLaneJobOptionsInput, buildFactoryLaneJob, factoryLaneJobOptionsSchema };
@@ -0,0 +1,101 @@
1
+ import { z } from "zod";
2
+ //#region src/factory/factory-lane.ts
3
+ /**
4
+ * Kubernetes Job manifest builder for factory lanes (create-experiment /
5
+ * template-update). The console front door creates these Jobs directly
6
+ * (batch/v1, NOT an Argo Workflow): a lane is a single deterministic `moka`
7
+ * subcommand with no payload repo, no schedule and no node graph, so the
8
+ * runner-command DAG machinery does not apply. The runner image ENTRYPOINT is
9
+ * `entrypoint-preflight.sh moka`; the Job overrides only the container args,
10
+ * so `args: ["create-experiment", ...]` runs `moka create-experiment ...`.
11
+ *
12
+ * Credential mounts replicate the runner workflow's secret storage shapes
13
+ * (src/remote/argo/storage.ts): the git credential store dir at
14
+ * /etc/pipeline/git-credentials (consumed by runAuthenticatedGit) and the gh
15
+ * hosts.yml at /root/.config/gh/hosts.yml.
16
+ */
17
+ const factoryLaneJobOptionsSchema = z.object({
18
+ activeDeadlineSeconds: z.number().int().positive().default(1800),
19
+ argv: z.array(z.string().min(1)).min(1),
20
+ generateName: z.string().min(1).default("moka-factory-"),
21
+ gitCredentialsSecretName: z.string().min(1),
22
+ githubAuthSecretName: z.string().min(1),
23
+ image: z.string().min(1),
24
+ imagePullPolicy: z.string().min(1).optional(),
25
+ imagePullSecretName: z.string().min(1).optional(),
26
+ labels: z.record(z.string(), z.string()).default({}),
27
+ namespace: z.string().min(1),
28
+ resources: z.object({
29
+ limits: z.record(z.string(), z.string()).optional(),
30
+ requests: z.record(z.string(), z.string()).optional()
31
+ }).optional(),
32
+ serviceAccountName: z.string().min(1).optional(),
33
+ ttlSecondsAfterFinished: z.number().int().positive().default(86400)
34
+ });
35
+ const FACTORY_LANE_LABEL = "pipeline.oisin.dev/factory-lane";
36
+ function buildFactoryLaneJob(input) {
37
+ const options = factoryLaneJobOptionsSchema.parse(input);
38
+ const lane = options.argv[0] ?? "unknown";
39
+ return {
40
+ apiVersion: "batch/v1",
41
+ kind: "Job",
42
+ metadata: {
43
+ generateName: options.generateName,
44
+ labels: {
45
+ [FACTORY_LANE_LABEL]: lane,
46
+ ...options.labels
47
+ },
48
+ namespace: options.namespace
49
+ },
50
+ spec: {
51
+ activeDeadlineSeconds: options.activeDeadlineSeconds,
52
+ backoffLimit: 0,
53
+ template: {
54
+ metadata: { labels: {
55
+ [FACTORY_LANE_LABEL]: lane,
56
+ ...options.labels
57
+ } },
58
+ spec: {
59
+ containers: [{
60
+ args: [...options.argv],
61
+ image: options.image,
62
+ ...options.imagePullPolicy ? { imagePullPolicy: options.imagePullPolicy } : {},
63
+ name: "lane",
64
+ ...options.resources ? { resources: options.resources } : {},
65
+ volumeMounts: [{
66
+ mountPath: "/etc/pipeline/git-credentials",
67
+ name: "runner-git-credentials",
68
+ readOnly: true
69
+ }, {
70
+ mountPath: "/root/.config/gh/hosts.yml",
71
+ name: "github-auth",
72
+ readOnly: true,
73
+ subPath: "hosts.yml"
74
+ }]
75
+ }],
76
+ ...options.imagePullSecretName ? { imagePullSecrets: [{ name: options.imagePullSecretName }] } : {},
77
+ restartPolicy: "Never",
78
+ ...options.serviceAccountName ? { serviceAccountName: options.serviceAccountName } : {},
79
+ volumes: [{
80
+ name: "runner-git-credentials",
81
+ secret: {
82
+ defaultMode: 256,
83
+ secretName: options.gitCredentialsSecretName
84
+ }
85
+ }, {
86
+ name: "github-auth",
87
+ secret: {
88
+ items: [{
89
+ key: "hosts.yml",
90
+ path: "hosts.yml"
91
+ }],
92
+ secretName: options.githubAuthSecretName
93
+ }
94
+ }]
95
+ }
96
+ }
97
+ }
98
+ };
99
+ }
100
+ //#endregion
101
+ export { FACTORY_LANE_LABEL, buildFactoryLaneJob, factoryLaneJobOptionsSchema };
package/package.json CHANGED
@@ -81,6 +81,10 @@
81
81
  "types": "./dist/config.d.ts",
82
82
  "import": "./dist/config.js"
83
83
  },
84
+ "./factory-lane": {
85
+ "types": "./dist/factory-lane.d.ts",
86
+ "import": "./dist/factory-lane.js"
87
+ },
84
88
  "./events": {
85
89
  "types": "./dist/runner-event-schema.d.ts",
86
90
  "import": "./dist/runner-event-schema.js"
@@ -138,7 +142,7 @@
138
142
  "prepack": "nub run build:cli"
139
143
  },
140
144
  "type": "module",
141
- "version": "3.21.0",
145
+ "version": "3.22.0",
142
146
  "description": "Config-driven multi-agent pipeline runner for repository work",
143
147
  "main": "./dist/index.js",
144
148
  "types": "./dist/index.d.ts",