@a5c-ai/krate 5.0.1-staging.00fa5317c
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.
- package/Dockerfile +31 -0
- package/README.md +183 -0
- package/bin/krate-demo.mjs +23 -0
- package/bin/krate-server.mjs +14 -0
- package/dist/krate-controller-ui.json +3205 -0
- package/dist/krate-lifecycle.json +201 -0
- package/dist/krate-runtime-snapshot.json +3125 -0
- package/dist/krate-summary.json +724 -0
- package/docs/README.md +61 -0
- package/docs/agents/README.md +83 -0
- package/docs/agents/acceptance-test-matrix.md +193 -0
- package/docs/agents/agent-mux-adapter-contract.md +167 -0
- package/docs/agents/agent-mux-source-map.md +310 -0
- package/docs/agents/agent-run-memory-import-spec.md +256 -0
- package/docs/agents/agent-stack-management-spec.md +421 -0
- package/docs/agents/api-contract-spec.md +309 -0
- package/docs/agents/artifacts-writeback-spec.md +145 -0
- package/docs/agents/chart-packaging-spec.md +128 -0
- package/docs/agents/ci-orchestration-spec.md +140 -0
- package/docs/agents/context-assembly-spec.md +219 -0
- package/docs/agents/controller-reconciliation-spec.md +255 -0
- package/docs/agents/crd-schema-spec.md +315 -0
- package/docs/agents/decision-log-open-questions.md +169 -0
- package/docs/agents/developer-implementation-checklist.md +329 -0
- package/docs/agents/dispatching-design.md +262 -0
- package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
- package/docs/agents/glossary.md +66 -0
- package/docs/agents/implementation-blueprint.md +324 -0
- package/docs/agents/implementation-rollout-slices.md +251 -0
- package/docs/agents/memory-context-integration-spec.md +194 -0
- package/docs/agents/memory-ontology-schema-spec.md +253 -0
- package/docs/agents/memory-operations-runbook.md +121 -0
- package/docs/agents/mvp-vertical-slice-spec.md +146 -0
- package/docs/agents/observability-audit-spec.md +265 -0
- package/docs/agents/operator-runbook.md +174 -0
- package/docs/agents/org-memory-api-payload-examples.md +333 -0
- package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
- package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
- package/docs/agents/org-memory-ui-implementation-map.md +114 -0
- package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
- package/docs/agents/org-resource-model-delta-spec.md +111 -0
- package/docs/agents/org-route-resource-model-spec.md +183 -0
- package/docs/agents/org-scoping-namespace-spec.md +114 -0
- package/docs/agents/rbac-secrets-management-spec.md +406 -0
- package/docs/agents/repository-page-integration-spec.md +255 -0
- package/docs/agents/resource-contract-examples.md +808 -0
- package/docs/agents/resource-relationship-map.md +190 -0
- package/docs/agents/security-threat-model.md +188 -0
- package/docs/agents/shared-memory-company-brain-spec.md +358 -0
- package/docs/agents/storage-migration-spec.md +168 -0
- package/docs/agents/subagent-orchestration-spec.md +152 -0
- package/docs/agents/system-overview.md +88 -0
- package/docs/agents/tools-mcp-skills-spec.md +189 -0
- package/docs/agents/traceability-matrix.md +79 -0
- package/docs/agents/ui-flow-spec.md +211 -0
- package/docs/agents/ui-ux-system-spec.md +426 -0
- package/docs/agents/workspace-lifecycle-spec.md +166 -0
- package/docs/architecture-spec.md +78 -0
- package/docs/components/control-plane.md +78 -0
- package/docs/components/data-plane.md +69 -0
- package/docs/components/hooks-events.md +67 -0
- package/docs/components/identity-rbac-policy.md +73 -0
- package/docs/components/kubevela-oam.md +70 -0
- package/docs/components/operations-publishing.md +81 -0
- package/docs/components/runners-ci.md +66 -0
- package/docs/components/web-ui.md +94 -0
- package/docs/external/README.md +47 -0
- package/docs/external/bidirectional-sync-design.md +134 -0
- package/docs/external/cicd-interface.md +64 -0
- package/docs/external/external-backend-controllers.md +170 -0
- package/docs/external/external-backend-crds.md +234 -0
- package/docs/external/external-backend-ui-spec.md +151 -0
- package/docs/external/external-backend-ux-flows.md +115 -0
- package/docs/external/external-object-mapping.md +125 -0
- package/docs/external/git-forge-interface.md +68 -0
- package/docs/external/github-integration-design.md +151 -0
- package/docs/external/issue-tracking-interface.md +66 -0
- package/docs/external/provider-capability-manifests.md +204 -0
- package/docs/external/provider-catalog.md +139 -0
- package/docs/external/provider-rollout-testing.md +78 -0
- package/docs/external/research-results.md +48 -0
- package/docs/external/security-auth-permissions.md +81 -0
- package/docs/external/sync-state-machines.md +108 -0
- package/docs/external/unified-external-backend-model.md +107 -0
- package/docs/external/user-facing-changes.md +67 -0
- package/docs/gaps.md +161 -0
- package/docs/install.md +94 -0
- package/docs/krate-design.md +334 -0
- package/docs/local-minikube.md +55 -0
- package/docs/ontology/README.md +32 -0
- package/docs/ontology/bounded-contexts.md +29 -0
- package/docs/ontology/events-and-hooks.md +32 -0
- package/docs/ontology/oam-kubevela.md +32 -0
- package/docs/ontology/operations-and-release.md +25 -0
- package/docs/ontology/personas-and-actors.md +32 -0
- package/docs/ontology/policies-and-invariants.md +33 -0
- package/docs/ontology/problem-space.md +30 -0
- package/docs/ontology/resource-contracts.md +40 -0
- package/docs/ontology/resource-taxonomy.md +42 -0
- package/docs/ontology/runners-and-ci.md +29 -0
- package/docs/ontology/solution-space.md +24 -0
- package/docs/ontology/storage-and-data-boundaries.md +29 -0
- package/docs/ontology/validation-matrix.md +24 -0
- package/docs/ontology/web-ui-excellent-flows.md +32 -0
- package/docs/ontology/workflows.md +39 -0
- package/docs/ontology/world.md +35 -0
- package/docs/openapi.yaml +1275 -0
- package/docs/product-requirements.md +62 -0
- package/docs/roadmap-mvp.md +87 -0
- package/docs/system-requirements.md +90 -0
- package/docs/tests/README.md +53 -0
- package/docs/tests/agent-qa-plan.md +63 -0
- package/docs/tests/browser-ui-tests.md +62 -0
- package/docs/tests/ci-quality-gates.md +48 -0
- package/docs/tests/coverage-model.md +64 -0
- package/docs/tests/e2e-scenario-tests.md +53 -0
- package/docs/tests/fixtures-test-data.md +63 -0
- package/docs/tests/observability-reliability-tests.md +54 -0
- package/docs/tests/product-test-matrix.md +145 -0
- package/docs/tests/qa-adoption-roadmap.md +130 -0
- package/docs/tests/qa-automation-plan.md +101 -0
- package/docs/tests/security-compliance-tests.md +57 -0
- package/docs/tests/test-framework-tools.md +88 -0
- package/docs/tests/test-suite-layout.md +121 -0
- package/docs/tests/unit-integration-tests.md +48 -0
- package/docs/todo-kyverno +714 -0
- package/docs/todos.md +4 -0
- package/docs/user-stories.md +78 -0
- package/examples/minikube-demo.yaml +190 -0
- package/examples/oam-application.yaml +23 -0
- package/examples/policy-kyverno-pr-title.yaml +18 -0
- package/package.json +63 -0
- package/scripts/build.mjs +29 -0
- package/scripts/setup-minikube.mjs +65 -0
- package/scripts/smoke.mjs +37 -0
- package/scripts/validate-doc-coverage.mjs +152 -0
- package/scripts/validate-package.mjs +93 -0
- package/scripts/validate-ui.mjs +278 -0
- package/src/agent-adapter-controller.js +169 -0
- package/src/agent-approval-controller.js +170 -0
- package/src/agent-context-bundles.js +242 -0
- package/src/agent-dispatch-controller.js +209 -0
- package/src/agent-gateway-config-controller.js +147 -0
- package/src/agent-memory-controller.js +357 -0
- package/src/agent-memory-import.js +327 -0
- package/src/agent-memory-query.js +292 -0
- package/src/agent-memory-repository-source-controller.js +255 -0
- package/src/agent-mux-client.js +280 -0
- package/src/agent-permission-review.js +250 -0
- package/src/agent-project-controller.js +117 -0
- package/src/agent-provider-config-controller.js +150 -0
- package/src/agent-secret-config-grant-controller.js +282 -0
- package/src/agent-session-transcript-controller.js +189 -0
- package/src/agent-stack-controller.js +347 -0
- package/src/agent-subagent-controller.js +160 -0
- package/src/agent-transport-binding-controller.js +121 -0
- package/src/agent-trigger-controller.js +381 -0
- package/src/agent-workspace-controller.js +702 -0
- package/src/agent-writeback-controller.js +302 -0
- package/src/api-controller.js +541 -0
- package/src/argocd-gitops.js +43 -0
- package/src/async-controller.js +207 -0
- package/src/audit-controller.js +191 -0
- package/src/auth.js +307 -0
- package/src/component-catalog.js +41 -0
- package/src/control-plane.js +136 -0
- package/src/controller-client.js +72 -0
- package/src/controller-ui.js +617 -0
- package/src/data-plane.js +179 -0
- package/src/event-bus.js +61 -0
- package/src/external/conflict-controller.js +225 -0
- package/src/external/github/auth.js +96 -0
- package/src/external/github/cicd.js +180 -0
- package/src/external/github/git-forge.js +240 -0
- package/src/external/github/index.js +144 -0
- package/src/external/github/issue-tracking.js +163 -0
- package/src/external/provider-adapter.js +161 -0
- package/src/external/provider-resource-factory.js +161 -0
- package/src/external/sync-controller.js +235 -0
- package/src/external/webhook-controller.js +144 -0
- package/src/external/write-controller.js +283 -0
- package/src/gitea-backend.js +131 -0
- package/src/gitea-service.js +173 -0
- package/src/handoff.js +98 -0
- package/src/hooks-events.js +63 -0
- package/src/http-server.js +377 -0
- package/src/identity-policy.js +86 -0
- package/src/index.js +57 -0
- package/src/kubernetes-controller-async.js +511 -0
- package/src/kubernetes-controller.js +878 -0
- package/src/kubernetes-resource-gateway.js +48 -0
- package/src/notification-controller.js +178 -0
- package/src/operations.js +112 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +221 -0
- package/src/runner-controller.js +272 -0
- package/src/runners-ci.js +48 -0
- package/src/runtime.js +196 -0
- package/src/snapshot-cache.js +157 -0
- package/src/web-ui.js +40 -0
- package/tests/agent-adapter-controller.test.js +361 -0
- package/tests/agent-approval-controller.test.js +173 -0
- package/tests/agent-context-bundles.test.js +278 -0
- package/tests/agent-dispatch-controller.test.js +315 -0
- package/tests/agent-gateway-config-controller.test.js +386 -0
- package/tests/agent-memory-controller.test.js +308 -0
- package/tests/agent-memory-import-snapshot.test.js +477 -0
- package/tests/agent-memory-query.test.js +404 -0
- package/tests/agent-memory-repository-source.test.js +514 -0
- package/tests/agent-mux-client.test.js +204 -0
- package/tests/agent-permission-review-v2.test.js +317 -0
- package/tests/agent-permission-review.test.js +209 -0
- package/tests/agent-project-controller.test.js +302 -0
- package/tests/agent-provider-config-controller.test.js +376 -0
- package/tests/agent-resources.test.js +228 -0
- package/tests/agent-secret-config-grant.test.js +231 -0
- package/tests/agent-session-transcript-controller.test.js +499 -0
- package/tests/agent-stack-controller.test.js +221 -0
- package/tests/agent-subagent-controller.test.js +201 -0
- package/tests/agent-transport-binding-controller.test.js +294 -0
- package/tests/agent-trigger-controller.test.js +211 -0
- package/tests/agent-trigger-routes.test.js +190 -0
- package/tests/agent-trigger-sources.test.js +245 -0
- package/tests/agent-workspace-controller.test.js +181 -0
- package/tests/agent-writeback.test.js +292 -0
- package/tests/approval-persistence.test.js +171 -0
- package/tests/async-controller.test.js +252 -0
- package/tests/audit-controller.test.js +227 -0
- package/tests/codespace-controller.test.js +318 -0
- package/tests/deployment.test.js +407 -0
- package/tests/e2e/lifecycle.test.js +117 -0
- package/tests/event-bus-integration.test.js +190 -0
- package/tests/external-github-forge.test.js +560 -0
- package/tests/external-github-issues-cicd.test.js +520 -0
- package/tests/external-integration.test.js +470 -0
- package/tests/external-persistence.test.js +340 -0
- package/tests/external-provider-adapter.test.js +365 -0
- package/tests/external-resource-model.test.js +215 -0
- package/tests/external-webhook-sync.test.js +287 -0
- package/tests/external-write-conflict.test.js +353 -0
- package/tests/gitea-service.test.js +253 -0
- package/tests/health-check-real.test.js +165 -0
- package/tests/integration/full-flow.test.js +266 -0
- package/tests/krate.test.js +756 -0
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/notification-controller.test.js +196 -0
- package/tests/notification-integration.test.js +179 -0
- package/tests/org-scoping.test.js +687 -0
- package/tests/runner-controller.test.js +327 -0
- package/tests/runner-integration.test.js +231 -0
- package/tests/session-cookie-hmac.test.js +151 -0
- package/tests/snapshot-performance.test.js +247 -0
- package/tests/sse-events.test.js +107 -0
- package/tests/webhook-trigger.test.js +198 -0
- package/tests/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { createResource, clone } from './resource-model.js';
|
|
2
|
+
import { serviceAccountForJob } from './identity-policy.js';
|
|
3
|
+
|
|
4
|
+
export const RUNNER_CONTROLLER_BOUNDARY = {
|
|
5
|
+
role: 'runner-controller',
|
|
6
|
+
scope: 'RunnerPool lifecycle, runner scheduling, pod spec generation, job assignment',
|
|
7
|
+
owns: ['pool validation', 'runner lifecycle', 'job scheduling', 'pod spec generation', 'capacity tracking'],
|
|
8
|
+
delegatesTo: ['resource-model', 'identity-policy'],
|
|
9
|
+
mustNotOwn: ['Kubernetes API calls', 'actual pod creation', 'network I/O']
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const RUNNER_STATUSES = new Set(['Idle', 'Running', 'Terminating']);
|
|
13
|
+
const DEFAULT_IMAGE = 'ubuntu:24.04';
|
|
14
|
+
const DEFAULT_SERVICE_ACCOUNT = 'krate-runner';
|
|
15
|
+
const DEFAULT_NAMESPACE = 'krate-org-default';
|
|
16
|
+
|
|
17
|
+
export function createRunnerController() {
|
|
18
|
+
// In-memory runner registry (runner id → runner record)
|
|
19
|
+
const runners = new Map();
|
|
20
|
+
// job ref → runner id
|
|
21
|
+
const jobAssignments = new Map();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
role: 'runner-controller',
|
|
25
|
+
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
// Pool management
|
|
28
|
+
// -------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
validateRunnerPool(resource) {
|
|
31
|
+
if (!resource || typeof resource !== 'object') {
|
|
32
|
+
return { valid: false, reason: 'missing-resource', message: 'resource is required' };
|
|
33
|
+
}
|
|
34
|
+
const name = resource.metadata?.name;
|
|
35
|
+
if (!name) return { valid: false, reason: 'missing-name', message: 'metadata.name is required' };
|
|
36
|
+
|
|
37
|
+
const spec = resource.spec || {};
|
|
38
|
+
if (spec.organizationRef === undefined || spec.organizationRef === null || String(spec.organizationRef).trim() === '') return { valid: false, reason: 'missing-org', message: 'spec.organizationRef is required' };
|
|
39
|
+
|
|
40
|
+
const min = Number(spec.warmReplicas ?? 0);
|
|
41
|
+
const max = Number(spec.maxReplicas ?? 0);
|
|
42
|
+
if (!Number.isInteger(min) || min < 0) return { valid: false, reason: 'invalid-min-replicas', message: 'spec.warmReplicas must be a non-negative integer' };
|
|
43
|
+
if (!Number.isInteger(max) || max < 1) return { valid: false, reason: 'invalid-max-replicas', message: 'spec.maxReplicas must be a positive integer' };
|
|
44
|
+
if (min > max) return { valid: false, reason: 'replicas-conflict', message: 'spec.warmReplicas must not exceed spec.maxReplicas' };
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
valid: true,
|
|
48
|
+
name,
|
|
49
|
+
organizationRef: spec.organizationRef,
|
|
50
|
+
warmReplicas: min,
|
|
51
|
+
maxReplicas: max,
|
|
52
|
+
image: spec.image || DEFAULT_IMAGE,
|
|
53
|
+
labels: resource.metadata?.labels || {}
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
getPoolStatus(pool) {
|
|
58
|
+
const poolName = pool?.metadata?.name;
|
|
59
|
+
const poolRunners = [...runners.values()].filter((r) => r.poolName === poolName);
|
|
60
|
+
const idle = poolRunners.filter((r) => r.status === 'Idle').length;
|
|
61
|
+
const active = poolRunners.filter((r) => r.status === 'Running').length;
|
|
62
|
+
const terminating = poolRunners.filter((r) => r.status === 'Terminating').length;
|
|
63
|
+
const total = poolRunners.length;
|
|
64
|
+
const desired = Number(pool?.spec?.warmReplicas ?? 0);
|
|
65
|
+
const maxReplicas = Number(pool?.spec?.maxReplicas ?? 0);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
poolName,
|
|
69
|
+
idle,
|
|
70
|
+
active,
|
|
71
|
+
terminating,
|
|
72
|
+
total,
|
|
73
|
+
desired,
|
|
74
|
+
maxReplicas,
|
|
75
|
+
phase: total === 0 ? 'Empty' : active > 0 ? 'Active' : 'Idle',
|
|
76
|
+
scaling: total < desired ? 'ScalingUp' : total > maxReplicas ? 'ScalingDown' : 'Stable'
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
getCapacity(pool) {
|
|
81
|
+
const poolName = pool?.metadata?.name;
|
|
82
|
+
const poolRunners = [...runners.values()].filter((r) => r.poolName === poolName);
|
|
83
|
+
const maxReplicas = Number(pool?.spec?.maxReplicas ?? 0);
|
|
84
|
+
const used = poolRunners.filter((r) => r.status === 'Running').length;
|
|
85
|
+
const available = Math.max(0, maxReplicas - used);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
poolName,
|
|
89
|
+
maxReplicas,
|
|
90
|
+
used,
|
|
91
|
+
available,
|
|
92
|
+
utilizationPct: maxReplicas > 0 ? Math.round((used / maxReplicas) * 100) : 0
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
// Runner lifecycle
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
createRunner(pool, runRef = null) {
|
|
101
|
+
const poolName = pool?.metadata?.name;
|
|
102
|
+
if (!poolName) return { error: true, reason: 'missing-pool', message: 'pool.metadata.name is required' };
|
|
103
|
+
|
|
104
|
+
const namespace = pool?.metadata?.namespace || DEFAULT_NAMESPACE;
|
|
105
|
+
const spec = pool?.spec || {};
|
|
106
|
+
const runnerId = `runner-${poolName}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
107
|
+
|
|
108
|
+
const workspace = runRef ? { runRef, mountPath: '/workspace' } : null;
|
|
109
|
+
const podSpec = this.generatePodSpec({ runnerId, pool }, workspace);
|
|
110
|
+
|
|
111
|
+
const runner = {
|
|
112
|
+
id: runnerId,
|
|
113
|
+
poolName,
|
|
114
|
+
namespace,
|
|
115
|
+
status: runRef ? 'Running' : 'Idle',
|
|
116
|
+
runRef: runRef || null,
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
image: spec.image || DEFAULT_IMAGE,
|
|
119
|
+
podSpec
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
runners.set(runnerId, runner);
|
|
123
|
+
if (runRef) jobAssignments.set(runRef, runnerId);
|
|
124
|
+
|
|
125
|
+
return { error: false, runnerId, runner: clone(runner) };
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
terminateRunner(runnerId) {
|
|
129
|
+
const runner = runners.get(runnerId);
|
|
130
|
+
if (!runner) return { error: true, reason: 'not-found', message: `Runner not found: ${runnerId}` };
|
|
131
|
+
|
|
132
|
+
// Remove job assignment if any
|
|
133
|
+
if (runner.runRef) jobAssignments.delete(runner.runRef);
|
|
134
|
+
|
|
135
|
+
runner.status = 'Terminating';
|
|
136
|
+
runner.terminatedAt = new Date().toISOString();
|
|
137
|
+
runners.delete(runnerId);
|
|
138
|
+
|
|
139
|
+
return { error: false, runnerId, terminatedAt: runner.terminatedAt };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Job scheduling
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
scheduleJob(pool, job) {
|
|
147
|
+
const jobRef = job?.metadata?.name || job?.ref;
|
|
148
|
+
if (!jobRef) return { error: true, reason: 'missing-job-ref', message: 'job.metadata.name or job.ref is required' };
|
|
149
|
+
|
|
150
|
+
const poolName = pool?.metadata?.name;
|
|
151
|
+
if (!poolName) return { error: true, reason: 'missing-pool', message: 'pool.metadata.name is required' };
|
|
152
|
+
|
|
153
|
+
// Check if already assigned
|
|
154
|
+
if (jobAssignments.has(jobRef)) {
|
|
155
|
+
const existingRunnerId = jobAssignments.get(jobRef);
|
|
156
|
+
return { error: false, runnerId: existingRunnerId, reused: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Find an idle runner in this pool
|
|
160
|
+
const idleRunner = [...runners.values()].find(
|
|
161
|
+
(r) => r.poolName === poolName && r.status === 'Idle'
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (idleRunner) {
|
|
165
|
+
idleRunner.status = 'Running';
|
|
166
|
+
idleRunner.runRef = jobRef;
|
|
167
|
+
idleRunner.assignedAt = new Date().toISOString();
|
|
168
|
+
jobAssignments.set(jobRef, idleRunner.id);
|
|
169
|
+
return { error: false, runnerId: idleRunner.id, reused: false, runner: clone(idleRunner) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check capacity for a new runner
|
|
173
|
+
const capacity = this.getCapacity(pool);
|
|
174
|
+
if (capacity.available <= 0) {
|
|
175
|
+
return { error: true, reason: 'no-capacity', message: `Pool ${poolName} has no available capacity (${capacity.used}/${capacity.maxReplicas})` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Create a new runner
|
|
179
|
+
const result = this.createRunner(pool, jobRef);
|
|
180
|
+
if (result.error) return result;
|
|
181
|
+
return { error: false, runnerId: result.runnerId, reused: false, runner: result.runner };
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
getRunnerForJob(jobRef) {
|
|
185
|
+
const runnerId = jobAssignments.get(jobRef);
|
|
186
|
+
if (!runnerId) return null;
|
|
187
|
+
const runner = runners.get(runnerId);
|
|
188
|
+
return runner ? clone(runner) : null;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// Pod spec generation
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
generatePodSpec({ runnerId, pool }, workspace = null) {
|
|
196
|
+
const spec = pool?.spec || {};
|
|
197
|
+
const namespace = pool?.metadata?.namespace || DEFAULT_NAMESPACE;
|
|
198
|
+
const image = spec.image || DEFAULT_IMAGE;
|
|
199
|
+
const organizationRef = spec.organizationRef || 'default';
|
|
200
|
+
const serviceAccountName = spec.serviceAccount || DEFAULT_SERVICE_ACCOUNT;
|
|
201
|
+
const runId = workspace?.runRef || runnerId;
|
|
202
|
+
|
|
203
|
+
const resourceLimits = spec.resourceLimits || {};
|
|
204
|
+
const resourceRequests = spec.resourceRequests || {};
|
|
205
|
+
|
|
206
|
+
const envVars = [
|
|
207
|
+
{ name: 'KRATE_ORG', value: organizationRef },
|
|
208
|
+
{ name: 'KRATE_RUN_ID', value: runId },
|
|
209
|
+
{ name: 'KRATE_WORKSPACE_PATH', value: workspace?.mountPath || '/workspace' }
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const volumes = [];
|
|
213
|
+
const volumeMounts = [];
|
|
214
|
+
|
|
215
|
+
if (workspace) {
|
|
216
|
+
const pvcName = workspace.pvcName || `krate-ws-${runId}`;
|
|
217
|
+
volumes.push({
|
|
218
|
+
name: 'workspace',
|
|
219
|
+
persistentVolumeClaim: { claimName: pvcName }
|
|
220
|
+
});
|
|
221
|
+
volumeMounts.push({
|
|
222
|
+
name: 'workspace',
|
|
223
|
+
mountPath: workspace.mountPath || '/workspace'
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
apiVersion: 'v1',
|
|
229
|
+
kind: 'Pod',
|
|
230
|
+
metadata: {
|
|
231
|
+
name: `runner-${runnerId}`,
|
|
232
|
+
namespace,
|
|
233
|
+
labels: {
|
|
234
|
+
'krate.a5c.ai/runner': runnerId,
|
|
235
|
+
'krate.a5c.ai/pool': pool?.metadata?.name || 'unknown',
|
|
236
|
+
'krate.a5c.ai/org': organizationRef
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
spec: {
|
|
240
|
+
serviceAccountName,
|
|
241
|
+
restartPolicy: 'Never',
|
|
242
|
+
containers: [{
|
|
243
|
+
name: 'runner',
|
|
244
|
+
image,
|
|
245
|
+
env: envVars,
|
|
246
|
+
volumeMounts,
|
|
247
|
+
resources: {
|
|
248
|
+
limits: Object.keys(resourceLimits).length ? resourceLimits : { cpu: '2', memory: '4Gi' },
|
|
249
|
+
requests: Object.keys(resourceRequests).length ? resourceRequests : { cpu: '500m', memory: '1Gi' }
|
|
250
|
+
}
|
|
251
|
+
}],
|
|
252
|
+
volumes
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
// Introspection helpers
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
listRunners(poolName = null) {
|
|
262
|
+
const all = [...runners.values()];
|
|
263
|
+
if (poolName) return all.filter((r) => r.poolName === poolName).map(clone);
|
|
264
|
+
return all.map(clone);
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
getRunner(runnerId) {
|
|
268
|
+
const r = runners.get(runnerId);
|
|
269
|
+
return r ? clone(r) : null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createResource } from './resource-model.js';
|
|
2
|
+
import { serviceAccountForJob } from './identity-policy.js';
|
|
3
|
+
|
|
4
|
+
export class RunnerScheduler {
|
|
5
|
+
constructor({ controlPlane }) { this.controlPlane = controlPlane; }
|
|
6
|
+
|
|
7
|
+
createRunnerPool({ name, namespace = 'krate-org-default', organizationRef = 'default', image = 'ubuntu:24.04', warmReplicas = 1, maxReplicas = 10, trustTier = 'trusted', cache = { type: 'object-storage' } }, user) {
|
|
8
|
+
return this.controlPlane.create(createResource('RunnerPool', { name, namespace, labels: { trustTier } }, {
|
|
9
|
+
organizationRef, image, warmReplicas, maxReplicas, trustTier, cache, scalingMetric: 'queueDepth'
|
|
10
|
+
}, { readyReplicas: warmReplicas, queueDepth: 0 }), user);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
planReplicas(pool, queueDepth) {
|
|
14
|
+
return Math.min(pool.spec.maxReplicas || 0, Math.max(pool.spec.warmReplicas || 0, queueDepth));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
startPipeline({ name, namespace = 'krate-org-default', organizationRef = 'default', repository, ref, actor, steps = ['checkout', 'test'], fork = false, resumeFrom = null }, user) {
|
|
18
|
+
const trustTier = fork ? 'untrusted' : 'trusted';
|
|
19
|
+
const pipeline = this.controlPlane.create(createResource('Pipeline', { name, namespace, labels: { repository, ref, trustTier } }, {
|
|
20
|
+
organizationRef, repository, ref, actor: actor.name, resumeFrom, trustTier, steps
|
|
21
|
+
}, { phase: 'Running', currentStep: resumeFrom || steps[0] }), user);
|
|
22
|
+
const jobs = steps.map((step, index) => this.controlPlane.create(createResource('Job', {
|
|
23
|
+
name: `${name}-${index + 1}-${step}`,
|
|
24
|
+
namespace,
|
|
25
|
+
labels: { pipeline: name, repository, trustTier }
|
|
26
|
+
}, {
|
|
27
|
+
organizationRef: pipeline.spec?.organizationRef || organizationRef,
|
|
28
|
+
pipeline: name,
|
|
29
|
+
step,
|
|
30
|
+
serviceAccount: serviceAccountForJob({ namespace, repository, pipeline: name, trustTier })
|
|
31
|
+
}, { phase: step === (resumeFrom || steps[0]) ? 'Running' : 'Pending' }), user));
|
|
32
|
+
return { pipeline, jobs };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
rerunFromStep(pipeline, step, user) {
|
|
36
|
+
return this.startPipeline({
|
|
37
|
+
name: `${pipeline.metadata.name}-rerun-${step}`,
|
|
38
|
+
namespace: pipeline.metadata.namespace,
|
|
39
|
+
organizationRef: pipeline.spec.organizationRef,
|
|
40
|
+
repository: pipeline.spec.repository,
|
|
41
|
+
ref: pipeline.spec.ref,
|
|
42
|
+
actor: { name: pipeline.spec.actor },
|
|
43
|
+
steps: pipeline.spec.steps,
|
|
44
|
+
resumeFrom: step,
|
|
45
|
+
fork: pipeline.spec.trustTier === 'untrusted'
|
|
46
|
+
}, user);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { ControlPlane } from './control-plane.js';
|
|
2
|
+
import { GiteaGitService, GiteaRepositoryStore } from './data-plane.js';
|
|
3
|
+
import { WebhookBus } from './hooks-events.js';
|
|
4
|
+
import { createAdmissionPolicy, mapOidcIdentity } from './identity-policy.js';
|
|
5
|
+
import { createInviteResource, createTeamResource, createAuthProviderResources, mapLoginProfileToKrateIdentity } from './auth.js';
|
|
6
|
+
import { createResource, clone } from './resource-model.js';
|
|
7
|
+
import { RunnerScheduler } from './runners-ci.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ORG = 'default';
|
|
10
|
+
const DEFAULT_NAMESPACE = 'krate-org-default';
|
|
11
|
+
|
|
12
|
+
export function createDefaultKrateUsers() {
|
|
13
|
+
return {
|
|
14
|
+
platformEngineer: mapOidcIdentity({ subject: 'user:platform', email: 'platform@example.com', groups: ['krate:platform-engineers'] }),
|
|
15
|
+
developer: mapOidcIdentity({ subject: 'user:dev', email: 'dev@example.com', groups: ['krate:developers'] }),
|
|
16
|
+
repoAdmin: mapOidcIdentity({ subject: 'user:admin', email: 'admin@example.com', groups: ['krate:repo-admins'] })
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class KrateRuntime {
|
|
21
|
+
constructor({ organizationRef = DEFAULT_ORG, namespace = DEFAULT_NAMESPACE, users = createDefaultKrateUsers(), controlPlane, git, runners, webhooks } = {}) {
|
|
22
|
+
this.organizationRef = organizationRef;
|
|
23
|
+
this.namespace = namespace;
|
|
24
|
+
this.users = users;
|
|
25
|
+
this.controlPlane = controlPlane || new ControlPlane();
|
|
26
|
+
this.controlPlane.addAdmissionPolicy(createAdmissionPolicy({
|
|
27
|
+
name: 'pr-title-required',
|
|
28
|
+
mode: 'enforce',
|
|
29
|
+
match: ({ resource }) => resource.kind === 'PullRequest',
|
|
30
|
+
validate: ({ resource }) => Boolean(resource.spec.title && resource.spec.title.length >= 8),
|
|
31
|
+
message: 'PullRequest spec.title must be descriptive'
|
|
32
|
+
}));
|
|
33
|
+
this.git = git || new GiteaGitService({ controlPlane: this.controlPlane, stores: [new GiteaRepositoryStore({ name: 'gitea-primary', receivePackReady: true })] });
|
|
34
|
+
this.runners = runners || new RunnerScheduler({ controlPlane: this.controlPlane });
|
|
35
|
+
this.webhooks = webhooks || new WebhookBus({ controlPlane: this.controlPlane });
|
|
36
|
+
this.seeded = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static fromSnapshot(snapshot, options = {}) {
|
|
40
|
+
const runtime = new KrateRuntime(options);
|
|
41
|
+
runtime.importSnapshot(snapshot);
|
|
42
|
+
return runtime;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
bootstrap({ repository = 'krate-demo', webhookUrl = 'https://hooks.example.test/krate' } = {}) {
|
|
46
|
+
if (this.seeded) return this.snapshot();
|
|
47
|
+
const { platformEngineer, repoAdmin } = this.users;
|
|
48
|
+
this.git.createRepository({ name: repository, namespace: this.namespace, organizationRef: this.organizationRef }, repoAdmin);
|
|
49
|
+
this.controlPlane.create(createResource('BranchProtection', { name: 'main-protection', namespace: this.namespace }, { organizationRef: this.organizationRef, refs: ['refs/heads/main'], requirePullRequest: true, requiredChecks: ['test'], requiredApprovals: 1 }), repoAdmin);
|
|
50
|
+
this.controlPlane.create(createResource('RefPolicy', { name: 'deny-internal-refs', namespace: this.namespace }, { organizationRef: this.organizationRef, deny: ['refs/internal/'] }), repoAdmin);
|
|
51
|
+
this.runners.createRunnerPool({ name: 'trusted-linux', namespace: this.namespace, organizationRef: this.organizationRef, warmReplicas: 1, maxReplicas: 4 }, platformEngineer);
|
|
52
|
+
this.webhooks.subscribe({ name: 'chatops', namespace: this.namespace, organizationRef: this.organizationRef, url: webhookUrl, events: ['pullrequest.created', 'pullrequest.merged'] }, repoAdmin);
|
|
53
|
+
this.controlPlane.create(createTeamResource({ name: 'maintainers', organizationRef: this.organizationRef, displayName: 'Maintainers', members: ['admin'], maintainers: ['admin'], repositoryGrants: [{ repository, permission: 'admin' }], namespace: this.namespace }), platformEngineer);
|
|
54
|
+
const identity = mapLoginProfileToKrateIdentity({ provider: 'sso', subject: 'user:admin', email: 'admin@example.com', displayName: 'Admin', username: 'admin', teams: ['maintainers'], admin: true, namespace: this.namespace, organizationRef: this.organizationRef });
|
|
55
|
+
this.controlPlane.create(identity.user, platformEngineer);
|
|
56
|
+
this.controlPlane.create(identity.mapping, platformEngineer);
|
|
57
|
+
this.controlPlane.create(createInviteResource({ email: 'new-user@example.com', role: 'member', teams: ['maintainers'], invitedBy: 'admin', namespace: this.namespace, organizationRef: this.organizationRef }), repoAdmin);
|
|
58
|
+
for (const provider of createAuthProviderResources(undefined, this.namespace, this.organizationRef)) this.controlPlane.create(provider, platformEngineer);
|
|
59
|
+
this.seeded = true;
|
|
60
|
+
return this.snapshot();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
exportSnapshot() {
|
|
64
|
+
return {
|
|
65
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
66
|
+
kind: 'KrateRuntimeSnapshot',
|
|
67
|
+
namespace: this.namespace,
|
|
68
|
+
organizationRef: this.organizationRef,
|
|
69
|
+
seeded: this.seeded,
|
|
70
|
+
controlPlane: this.controlPlane.exportSnapshot(),
|
|
71
|
+
git: this.git.snapshot?.() || null
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
importSnapshot(snapshot) {
|
|
76
|
+
const controlPlaneSnapshot = snapshot?.controlPlane || snapshot;
|
|
77
|
+
this.controlPlane.importSnapshot(controlPlaneSnapshot);
|
|
78
|
+
if (snapshot?.git) this.git.importSnapshot(snapshot.git);
|
|
79
|
+
this.seeded = Boolean(snapshot?.seeded || this.controlPlane.list('Repository', { namespace: this.namespace }).items.length);
|
|
80
|
+
return this.snapshot();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
createRepository({ name, visibility = 'private' }, user = this.users.repoAdmin) {
|
|
84
|
+
return this.git.createRepository({ name, namespace: this.namespace, organizationRef: this.organizationRef, visibility }, user);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
createPullRequest({ repository, sourceRef = 'refs/heads/feature', targetRef = 'refs/heads/main', title, actor = this.users.developer, fork = false }) {
|
|
88
|
+
this.#requireRepository(repository);
|
|
89
|
+
const index = this.controlPlane.list('PullRequest', { namespace: this.namespace }).items.length + 1;
|
|
90
|
+
const name = `pr-${index}`;
|
|
91
|
+
const pullRequest = this.controlPlane.create(createResource('PullRequest', { name, namespace: this.namespace, labels: { repository } }, {
|
|
92
|
+
organizationRef: this.organizationRef,
|
|
93
|
+
repository,
|
|
94
|
+
sourceRef,
|
|
95
|
+
targetRef,
|
|
96
|
+
title,
|
|
97
|
+
author: actor.name,
|
|
98
|
+
checks: [],
|
|
99
|
+
requiredApprovals: this.#requiredApprovals(targetRef)
|
|
100
|
+
}, { phase: 'Open', approvals: 0, mergeable: false }), actor);
|
|
101
|
+
const pipelineRun = this.runners.startPipeline({ name: `pipeline-${name}`, namespace: this.namespace, organizationRef: this.organizationRef, repository, ref: `refs/pull/${index}/head`, actor, fork, steps: ['checkout', 'test'] }, actor);
|
|
102
|
+
this.webhooks.deliver({ subscriptionName: 'chatops', namespace: this.namespace, organizationRef: this.organizationRef, eventType: 'pullrequest.created', payload: { pullRequest: name, repository, title } }, this.users.repoAdmin);
|
|
103
|
+
return { pullRequest, pipeline: pipelineRun.pipeline, jobs: pipelineRun.jobs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
completePipeline({ pipeline, phase = 'Succeeded', failedStep = null }, user = this.users.developer) {
|
|
107
|
+
const existing = this.controlPlane.get('Pipeline', this.namespace, pipeline);
|
|
108
|
+
if (!existing) throw new Error(`Pipeline ${pipeline} not found`);
|
|
109
|
+
const jobs = this.controlPlane.list('Job', { namespace: this.namespace, labels: { pipeline } }).items.map((job) => {
|
|
110
|
+
const jobPhase = failedStep && job.spec.step === failedStep ? 'Failed' : phase;
|
|
111
|
+
return this.controlPlane.patchStatus('Job', this.namespace, job.metadata.name, { phase: jobPhase, finishedAt: new Date().toISOString() }, user);
|
|
112
|
+
});
|
|
113
|
+
const pipelinePhase = jobs.some((job) => job.status.phase === 'Failed') ? 'Failed' : phase;
|
|
114
|
+
const updatedPipeline = this.controlPlane.patchStatus('Pipeline', this.namespace, pipeline, { phase: pipelinePhase, currentStep: null, finishedAt: new Date().toISOString() }, user);
|
|
115
|
+
this.#refreshPullRequestMergeability(existing.spec.repository);
|
|
116
|
+
return { pipeline: updatedPipeline, jobs };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
addReview({ pullRequest, decision = 'approved', body = 'Looks good', reviewer = this.users.repoAdmin }) {
|
|
120
|
+
const pr = this.#requirePullRequest(pullRequest);
|
|
121
|
+
const reviewCount = this.controlPlane.list('Review', { namespace: this.namespace, labels: { pullRequest } }).items.length + 1;
|
|
122
|
+
const review = this.controlPlane.create(createResource('Review', { name: `${pullRequest}-review-${reviewCount}`, namespace: this.namespace, labels: { pullRequest, decision } }, {
|
|
123
|
+
organizationRef: this.organizationRef,
|
|
124
|
+
pullRequest,
|
|
125
|
+
repository: pr.spec.repository,
|
|
126
|
+
reviewer: reviewer.name,
|
|
127
|
+
decision,
|
|
128
|
+
body
|
|
129
|
+
}, { phase: decision === 'approved' ? 'Approved' : 'ChangesRequested' }), reviewer);
|
|
130
|
+
this.#refreshPullRequestMergeability(pr.spec.repository);
|
|
131
|
+
return review;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
mergePullRequest({ pullRequest, actor = this.users.repoAdmin }) {
|
|
135
|
+
const pr = this.#requirePullRequest(pullRequest);
|
|
136
|
+
const refreshed = this.#refreshPullRequestMergeability(pr.spec.repository).find((candidate) => candidate.metadata.name === pullRequest) || pr;
|
|
137
|
+
if (!refreshed.status.mergeable) throw new Error(`PullRequest ${pullRequest} is not mergeable`);
|
|
138
|
+
const gitEvent = this.git.receivePack({ repository: refreshed.spec.repository, namespace: this.namespace, ref: refreshed.spec.targetRef, newRev: this.#syntheticRevision(pullRequest), actor });
|
|
139
|
+
const merged = this.controlPlane.patchStatus('PullRequest', this.namespace, pullRequest, { phase: 'Merged', mergeable: false, mergedAt: new Date().toISOString(), mergeCommit: gitEvent.newRev }, actor);
|
|
140
|
+
const delivery = this.webhooks.deliver({ subscriptionName: 'chatops', namespace: this.namespace, organizationRef: this.organizationRef, eventType: 'pullrequest.merged', payload: { pullRequest, repository: refreshed.spec.repository, mergeCommit: gitEvent.newRev } }, this.users.repoAdmin);
|
|
141
|
+
return { pullRequest: merged, gitEvent, delivery };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
snapshot() {
|
|
145
|
+
const kinds = ['Organization', 'User', 'Team', 'Invite', 'IdentityMapping', 'AuthProvider', 'Repository', 'SSHKey', 'RepositoryPermission', 'BranchProtection', 'RefPolicy', 'RunnerPool', 'PullRequest', 'Issue', 'Review', 'Pipeline', 'Job', 'WebhookSubscription', 'WebhookDelivery'];
|
|
146
|
+
const resources = Object.fromEntries(kinds.map((kind) => [kind, this.controlPlane.list(kind, { namespace: this.namespace }).items]));
|
|
147
|
+
return {
|
|
148
|
+
namespace: this.namespace,
|
|
149
|
+
export: this.exportSnapshot(),
|
|
150
|
+
storage: this.controlPlane.storageReport(),
|
|
151
|
+
resources,
|
|
152
|
+
events: clone(this.controlPlane.events),
|
|
153
|
+
auditLog: clone(this.controlPlane.auditLog)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#requireRepository(repository) {
|
|
158
|
+
const existing = this.controlPlane.get('Repository', this.namespace, repository);
|
|
159
|
+
if (!existing) throw new Error(`Repository ${repository} not found`);
|
|
160
|
+
return existing;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#requirePullRequest(pullRequest) {
|
|
164
|
+
const existing = this.controlPlane.get('PullRequest', this.namespace, pullRequest);
|
|
165
|
+
if (!existing) throw new Error(`PullRequest ${pullRequest} not found`);
|
|
166
|
+
return existing;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#requiredApprovals(targetRef) {
|
|
170
|
+
const branchProtection = this.controlPlane.list('BranchProtection', { namespace: this.namespace }).items.find((policy) => (policy.spec.refs || []).includes(targetRef));
|
|
171
|
+
return branchProtection?.spec.requiredApprovals || 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#refreshPullRequestMergeability(repository) {
|
|
175
|
+
const pullRequests = this.controlPlane.list('PullRequest', { namespace: this.namespace, labels: { repository } }).items;
|
|
176
|
+
return pullRequests.map((pr) => {
|
|
177
|
+
if (pr.status.phase !== 'Open') return pr;
|
|
178
|
+
const reviews = this.controlPlane.list('Review', { namespace: this.namespace, labels: { pullRequest: pr.metadata.name } }).items;
|
|
179
|
+
const approvals = reviews.filter((review) => review.spec.decision === 'approved').length;
|
|
180
|
+
const pipelines = this.controlPlane.list('Pipeline', { namespace: this.namespace, labels: { repository } }).items.filter((pipeline) => pipeline.metadata.name === `pipeline-${pr.metadata.name}`);
|
|
181
|
+
const checksPassed = pipelines.length > 0 && pipelines.every((pipeline) => pipeline.status.phase === 'Succeeded');
|
|
182
|
+
return this.controlPlane.patchStatus('PullRequest', this.namespace, pr.metadata.name, { approvals, checksPassed, mergeable: approvals >= (pr.spec.requiredApprovals || 0) && checksPassed }, this.users.repoAdmin);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#syntheticRevision(seed) {
|
|
187
|
+
const source = Buffer.from(`${seed}:${Date.now()}`).toString('hex');
|
|
188
|
+
return source.padEnd(40, '0').slice(0, 40);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createKrateRuntime(options = {}) {
|
|
193
|
+
const runtime = new KrateRuntime(options);
|
|
194
|
+
if (options.bootstrap !== false) runtime.bootstrap(options.bootstrapOptions);
|
|
195
|
+
return runtime;
|
|
196
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot cache with:
|
|
3
|
+
* - Per-org storage (multiple orgs cached simultaneously)
|
|
4
|
+
* - stale-while-revalidate: return stale data immediately, refresh in background
|
|
5
|
+
* - Configurable TTL via KRATE_SNAPSHOT_CACHE_TTL_MS
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const CACHE_TTL_MS = Number(process.env.KRATE_SNAPSHOT_CACHE_TTL_MS || 30_000);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-org cache map. Key = org string (or '' for no-org).
|
|
12
|
+
* Value = { data, timestamp, revalidating: boolean }
|
|
13
|
+
*/
|
|
14
|
+
const orgCacheMap = new Map();
|
|
15
|
+
|
|
16
|
+
// Legacy single-org cache kept for backward compatibility with controller-client.js
|
|
17
|
+
let snapshotCache = { data: null, timestamp: 0, org: null };
|
|
18
|
+
|
|
19
|
+
export function getSnapshotCache() {
|
|
20
|
+
return snapshotCache;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setSnapshotCache(data, org) {
|
|
24
|
+
snapshotCache = { data, timestamp: Date.now(), org };
|
|
25
|
+
// Also update per-org map
|
|
26
|
+
const key = org ?? '';
|
|
27
|
+
const entry = orgCacheMap.get(key) || {};
|
|
28
|
+
orgCacheMap.set(key, { ...entry, data, timestamp: Date.now(), revalidating: false });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearSnapshotCache() {
|
|
32
|
+
snapshotCache = { data: null, timestamp: 0, org: null };
|
|
33
|
+
orgCacheMap.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Per-org cache API
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the cached snapshot for a specific org.
|
|
42
|
+
* Returns null when nothing is cached or when the TTL has expired.
|
|
43
|
+
*
|
|
44
|
+
* @param {string|null} org
|
|
45
|
+
* @returns {{ data: object, timestamp: number, revalidating: boolean } | null}
|
|
46
|
+
*/
|
|
47
|
+
export function getOrgCache(org) {
|
|
48
|
+
const key = org ?? '';
|
|
49
|
+
const entry = orgCacheMap.get(key);
|
|
50
|
+
if (!entry || !entry.data) return null;
|
|
51
|
+
return entry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Store a snapshot for a specific org.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} data
|
|
58
|
+
* @param {string|null} org
|
|
59
|
+
*/
|
|
60
|
+
export function setOrgCache(data, org) {
|
|
61
|
+
const key = org ?? '';
|
|
62
|
+
orgCacheMap.set(key, { data, timestamp: Date.now(), revalidating: false });
|
|
63
|
+
// Keep legacy cache in sync for the most recently written org
|
|
64
|
+
snapshotCache = { data, timestamp: Date.now(), org };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Clear the cache entry for a specific org only.
|
|
69
|
+
*
|
|
70
|
+
* @param {string|null} org
|
|
71
|
+
*/
|
|
72
|
+
export function clearOrgCache(org) {
|
|
73
|
+
orgCacheMap.delete(org ?? '');
|
|
74
|
+
if (snapshotCache.org === org) {
|
|
75
|
+
snapshotCache = { data: null, timestamp: 0, org: null };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return all orgs currently in the cache (for introspection / debugging).
|
|
81
|
+
*
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
export function cachedOrgs() {
|
|
85
|
+
return [...orgCacheMap.keys()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// stale-while-revalidate helpers
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check whether the cache entry for the given org is fresh.
|
|
94
|
+
*
|
|
95
|
+
* @param {string|null} org
|
|
96
|
+
* @param {number} [ttlMs] - defaults to CACHE_TTL_MS
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isCacheFresh(org, ttlMs = CACHE_TTL_MS) {
|
|
100
|
+
const entry = getOrgCache(org);
|
|
101
|
+
if (!entry) return false;
|
|
102
|
+
return (Date.now() - entry.timestamp) < ttlMs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* stale-while-revalidate: return stale data immediately if available, then
|
|
107
|
+
* trigger revalidateFn() in the background to refresh the cache entry.
|
|
108
|
+
*
|
|
109
|
+
* @param {string|null} org - Organization key for the cache
|
|
110
|
+
* @param {Function} revalidateFn - Async function that returns fresh data
|
|
111
|
+
* @param {object} [swrOptions]
|
|
112
|
+
* @param {number} [swrOptions.ttlMs] - Fresh TTL in ms (default: CACHE_TTL_MS)
|
|
113
|
+
* @param {number} [swrOptions.staleMs] - Max staleness before we block on revalidate (default: 5 × ttlMs)
|
|
114
|
+
* @returns {Promise<object>} Either the stale cached value or the freshly fetched one
|
|
115
|
+
*/
|
|
116
|
+
export async function staleWhileRevalidate(org, revalidateFn, swrOptions = {}) {
|
|
117
|
+
const ttlMs = swrOptions.ttlMs ?? CACHE_TTL_MS;
|
|
118
|
+
const staleMs = swrOptions.staleMs ?? ttlMs * 5;
|
|
119
|
+
const key = org ?? '';
|
|
120
|
+
const entry = orgCacheMap.get(key);
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
|
|
123
|
+
const isFresh = entry && entry.data && (now - entry.timestamp) < ttlMs;
|
|
124
|
+
const isStale = entry && entry.data && (now - entry.timestamp) < staleMs;
|
|
125
|
+
|
|
126
|
+
if (isFresh) {
|
|
127
|
+
// Cache is fresh: return immediately
|
|
128
|
+
return entry.data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isStale && !entry.revalidating) {
|
|
132
|
+
// Stale but still usable: return immediately and revalidate in background
|
|
133
|
+
orgCacheMap.set(key, { ...entry, revalidating: true });
|
|
134
|
+
Promise.resolve().then(async () => {
|
|
135
|
+
try {
|
|
136
|
+
const fresh = await revalidateFn();
|
|
137
|
+
setOrgCache(fresh, org);
|
|
138
|
+
} catch {
|
|
139
|
+
// On background refresh error, clear the revalidating flag so a future
|
|
140
|
+
// request can try again
|
|
141
|
+
const current = orgCacheMap.get(key);
|
|
142
|
+
if (current) orgCacheMap.set(key, { ...current, revalidating: false });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return entry.data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isStale && entry.revalidating) {
|
|
149
|
+
// Another caller is already refreshing: return stale data now
|
|
150
|
+
return entry.data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// No usable cache: block on the revalidation
|
|
154
|
+
const fresh = await revalidateFn();
|
|
155
|
+
setOrgCache(fresh, org);
|
|
156
|
+
return fresh;
|
|
157
|
+
}
|