@a5c-ai/krate 5.0.1-staging.04a3db697
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 +3067 -0
- package/dist/krate-lifecycle.json +201 -0
- package/dist/krate-runtime-snapshot.json +2955 -0
- package/dist/krate-summary.json +722 -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/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 +236 -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 +321 -0
- package/src/agent-workspace-controller.js +447 -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 +50 -0
- package/src/controller-ui.js +551 -0
- package/src/data-plane.js +178 -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 +95 -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 +55 -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/operations.js +112 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +221 -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/deployment.test.js +396 -0
- package/tests/e2e/lifecycle.test.js +117 -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 +727 -0
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/org-scoping.test.js +687 -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/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { ControlPlane, GiteaGitService, GiteaRepositoryStore, RunnerScheduler, WebhookBus, createAdmissionPolicy, createAuthProviderConfig, buildAuthorizationRedirect, exchangeOAuthCodeForProfile, profileFromDelegatedHeaders, registerLoginProfile, createSessionCookie, parseSessionCookie, createInviteResource, createTeamResource, identityBackendSyncPlan, mapLoginProfileToKrateIdentity, createKrateApiController, createControllerUiModel, createKrateComponentCatalog, createKrateHandoffSummary, createKrateLifecycleSnapshot, createKrateMvpDemo, createPolicyRolloutModel, createResource, createKrateKubernetesReconciler, identityAccessReconciliationPlan, createKubernetesResourceClient, createKubernetesResourceGateway, createGiteaBackend, createGiteaRepositoryHosting, createKrateGitOpsPlan, listResourceDefinitions, mapOidcIdentity, resourceSchemaForKind, resourceToYaml, runSmokeAssertions } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const platform = mapOidcIdentity({ subject: 'platform', email: 'platform@example.com', groups: ['krate:platform-engineers'] });
|
|
6
|
+
const developer = mapOidcIdentity({ subject: 'dev', email: 'dev@example.com', groups: ['krate:developers'] });
|
|
7
|
+
const repoAdmin = mapOidcIdentity({ subject: 'admin', email: 'admin@example.com', groups: ['krate:repo-admins'] });
|
|
8
|
+
|
|
9
|
+
function setOptionalEnv(name, value) {
|
|
10
|
+
if (value === undefined) delete process.env[name];
|
|
11
|
+
else process.env[name] = value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
test('Gitea backend maps Git hosting integrations to API-shaped calls', async () => {
|
|
16
|
+
const calls = [];
|
|
17
|
+
const backend = createGiteaBackend({ baseUrl: 'https://gitea.example.test', token: 'secret', fetchImpl: async (url, options) => {
|
|
18
|
+
calls.push({ url, options });
|
|
19
|
+
return { ok: true, status: 200, async json() { return { ok: true, url, method: options.method }; } };
|
|
20
|
+
} });
|
|
21
|
+
|
|
22
|
+
await backend.createOrganization({ name: 'krate', fullName: 'Krate' });
|
|
23
|
+
await backend.createRepository({ owner: 'krate', name: 'app', private: true, defaultBranch: 'main' });
|
|
24
|
+
await backend.addUserSshKey({ title: 'alice', key: 'ssh-ed25519 USER' });
|
|
25
|
+
await backend.addDeployKey({ owner: 'krate', repo: 'app', title: 'argocd', key: 'ssh-ed25519 AAAA' });
|
|
26
|
+
await backend.addCollaborator({ owner: 'krate', repo: 'app', username: 'alice', permission: 'write' });
|
|
27
|
+
await backend.createTeam({ org: 'krate', name: 'maintainers', permission: 'admin' });
|
|
28
|
+
await backend.createIssue({ owner: 'krate', repo: 'app', title: 'Bug' });
|
|
29
|
+
await backend.createPullRequest({ owner: 'krate', repo: 'app', title: 'Change', head: 'feature', base: 'main' });
|
|
30
|
+
await backend.protectBranch({ owner: 'krate', repo: 'app', branch: 'main', statusChecks: ['ci/test'] });
|
|
31
|
+
await backend.createWebhook({ owner: 'krate', repo: 'app', url: 'https://hooks.example.test/krate', secret: 'hook-secret' });
|
|
32
|
+
|
|
33
|
+
assert.equal(backend.role, 'gitea-backend');
|
|
34
|
+
assert.deepEqual(calls.map((call) => call.options.method), ['POST', 'POST', 'POST', 'POST', 'PUT', 'POST', 'POST', 'POST', 'POST', 'POST']);
|
|
35
|
+
assert.ok(calls[0].url.endsWith('/api/v1/orgs'));
|
|
36
|
+
assert.ok(calls[1].url.endsWith('/api/v1/orgs/krate/repos'));
|
|
37
|
+
assert.ok(calls[2].url.endsWith('/api/v1/user/keys'));
|
|
38
|
+
assert.ok(calls[3].url.endsWith('/api/v1/repos/krate/app/keys'));
|
|
39
|
+
assert.ok(calls[4].url.endsWith('/api/v1/repos/krate/app/collaborators/alice'));
|
|
40
|
+
assert.ok(calls.some((call) => call.url.endsWith('/api/v1/repos/krate/app/issues')));
|
|
41
|
+
assert.ok(calls.some((call) => call.url.endsWith('/api/v1/repos/krate/app/pulls')));
|
|
42
|
+
assert.ok(calls[8].url.endsWith('/api/v1/repos/krate/app/branch_protections'));
|
|
43
|
+
assert.ok(JSON.parse(calls[9].options.body).config.secret === 'hook-secret');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('Argo CD GitOps plan creates an automated Krate Application', () => {
|
|
47
|
+
const plan = createKrateGitOpsPlan({ repoURL: 'https://gitea.example.test/krate/platform-config.git', namespace: 'krate-system' });
|
|
48
|
+
assert.equal(plan.engine, 'argocd');
|
|
49
|
+
assert.equal(plan.application.apiVersion, 'argoproj.io/v1alpha1');
|
|
50
|
+
assert.equal(plan.application.kind, 'Application');
|
|
51
|
+
assert.equal(plan.application.spec.source.repoURL, 'https://gitea.example.test/krate/platform-config.git');
|
|
52
|
+
assert.equal(plan.application.spec.destination.namespace, 'krate-system');
|
|
53
|
+
assert.equal(plan.application.spec.syncPolicy.automated.prune, true);
|
|
54
|
+
assert.equal(plan.application.spec.syncPolicy.automated.selfHeal, true);
|
|
55
|
+
assert.ok(plan.requiredClusterResources.includes('Application.argoproj.io'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('Gitea repository hosting plan includes Argo CD deploy key and webhook integration', () => {
|
|
59
|
+
const hosting = createGiteaRepositoryHosting({ namespace: 'krate-system', repository: 'platform-config' });
|
|
60
|
+
assert.equal(hosting.backend, 'gitea');
|
|
61
|
+
assert.match(hosting.httpUrl, /platform-config\.git$/);
|
|
62
|
+
assert.ok(hosting.integrationPlan.operations.some((step) => step.action === 'addDeployKey' && step.title === 'krate-argocd'));
|
|
63
|
+
assert.ok(hosting.integrationPlan.operations.some((step) => step.action === 'createWebhook'));
|
|
64
|
+
});
|
|
65
|
+
test('resource catalog exposes all ontology kinds and storage boundaries', () => {
|
|
66
|
+
const definitions = listResourceDefinitions();
|
|
67
|
+
assert.equal(definitions.length, 75);
|
|
68
|
+
assert.deepEqual(definitions.filter((definition) => definition.storage === 'etcd').map((definition) => definition.kind), ['Organization', 'OrgNamespaceBinding', 'User', 'Team', 'Invite', 'IdentityMapping', 'AuthProvider', 'Repository', 'SSHKey', 'RepositoryPermission', 'WebhookSubscription', 'RefPolicy', 'BranchProtection', 'PolicyProfile', 'PolicyTemplate', 'PolicyBinding', 'PolicyExceptionRequest', 'View', 'Selector', 'RunnerPool', 'AgentStack', 'AgentSubagent', 'AgentToolProfile', 'AgentMcpServer', 'AgentSkill', 'AgentTriggerRule', 'AgentContextLabel', 'KrateWorkspacePolicy', 'AgentServiceAccount', 'AgentRoleBinding', 'AgentSecretGrant', 'AgentConfigGrant', 'KrateWorkspace', 'AgentAdapter', 'AgentTransportBinding', 'AgentProviderConfig', 'KrateProject', 'AgentGatewayConfig', 'AgentMemoryRepository', 'AgentMemorySource', 'AgentMemoryOntology', 'AgentMemoryAssociation', 'ExternalBackendProvider', 'ExternalBackendBinding', 'ExternalBackendSyncPolicy', 'ExternalProviderCapabilityManifest']);
|
|
69
|
+
assert.deepEqual(definitions.filter((definition) => definition.storage === 'postgres').map((definition) => definition.kind), ['PullRequest', 'Issue', 'Review', 'Pipeline', 'Job', 'WebhookDelivery', 'AgentDispatchRun', 'AgentDispatchAttempt', 'AgentSession', 'AgentContextBundle', 'KrateArtifact', 'AgentApproval', 'AgentTriggerExecution', 'AgentCapabilityRequirement', 'WorkItemSessionLink', 'WorkItemWorkspaceLink', 'AgentSessionTranscript', 'AgentSessionAttachment', 'KrateWorkspaceRuntime', 'AgentMemorySnapshot', 'AgentMemoryQuery', 'AgentMemoryUpdate', 'AgentRunMemoryImport', 'ExternalWebhookDelivery', 'ExternalSyncEvent', 'ExternalSyncState', 'ExternalWriteIntent', 'ExternalSyncConflict', 'ExternalObjectLink']);
|
|
70
|
+
assert.equal(resourceSchemaForKind('Organization').plural, 'organizations');
|
|
71
|
+
assert.equal(resourceSchemaForKind('User').plural, 'users');
|
|
72
|
+
assert.equal(resourceSchemaForKind('Team').plural, 'teams');
|
|
73
|
+
assert.equal(resourceSchemaForKind('Invite').plural, 'invites');
|
|
74
|
+
assert.equal(resourceSchemaForKind('IdentityMapping').plural, 'identitymappings');
|
|
75
|
+
assert.equal(resourceSchemaForKind('AuthProvider').plural, 'authproviders');
|
|
76
|
+
assert.equal(resourceSchemaForKind('SSHKey').plural, 'sshkeys');
|
|
77
|
+
assert.equal(resourceSchemaForKind('RepositoryPermission').plural, 'repositorypermissions');
|
|
78
|
+
const schema = resourceSchemaForKind('PullRequest');
|
|
79
|
+
assert.deepEqual(schema.required.spec, ['organizationRef', 'repository', 'title']);
|
|
80
|
+
assert.match(resourceToYaml(createResource('Repository', { name: 'yaml-demo' }, { organizationRef: 'default', visibility: 'private' })), /kind: Repository/);
|
|
81
|
+
assert.match(resourceToYaml(createResource('SSHKey', { name: 'alice-key' }, { organizationRef: 'default', scope: 'user', key: 'ssh-ed25519 AAAA' })), /kind: SSHKey/);
|
|
82
|
+
assert.match(resourceToYaml({ apiVersion: 'core.oam.dev/v1beta1', kind: 'Application', spec: { components: [{ name: 'web', properties: { image: 'krate/mvp-model:0.1.0' }, traits: [{ type: 'scaler', properties: { replicas: 1 } }] }] } }), /components:\n - name: web\n properties:\n image: krate\/mvp-model:0.1.0\n traits:\n - type: scaler/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
test('Krate auth resources map sign-in, teams, invites, and repository identities', () => {
|
|
88
|
+
const config = createAuthProviderConfig({
|
|
89
|
+
KRATE_AUTH_GITHUB_ENABLED: 'true',
|
|
90
|
+
KRATE_AUTH_GITHUB_CLIENT_ID: 'gh-client',
|
|
91
|
+
KRATE_AUTH_GITHUB_CLIENT_SECRET: 'secret',
|
|
92
|
+
KRATE_AUTH_SSO_ENABLED: 'true',
|
|
93
|
+
KRATE_AUTH_SSO_PROVIDER_NAME: 'Company SSO',
|
|
94
|
+
KRATE_AUTH_SSO_CLIENT_ID: 'sso-client',
|
|
95
|
+
KRATE_AUTH_SSO_CLIENT_SECRET: 'secret',
|
|
96
|
+
KRATE_AUTH_SSO_AUTHORIZATION_URL: 'https://idp.example.test/authorize',
|
|
97
|
+
KRATE_AUTH_SSO_TOKEN_URL: 'https://idp.example.test/token',
|
|
98
|
+
KRATE_AUTH_SSO_USERINFO_URL: 'https://idp.example.test/userinfo',
|
|
99
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true'
|
|
100
|
+
});
|
|
101
|
+
const redirect = buildAuthorizationRedirect({ provider: config.providers.sso, requestUrl: 'https://krate.example.test/login', state: 'state' });
|
|
102
|
+
const mapped = mapLoginProfileToKrateIdentity({ provider: 'sso', subject: 'user-1', email: 'alice@example.com', displayName: 'Alice', username: 'alice', teams: ['maintainers'], admin: true, namespace: 'krate-test' });
|
|
103
|
+
const team = createTeamResource({ name: 'maintainers', members: ['alice'], repositoryGrants: [{ repository: 'app', permission: 'admin' }], namespace: 'krate-test' });
|
|
104
|
+
const invite = createInviteResource({ email: 'bob@example.com', teams: ['maintainers'], namespace: 'krate-test' });
|
|
105
|
+
const plan = identityBackendSyncPlan({ users: [mapped.user], teams: [team], invites: [invite], mappings: [mapped.mapping], permissions: [createResource('RepositoryPermission', { name: 'app-alice', namespace: 'krate-test' }, { organizationRef: 'default', repository: 'app', subject: 'alice', subjectKind: 'user', permission: 'admin' })] });
|
|
106
|
+
|
|
107
|
+
assert.equal(config.providers.github.enabled, true);
|
|
108
|
+
assert.equal(config.providers.sso.label, 'Company SSO');
|
|
109
|
+
assert.equal(config.delegatedIdentity.enabled, true);
|
|
110
|
+
assert.ok(redirect.url.includes('client_id=sso-client'));
|
|
111
|
+
assert.equal(mapped.user.kind, 'User');
|
|
112
|
+
assert.equal(mapped.mapping.spec.repositoryIdentity.username, 'alice');
|
|
113
|
+
assert.equal(team.kind, 'Team');
|
|
114
|
+
assert.equal(invite.kind, 'Invite');
|
|
115
|
+
assert.deepEqual(plan.mappings.map((entry) => entry.action), ['link-identity']);
|
|
116
|
+
assert.deepEqual(plan.permissions.map((entry) => entry.action), ['sync-repository-permission']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('session cookies parse into the signed-in Krate user', () => {
|
|
120
|
+
const config = createAuthProviderConfig({ KRATE_AUTH_COOKIE_NAME: 'krate_session' });
|
|
121
|
+
const cookie = createSessionCookie(config, { provider: 'sso', subject: 'alice-subject', username: 'alice' });
|
|
122
|
+
const cookieValue = cookie.match(/krate_session=([^;]+)/)?.[1];
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(parseSessionCookie(config, cookieValue), {
|
|
125
|
+
cookieName: 'krate_session',
|
|
126
|
+
provider: 'sso',
|
|
127
|
+
subject: 'alice-subject',
|
|
128
|
+
user: 'alice'
|
|
129
|
+
});
|
|
130
|
+
assert.equal(parseSessionCookie(config, 'not-json'), null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('OAuth callbacks and delegated identity auto-register Krate users and mappings', async () => {
|
|
134
|
+
const config = createAuthProviderConfig({
|
|
135
|
+
KRATE_AUTH_GITHUB_ENABLED: 'true',
|
|
136
|
+
KRATE_AUTH_GITHUB_CLIENT_ID: 'gh-client',
|
|
137
|
+
KRATE_AUTH_GITHUB_CLIENT_SECRET: 'gh-secret',
|
|
138
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true'
|
|
139
|
+
});
|
|
140
|
+
const calls = [];
|
|
141
|
+
const profile = await exchangeOAuthCodeForProfile({
|
|
142
|
+
provider: config.providers.github,
|
|
143
|
+
code: 'oauth-code',
|
|
144
|
+
requestUrl: 'https://krate.example.test/api/auth/callback/github',
|
|
145
|
+
fetchImpl: async (url, options = {}) => {
|
|
146
|
+
calls.push({ url, options });
|
|
147
|
+
if (String(url).includes('access_token')) return { ok: true, status: 200, async json() { return { access_token: 'token' }; } };
|
|
148
|
+
return { ok: true, status: 200, async json() { return { id: 42, login: 'alice', email: 'alice@example.com', name: 'Alice' }; } };
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const applied = [];
|
|
152
|
+
const registration = await registerLoginProfile({
|
|
153
|
+
namespace: 'krate-test',
|
|
154
|
+
profile,
|
|
155
|
+
controller: { async applyResource(resource) { applied.push(resource); return { operation: 'apply', resource }; } }
|
|
156
|
+
});
|
|
157
|
+
const delegated = profileFromDelegatedHeaders({ 'x-forwarded-user': 'bob', 'x-forwarded-email': 'bob@example.com', 'x-forwarded-groups': 'krate:repo-admins,team:release' }, config);
|
|
158
|
+
|
|
159
|
+
assert.equal(profile.username, 'alice');
|
|
160
|
+
assert.equal(calls.length, 2);
|
|
161
|
+
assert.deepEqual(applied.map((resource) => resource.kind), ['User', 'IdentityMapping']);
|
|
162
|
+
assert.equal(registration.mapping.spec.repositoryIdentity.username, 'alice');
|
|
163
|
+
assert.equal(delegated.admin, true);
|
|
164
|
+
assert.equal(delegated.email, 'bob@example.com');
|
|
165
|
+
assert.equal(delegated.delegatedIdentitySource, 'proxy-header');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
test('OAuth callback route completes fake SSO exchange and registers Krate identity', async () => {
|
|
170
|
+
const previous = {
|
|
171
|
+
enabled: process.env.KRATE_AUTH_SSO_ENABLED,
|
|
172
|
+
providerName: process.env.KRATE_AUTH_SSO_PROVIDER_NAME,
|
|
173
|
+
clientId: process.env.KRATE_AUTH_SSO_CLIENT_ID,
|
|
174
|
+
clientSecret: process.env.KRATE_AUTH_SSO_CLIENT_SECRET,
|
|
175
|
+
authorizationUrl: process.env.KRATE_AUTH_SSO_AUTHORIZATION_URL,
|
|
176
|
+
tokenUrl: process.env.KRATE_AUTH_SSO_TOKEN_URL,
|
|
177
|
+
userInfoUrl: process.env.KRATE_AUTH_SSO_USERINFO_URL,
|
|
178
|
+
scopes: process.env.KRATE_AUTH_SSO_SCOPES,
|
|
179
|
+
namespace: process.env.KRATE_NAMESPACE
|
|
180
|
+
};
|
|
181
|
+
Object.assign(process.env, {
|
|
182
|
+
KRATE_AUTH_SSO_ENABLED: 'true',
|
|
183
|
+
KRATE_AUTH_SSO_PROVIDER_NAME: 'Test Workspace SSO',
|
|
184
|
+
KRATE_AUTH_SSO_CLIENT_ID: 'fake-client',
|
|
185
|
+
KRATE_AUTH_SSO_CLIENT_SECRET: 'fake-secret',
|
|
186
|
+
KRATE_AUTH_SSO_AUTHORIZATION_URL: 'https://idp.example.test/authorize',
|
|
187
|
+
KRATE_AUTH_SSO_TOKEN_URL: 'https://idp.example.test/token',
|
|
188
|
+
KRATE_AUTH_SSO_USERINFO_URL: 'https://idp.example.test/userinfo',
|
|
189
|
+
KRATE_AUTH_SSO_SCOPES: 'openid profile email groups',
|
|
190
|
+
KRATE_NAMESPACE: 'krate-test'
|
|
191
|
+
});
|
|
192
|
+
const applied = [];
|
|
193
|
+
const fetchCalls = [];
|
|
194
|
+
const controller = { async applyResource(resource) { applied.push(resource); return { operation: 'apply', resource }; } };
|
|
195
|
+
const fetchImpl = async (url, options = {}) => {
|
|
196
|
+
fetchCalls.push({ url: String(url), options });
|
|
197
|
+
if (String(url).endsWith('/token')) {
|
|
198
|
+
const body = new URLSearchParams(String(options.body));
|
|
199
|
+
assert.equal(body.get('code'), 'fake-code');
|
|
200
|
+
assert.equal(body.get('client_id'), 'fake-client');
|
|
201
|
+
assert.equal(body.get('client_secret'), 'fake-secret');
|
|
202
|
+
assert.equal(body.get('redirect_uri'), 'https://krate.example.test/api/auth/callback/sso');
|
|
203
|
+
return { ok: true, status: 200, async json() { return { access_token: 'fake-access-token' }; } };
|
|
204
|
+
}
|
|
205
|
+
assert.equal(options.headers.Authorization, 'Bearer fake-access-token');
|
|
206
|
+
return { ok: true, status: 200, async json() { return { sub: 'user-123', email: 'route@example.test', preferred_username: 'route-alice', name: 'Route Alice', groups: ['krate:repo-admins', 'team:platform'] }; } };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const { createOAuthCallbackHandler } = await import(`../../web/app/api/auth/callback/[provider]/route.js?test=${Date.now()}`);
|
|
211
|
+
const handler = createOAuthCallbackHandler({ controller, fetchImpl });
|
|
212
|
+
const response = await handler(new Request('https://krate.example.test/api/auth/callback/sso?code=fake-code&state=state'), { params: { provider: 'sso' } });
|
|
213
|
+
|
|
214
|
+
assert.equal(response.status, 302);
|
|
215
|
+
assert.equal(response.headers.get('location'), '/orgs/default/people');
|
|
216
|
+
assert.equal(response.headers.get('x-krate-user'), 'route-alice');
|
|
217
|
+
assert.match(response.headers.get('set-cookie'), /krate_session=/);
|
|
218
|
+
assert.deepEqual(fetchCalls.map((call) => call.url), ['https://idp.example.test/token', 'https://idp.example.test/userinfo']);
|
|
219
|
+
assert.deepEqual(applied.map((resource) => resource.kind), ['User', 'IdentityMapping']);
|
|
220
|
+
assert.equal(applied[0].metadata.name, 'route-alice');
|
|
221
|
+
assert.equal(applied[0].spec.admin, true);
|
|
222
|
+
assert.equal(applied[1].spec.provider, 'sso');
|
|
223
|
+
assert.equal(applied[1].spec.repositoryIdentity.username, 'route-alice');
|
|
224
|
+
} finally {
|
|
225
|
+
setOptionalEnv('KRATE_AUTH_SSO_ENABLED', previous.enabled);
|
|
226
|
+
setOptionalEnv('KRATE_AUTH_SSO_PROVIDER_NAME', previous.providerName);
|
|
227
|
+
setOptionalEnv('KRATE_AUTH_SSO_CLIENT_ID', previous.clientId);
|
|
228
|
+
setOptionalEnv('KRATE_AUTH_SSO_CLIENT_SECRET', previous.clientSecret);
|
|
229
|
+
setOptionalEnv('KRATE_AUTH_SSO_AUTHORIZATION_URL', previous.authorizationUrl);
|
|
230
|
+
setOptionalEnv('KRATE_AUTH_SSO_TOKEN_URL', previous.tokenUrl);
|
|
231
|
+
setOptionalEnv('KRATE_AUTH_SSO_USERINFO_URL', previous.userInfoUrl);
|
|
232
|
+
setOptionalEnv('KRATE_AUTH_SSO_SCOPES', previous.scopes);
|
|
233
|
+
setOptionalEnv('KRATE_NAMESPACE', previous.namespace);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('Delegated identity supports localhost development fallback without proxy headers', () => {
|
|
238
|
+
const config = createAuthProviderConfig({
|
|
239
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true',
|
|
240
|
+
KRATE_AUTH_DELEGATED_LOCAL_USER: 'Dev Alice',
|
|
241
|
+
KRATE_AUTH_DELEGATED_LOCAL_EMAIL: 'alice@example.test',
|
|
242
|
+
KRATE_AUTH_DELEGATED_LOCAL_GROUPS: 'krate:repo-admins,team:local'
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const profile = profileFromDelegatedHeaders({}, config, { requestUrl: 'http://localhost:3000/api/auth/delegated' });
|
|
246
|
+
|
|
247
|
+
assert.equal(profile.username, 'dev-alice');
|
|
248
|
+
assert.equal(profile.email, 'alice@example.test');
|
|
249
|
+
assert.equal(profile.admin, true);
|
|
250
|
+
assert.equal(profile.delegatedIdentitySource, 'local-development');
|
|
251
|
+
assert.deepEqual(profile.groups, ['krate:repo-admins', 'team:local']);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('Delegated identity localhost fallback stays disabled for production unless explicitly enabled', () => {
|
|
255
|
+
const config = createAuthProviderConfig({ KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true', NODE_ENV: 'production' });
|
|
256
|
+
|
|
257
|
+
assert.equal(config.delegatedIdentity.localDevelopment.enabled, false);
|
|
258
|
+
assert.throws(
|
|
259
|
+
() => profileFromDelegatedHeaders({}, config, { requestUrl: 'https://krate.example.test/api/auth/delegated' }),
|
|
260
|
+
/Delegated identity header x-forwarded-user is missing/
|
|
261
|
+
);
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => profileFromDelegatedHeaders({}, config, { requestUrl: 'http://localhost:3000/api/auth/delegated' }),
|
|
264
|
+
/Delegated identity header x-forwarded-user is missing/
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const explicitConfig = createAuthProviderConfig({
|
|
268
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true',
|
|
269
|
+
KRATE_AUTH_DELEGATED_LOCAL_DEVELOPMENT: 'true',
|
|
270
|
+
NODE_ENV: 'production'
|
|
271
|
+
});
|
|
272
|
+
const profile = profileFromDelegatedHeaders({}, explicitConfig, { requestUrl: 'http://localhost:3000/api/auth/delegated' });
|
|
273
|
+
assert.equal(profile.username, 'local-developer');
|
|
274
|
+
assert.equal(profile.delegatedIdentitySource, 'local-development');
|
|
275
|
+
const boundAddressProfile = profileFromDelegatedHeaders({}, explicitConfig, { requestUrl: 'http://0.0.0.0:8080/api/auth/delegated' });
|
|
276
|
+
assert.equal(boundAddressProfile.username, 'local-developer');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('Delegated identity route redirects localhost fallback even when Kubernetes registration is unavailable', async () => {
|
|
280
|
+
const previous = {
|
|
281
|
+
delegated: process.env.KRATE_AUTH_DELEGATED_IDENTITY_ENABLED,
|
|
282
|
+
kubectl: process.env.KRATE_KUBECTL,
|
|
283
|
+
timeout: process.env.KRATE_KUBECTL_TIMEOUT_MS
|
|
284
|
+
};
|
|
285
|
+
process.env.KRATE_AUTH_DELEGATED_IDENTITY_ENABLED = 'true';
|
|
286
|
+
process.env.KRATE_KUBECTL = 'krate-missing-kubectl';
|
|
287
|
+
process.env.KRATE_KUBECTL_TIMEOUT_MS = '50';
|
|
288
|
+
try {
|
|
289
|
+
const { GET } = await import(`../../web/app/api/auth/delegated/route.js?test=${Date.now()}`);
|
|
290
|
+
const response = await GET(new Request('http://localhost:3000/api/auth/delegated?user=Route%20Alice&email=route@example.test&groups=krate:developers'));
|
|
291
|
+
|
|
292
|
+
assert.equal(response.status, 302);
|
|
293
|
+
assert.equal(response.headers.get('location'), '/orgs/default/people');
|
|
294
|
+
assert.equal(response.headers.get('x-krate-user'), 'route-alice');
|
|
295
|
+
assert.match(response.headers.get('set-cookie'), /krate_session=/);
|
|
296
|
+
} finally {
|
|
297
|
+
setOptionalEnv('KRATE_AUTH_DELEGATED_IDENTITY_ENABLED', previous.delegated);
|
|
298
|
+
setOptionalEnv('KRATE_KUBECTL', previous.kubectl);
|
|
299
|
+
setOptionalEnv('KRATE_KUBECTL_TIMEOUT_MS', previous.timeout);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('Krate delivery resources surface through controller UI model', () => {
|
|
304
|
+
const model = createControllerUiModel({
|
|
305
|
+
source: 'kubernetes',
|
|
306
|
+
namespace: 'krate-system',
|
|
307
|
+
generatedAt: 'test-time',
|
|
308
|
+
kubectl: { available: true, context: 'kind-krate', errors: [] },
|
|
309
|
+
crds: [{ spec: { group: 'core.oam.dev', names: { plural: 'applications' } } }],
|
|
310
|
+
resources: {
|
|
311
|
+
Organization: [{ apiVersion: 'krate.a5c.ai/v1alpha1', kind: 'Organization', metadata: { name: 'default', namespace: 'krate-system' }, spec: { slug: 'default', namespaceName: 'krate-org-default', displayName: 'Default org' } }],
|
|
312
|
+
KubeVelaApplication: [{ apiVersion: 'core.oam.dev/v1beta1', kind: 'Application', metadata: { name: 'app', namespace: 'krate-org-default', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', components: [{ name: 'web', type: 'webservice' }], workflow: { steps: [{ name: 'deploy', type: 'deploy' }] } }, status: { status: 'running', appliedResources: [{ apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'krate-org-default', name: 'web' }], workflow: { status: 'succeeded', finished: true, appRevision: 'app-v1', steps: [{ name: 'deploy', type: 'deploy', phase: 'succeeded' }] }, services: [{ name: 'web', namespace: 'krate-system', healthy: true, message: 'Ready:1/1', workloadDefinition: { apiVersion: 'apps/v1', kind: 'Deployment' } }] } }],
|
|
313
|
+
KubeVelaApplicationRevision: [{ metadata: { name: 'app-v1', labels: { 'app.oam.dev/name': 'app', 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default' }, status: { succeeded: true } }],
|
|
314
|
+
KubeVelaComponentDefinition: [{ metadata: { name: 'webservice' } }],
|
|
315
|
+
KubeVelaWorkloadDefinition: [{ metadata: { name: 'deployments.apps' } }],
|
|
316
|
+
KubeVelaTraitDefinition: [{ metadata: { name: 'ingress' } }],
|
|
317
|
+
KubeVelaScopeDefinition: [{ metadata: { name: 'healthscopes.core.oam.dev' } }],
|
|
318
|
+
KubeVelaPolicyDefinition: [],
|
|
319
|
+
KubeVelaPolicy: [{ metadata: { name: 'topology', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', type: 'topology' } }],
|
|
320
|
+
KubeVelaWorkflowStepDefinition: [{ metadata: { name: 'deploy' } }],
|
|
321
|
+
KubeVelaWorkflow: [{ metadata: { name: 'app', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default' }, status: { phase: 'running' } }],
|
|
322
|
+
KubeVelaResourceTracker: [{ metadata: { name: 'app-v1-krate-system', labels: { 'app.oam.dev/name': 'app', 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', type: 'versioned' } }]
|
|
323
|
+
},
|
|
324
|
+
events: [],
|
|
325
|
+
permissions: [],
|
|
326
|
+
storage: {},
|
|
327
|
+
commands: []
|
|
328
|
+
});
|
|
329
|
+
assert.equal(model.delivery.installed, true);
|
|
330
|
+
assert.equal(model.delivery.counts.applications, 1);
|
|
331
|
+
assert.deepEqual(model.delivery.capabilityCatalog.components, ['webservice']);
|
|
332
|
+
assert.deepEqual(model.delivery.capabilityCatalog.workloads, ['deployments.apps']);
|
|
333
|
+
assert.deepEqual(model.delivery.capabilityCatalog.scopes, ['healthscopes.core.oam.dev']);
|
|
334
|
+
assert.equal(model.delivery.specVersion, 'v0.3.0');
|
|
335
|
+
assert.equal(model.delivery.counts.releases, 1);
|
|
336
|
+
assert.equal(model.delivery.counts.managedResources, 1);
|
|
337
|
+
assert.equal(model.delivery.applications[0].healthy, true);
|
|
338
|
+
assert.deepEqual(model.delivery.applications[0].appliedResources.map((resource) => `${resource.kind}/${resource.namespace}/${resource.name}`), ['Deployment/krate-org-default/web']);
|
|
339
|
+
assert.equal(model.delivery.applications[0].workflow.status, 'succeeded');
|
|
340
|
+
assert.deepEqual(model.delivery.runtime.managedResources.map((tracker) => tracker.name), ['app-v1-krate-system']);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('control plane keeps CRD config in etcd and aggregated records in Postgres', () => {
|
|
344
|
+
const controlPlane = new ControlPlane();
|
|
345
|
+
controlPlane.create(createResource('Repository', { name: 'app' }, { organizationRef: 'default', visibility: 'private' }), repoAdmin);
|
|
346
|
+
controlPlane.create(createResource('PullRequest', { name: 'pr-1' }, { organizationRef: 'default', repository: 'app', title: 'Improve platform routing' }), developer);
|
|
347
|
+
const report = controlPlane.storageReport();
|
|
348
|
+
assert.deepEqual(report.etcd, ['Repository']);
|
|
349
|
+
assert.deepEqual(report.postgres, ['PullRequest']);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
test('API controller is an application facade over the Kubernetes resource gateway', async () => {
|
|
354
|
+
const calls = [];
|
|
355
|
+
const repository = createResource('Repository', { name: 'app', namespace: 'krate-test' }, { organizationRef: 'default', visibility: 'internal', defaultBranch: 'main' });
|
|
356
|
+
const resourceGateway = {
|
|
357
|
+
role: 'kubernetes-resource-gateway',
|
|
358
|
+
namespace: 'krate-test',
|
|
359
|
+
resourceDefinitions: listResourceDefinitions(),
|
|
360
|
+
async snapshot() {
|
|
361
|
+
calls.push(['snapshot']);
|
|
362
|
+
return { source: 'kubernetes', namespace: 'krate-test', resources: { Repository: [repository] }, commands: [], events: [], permissions: [], storage: {} };
|
|
363
|
+
},
|
|
364
|
+
async list(kind) {
|
|
365
|
+
calls.push(['list', kind]);
|
|
366
|
+
return { items: [repository] };
|
|
367
|
+
},
|
|
368
|
+
async get(kind, name) {
|
|
369
|
+
calls.push(['get', kind, name]);
|
|
370
|
+
return repository;
|
|
371
|
+
},
|
|
372
|
+
async apply(resource) {
|
|
373
|
+
calls.push(['apply', resource.kind]);
|
|
374
|
+
return { operation: 'apply', resource };
|
|
375
|
+
},
|
|
376
|
+
async delete(kind, name) {
|
|
377
|
+
calls.push(['delete', kind, name]);
|
|
378
|
+
return { operation: 'delete', resource: null };
|
|
379
|
+
},
|
|
380
|
+
async createRepository(input) {
|
|
381
|
+
calls.push(['createRepository', input.name]);
|
|
382
|
+
return { operation: 'apply', command: 'kubectl apply -f -', resource: repository };
|
|
383
|
+
},
|
|
384
|
+
watch(resourcePath) {
|
|
385
|
+
calls.push(['watch', resourcePath]);
|
|
386
|
+
return { command: 'kubectl get repositories.krate.a5c.ai --watch -o json' };
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const controller = createKrateApiController({ resourceGateway });
|
|
391
|
+
const snapshot = await controller.snapshot();
|
|
392
|
+
const repositories = await controller.listRepositoriesForForge();
|
|
393
|
+
const view = await controller.getRepositoryForgeView('app');
|
|
394
|
+
const created = await controller.createRepository({ name: 'app', organizationRef: 'default' });
|
|
395
|
+
|
|
396
|
+
assert.equal(controller.role, 'krate-api-controller');
|
|
397
|
+
assert.equal(snapshot.architecture.apiController.role, 'krate-api-controller');
|
|
398
|
+
assert.ok(snapshot.architecture.apiController.mustNotOwn.includes('Kubernetes reconciliation loops'));
|
|
399
|
+
assert.equal(repositories[0].cloneUrl.endsWith('/app.git'), true);
|
|
400
|
+
assert.equal(view.primaryFlow, 'browse-code-open-pr-review-merge');
|
|
401
|
+
assert.equal(view.sections.some((section) => section.id === 'pull-requests'), true);
|
|
402
|
+
assert.equal(created.repository.actions.settings, '/orgs/default/repositories/app/settings');
|
|
403
|
+
assert.deepEqual(calls.map((call) => call[0]), ['snapshot', 'list', 'get', 'createRepository']);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('Kubernetes resource gateway owns Kubernetes operations while reconciler owns status projection', async () => {
|
|
407
|
+
const repository = createResource('Repository', { name: 'app', namespace: 'krate-test' }, { organizationRef: 'default', visibility: 'internal' });
|
|
408
|
+
const delegated = [];
|
|
409
|
+
const resourceClient = {
|
|
410
|
+
role: 'kubernetes-resource-client',
|
|
411
|
+
namespace: 'krate-test',
|
|
412
|
+
resourceDefinitions: listResourceDefinitions(),
|
|
413
|
+
async snapshot() { delegated.push('snapshot'); return { source: 'kubernetes', namespace: 'krate-test', resources: { Repository: [repository] } }; },
|
|
414
|
+
async listResource(kind) { delegated.push(`list:${kind}`); return { items: [repository] }; },
|
|
415
|
+
async getResource(kind, name) { delegated.push(`get:${kind}:${name}`); return repository; },
|
|
416
|
+
async applyResource(resource) { delegated.push(`apply:${resource.kind}`); return { operation: 'apply', resource }; },
|
|
417
|
+
async deleteResource(kind, name) { delegated.push(`delete:${kind}:${name}`); return { operation: 'delete' }; },
|
|
418
|
+
watchResource(resourcePath) { delegated.push(`watch:${resourcePath}`); return { command: 'kubectl get repositories.krate.a5c.ai --watch -o json' }; }
|
|
419
|
+
};
|
|
420
|
+
const gateway = createKubernetesResourceGateway({ resourceClient });
|
|
421
|
+
const reconciler = createKrateKubernetesReconciler({ namespace: 'krate-test' });
|
|
422
|
+
const applied = await gateway.createRepository({ name: 'app', organizationRef: 'default' });
|
|
423
|
+
const plan = reconciler.reconcileRepository(repository);
|
|
424
|
+
|
|
425
|
+
assert.equal(gateway.role, 'kubernetes-resource-gateway');
|
|
426
|
+
assert.ok(gateway.mustNotOwn.includes('Next.js page flow decisions'));
|
|
427
|
+
assert.equal(applied.resource.kind, 'Repository');
|
|
428
|
+
assert.equal(delegated.includes('apply:Repository'), true);
|
|
429
|
+
const client = createKubernetesResourceClient({ namespace: 'krate-test' });
|
|
430
|
+
assert.equal(client.role, 'kubernetes-resource-client');
|
|
431
|
+
assert.equal(client.mustNotOwn.includes('forge DTO composition'), true);
|
|
432
|
+
assert.equal(reconciler.role, 'krate-kubernetes-reconciler');
|
|
433
|
+
assert.ok(reconciler.mustNotOwn.includes('HTTP routes'));
|
|
434
|
+
assert.equal(plan.kind, 'RepositoryReconciliationPlan');
|
|
435
|
+
assert.equal(plan.syncIntents.some((intent) => intent.target === 'git-data-plane'), true);
|
|
436
|
+
assert.equal(reconciler.describeReconciliationScope().resources.some((resource) => resource.kind === 'Repository'), true);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('identity access reconciler projects users permissions and SSH keys into Krate status', () => {
|
|
440
|
+
const user = createResource('User', { name: 'alice', namespace: 'krate-test' }, { organizationRef: 'default', email: 'alice@example.com', username: 'alice', teams: ['maintainers'], admin: false });
|
|
441
|
+
const disabledUser = createResource('User', { name: 'bob', namespace: 'krate-test' }, { organizationRef: 'default', email: 'bob@example.com', username: 'bob', disabled: true });
|
|
442
|
+
const permission = createResource('RepositoryPermission', { name: 'app-alice', namespace: 'krate-test' }, { organizationRef: 'default', repository: 'app', subject: 'alice', subjectKind: 'user', permission: 'write', revoked: true });
|
|
443
|
+
const sshKey = createResource('SSHKey', { name: 'alice-laptop', namespace: 'krate-test' }, { organizationRef: 'default', owner: 'alice', title: 'laptop', scope: 'user', key: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKrateTest alice@example.com' });
|
|
444
|
+
const reconciler = createKrateKubernetesReconciler({ namespace: 'krate-test' });
|
|
445
|
+
const activePlan = reconciler.reconcileIdentityAccess(user);
|
|
446
|
+
const disabledPlan = identityAccessReconciliationPlan(disabledUser, { namespace: 'krate-test' });
|
|
447
|
+
const permissionPlan = reconciler.reconcileIdentityAccess(permission);
|
|
448
|
+
const sshPlan = reconciler.reconcileIdentityAccess(sshKey);
|
|
449
|
+
const aggregate = reconciler.reconcileIdentityAccessResources({ User: [user, disabledUser], RepositoryPermission: [permission], SSHKey: [sshKey] });
|
|
450
|
+
|
|
451
|
+
assert.equal(activePlan.desiredStatus.phase, 'Active');
|
|
452
|
+
assert.ok(activePlan.desiredStatus.groups.includes('team:maintainers'));
|
|
453
|
+
assert.ok(activePlan.syncIntents.some((intent) => intent.action === 'ensure-repository-user'));
|
|
454
|
+
assert.equal(disabledPlan.desiredStatus.phase, 'Disabled');
|
|
455
|
+
assert.ok(disabledPlan.syncIntents.some((intent) => intent.action === 'suspend-user'));
|
|
456
|
+
assert.equal(permissionPlan.desiredStatus.phase, 'Revoked');
|
|
457
|
+
assert.equal(permissionPlan.syncIntents[0].action, 'revoke-repository-permission');
|
|
458
|
+
assert.match(sshPlan.desiredStatus.fingerprint, /^sha256:[A-Za-z0-9_-]+$/);
|
|
459
|
+
assert.equal(sshPlan.syncIntents[0].action, 'sync-ssh-key');
|
|
460
|
+
assert.deepEqual(aggregate.counts, { User: 2, RepositoryPermission: 1, SSHKey: 1 });
|
|
461
|
+
assert.equal(aggregate.desiredStatuses.filter((status) => status.phase === 'Revoked').length, 1);
|
|
462
|
+
assert.ok(aggregate.syncIntents.some((intent) => intent.action === 'revoke-repository-permission'));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('controller boundary source keeps kubectl out of API facade and UI flow out of reconciler', () => {
|
|
466
|
+
const apiSource = readFileSync('src/api-controller.js', 'utf8');
|
|
467
|
+
const gatewaySource = readFileSync('src/kubernetes-resource-gateway.js', 'utf8');
|
|
468
|
+
const kubernetesSource = readFileSync('src/kubernetes-controller.js', 'utf8');
|
|
469
|
+
|
|
470
|
+
assert.equal(apiSource.includes('node:child_process'), false);
|
|
471
|
+
assert.equal(apiSource.includes('spawnSync'), false);
|
|
472
|
+
assert.equal(apiSource.includes('KRATE_API_CONTROLLER_BOUNDARY'), true);
|
|
473
|
+
assert.equal(gatewaySource.includes('KUBERNETES_RESOURCE_GATEWAY_BOUNDARY'), true);
|
|
474
|
+
assert.equal(kubernetesSource.includes('KRATE_KUBERNETES_RECONCILER_BOUNDARY'), true);
|
|
475
|
+
assert.equal(kubernetesSource.includes('Next.js page flows'), true);
|
|
476
|
+
assert.equal(kubernetesSource.includes('/repositories/${repo}'), false);
|
|
477
|
+
});test('RBAC and admission enforce Kubernetes-style policy while supporting audit mode', () => {
|
|
478
|
+
const controlPlane = new ControlPlane();
|
|
479
|
+
controlPlane.addAdmissionPolicy(createAdmissionPolicy({ name: 'descriptive-pr-title', mode: 'enforce', match: ({ resource }) => resource.kind === 'PullRequest', validate: ({ resource }) => resource.spec.title?.length >= 8, message: 'title too short' }));
|
|
480
|
+
assert.throws(() => controlPlane.create(createResource('PullRequest', { name: 'bad' }, { organizationRef: 'default', repository: 'app', title: 'tiny' }), developer), /title too short/);
|
|
481
|
+
const accepted = controlPlane.create(createResource('PullRequest', { name: 'good' }, { organizationRef: 'default', repository: 'app', title: 'Implement policy preview flow' }), developer);
|
|
482
|
+
assert.equal(accepted.status.storage, 'postgres');
|
|
483
|
+
assert.throws(() => controlPlane.create(createResource('Repository', { name: 'denied' }, { organizationRef: 'default', visibility: 'private' }), developer), /RBAC denied/);
|
|
484
|
+
const policyModel = createPolicyRolloutModel({ name: 'descriptive-pr-title', mode: 'audit' });
|
|
485
|
+
assert.deepEqual(policyModel.rollout, ['preview', 'audit', 'enforce']);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('watch events and audit-mode admission warnings remain inspectable', () => {
|
|
489
|
+
const controlPlane = new ControlPlane();
|
|
490
|
+
const events = [];
|
|
491
|
+
const stop = controlPlane.watch('PullRequest', (event) => events.push(event));
|
|
492
|
+
controlPlane.addAdmissionPolicy(createAdmissionPolicy({ name: 'audit-title', mode: 'audit', match: ({ resource }) => resource.kind === 'PullRequest', validate: ({ resource }) => resource.spec.title?.includes('audit'), message: 'title should mention audit' }));
|
|
493
|
+
const pr = controlPlane.create(createResource('PullRequest', { name: 'audit-pr' }, { organizationRef: 'default', repository: 'app', title: 'Implement warning visibility' }), developer);
|
|
494
|
+
stop();
|
|
495
|
+
assert.equal(pr.status.storage, 'postgres');
|
|
496
|
+
assert.equal(events.length, 1);
|
|
497
|
+
assert.equal(controlPlane.auditLog.at(-1).warnings[0].policy, 'audit-title');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('Gitea data plane keeps receive-pack warm and protected', () => {
|
|
501
|
+
const controlPlane = new ControlPlane();
|
|
502
|
+
const git = new GiteaGitService({ controlPlane, stores: [new GiteaRepositoryStore({ name: 'gitea-primary', receivePackReady: true })] });
|
|
503
|
+
const repository = git.createRepository({ name: 'app' }, repoAdmin);
|
|
504
|
+
controlPlane.create(createResource('BranchProtection', { name: 'main', namespace: 'krate-org-default' }, { organizationRef: 'default', refs: ['refs/heads/main'], requirePullRequest: true }), repoAdmin);
|
|
505
|
+
const route = git.route('app');
|
|
506
|
+
assert.equal(route.backend, 'gitea');
|
|
507
|
+
assert.equal(route.receivePackReady, true);
|
|
508
|
+
assert.equal(repository.spec.gitHosting.backend, 'gitea');
|
|
509
|
+
assert.equal(repository.spec.gitHosting.integrationPlan.backend, 'gitea');
|
|
510
|
+
assert.ok(repository.spec.gitHosting.integrationPlan.operations.some((step) => step.action === 'addDeployKey'));
|
|
511
|
+
assert.throws(() => git.receivePack({ repository: 'app', ref: 'refs/heads/main', newRev: '1'.repeat(40), actor: developer }), /requires pull request/);
|
|
512
|
+
const event = git.receivePack({ repository: 'app', ref: 'refs/heads/main', newRev: '2'.repeat(40), actor: repoAdmin });
|
|
513
|
+
assert.equal(event.backend, 'gitea');
|
|
514
|
+
assert.equal(event.store, 'gitea-primary');
|
|
515
|
+
assert.match(event.remoteUrl, /gitea/);
|
|
516
|
+
controlPlane.create(createResource('RefPolicy', { name: 'deny-internal', namespace: 'krate-org-default' }, { organizationRef: 'default', deny: ['refs/internal/'] }), repoAdmin);
|
|
517
|
+
assert.throws(() => git.receivePack({ repository: 'app', ref: 'refs/internal/secret', newRev: '3'.repeat(40), actor: repoAdmin }), /RefPolicy deny-internal denied/);
|
|
518
|
+
assert.equal(git.recordObject({ repository: 'app', key: 'lfs/abc', size: 128 }).storage, 'object-storage');
|
|
519
|
+
assert.equal(git.enqueueSearchIndex({ repository: 'app', commit: '2'.repeat(40), paths: ['README.md'] }).status, 'queued');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
test('runner scheduler isolates fork PR jobs and supports resume reruns', () => {
|
|
524
|
+
const controlPlane = new ControlPlane();
|
|
525
|
+
const runners = new RunnerScheduler({ controlPlane });
|
|
526
|
+
const pool = runners.createRunnerPool({ name: 'trusted-linux', warmReplicas: 1, maxReplicas: 3 }, platform);
|
|
527
|
+
assert.equal(runners.planReplicas(pool, 9), 3);
|
|
528
|
+
const run = runners.startPipeline({ name: 'pr-2', repository: 'app', ref: 'refs/pull/2/head', actor: developer, fork: true, steps: ['checkout', 'test', 'publish'] }, developer);
|
|
529
|
+
assert.equal(run.jobs[0].spec.serviceAccount.scopes.secrets, false);
|
|
530
|
+
assert.equal(run.jobs[0].spec.serviceAccount.scopes.clusterApi, false);
|
|
531
|
+
const rerun = runners.rerunFromStep(run.pipeline, 'test', developer);
|
|
532
|
+
assert.equal(rerun.pipeline.spec.resumeFrom, 'test');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('webhooks are signed, stored, inspectable, and replayable', () => {
|
|
536
|
+
const controlPlane = new ControlPlane();
|
|
537
|
+
const bus = new WebhookBus({ controlPlane, secret: 'test-secret' });
|
|
538
|
+
bus.subscribe({ name: 'chatops', url: 'https://hooks.example.test', events: ['pullrequest.created'] }, repoAdmin);
|
|
539
|
+
const first = bus.deliver({ subscriptionName: 'chatops', eventType: 'pullrequest.created', payload: { pr: 'pr-1' } }, repoAdmin);
|
|
540
|
+
const failed = bus.deliver({ subscriptionName: 'chatops', eventType: 'pullrequest.created', payload: { pr: 'pr-2' }, response: { status: 503, body: 'downstream unavailable' } }, repoAdmin);
|
|
541
|
+
const replay = bus.replay(first, repoAdmin);
|
|
542
|
+
assert.equal(first.kind, 'WebhookDelivery');
|
|
543
|
+
assert.equal(first.status.phase, 'Delivered');
|
|
544
|
+
assert.equal(bus.inspect(failed).phase, 'Failed');
|
|
545
|
+
assert.equal(bus.inspect(failed).replayable, true);
|
|
546
|
+
assert.notEqual(first.spec.signature, replay.spec.signature);
|
|
547
|
+
assert.equal(controlPlane.list('WebhookDelivery').items.length, 3);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
test('component catalog and lifecycle snapshot cover every implementation area', () => {
|
|
552
|
+
const demo = createKrateMvpDemo();
|
|
553
|
+
demo.smoke = runSmokeAssertions(demo);
|
|
554
|
+
const catalog = createKrateComponentCatalog(demo);
|
|
555
|
+
const snapshot = createKrateLifecycleSnapshot(demo, { packageInfo: { version: 'test' }, generatedAt: 'test-time' });
|
|
556
|
+
assert.equal(catalog.length, 7);
|
|
557
|
+
assert.equal(snapshot.status, 'ready-for-local-development');
|
|
558
|
+
assert.ok(snapshot.components.some((component) => component.id === 'control-plane' && component.implemented));
|
|
559
|
+
assert.ok(snapshot.components.some((component) => component.id === 'runners-ci' && component.resources.includes('Pipeline')));
|
|
560
|
+
assert.ok(snapshot.operations.releaseGates.includes('e2e package lifecycle tests'));
|
|
561
|
+
assert.ok(snapshot.validation.every((assertion) => assertion.passed));
|
|
562
|
+
});
|
|
563
|
+
test('MVP smoke path covers resources, UI, operations, and release gates', () => {
|
|
564
|
+
const demo = createKrateMvpDemo();
|
|
565
|
+
const smoke = runSmokeAssertions(demo);
|
|
566
|
+
assert.equal(smoke.ok, true);
|
|
567
|
+
assert.ok(demo.ui.dashboard.excellentFlows.includes('Open and review a PR'));
|
|
568
|
+
assert.ok(demo.operations.backupPlan.restoreOrder.includes('Postgres'));
|
|
569
|
+
assert.ok(demo.operations.backupPlan.restoreOrder.includes('repository data'));
|
|
570
|
+
assert.ok(demo.operations.installManifests.includes('krate-gitea'));
|
|
571
|
+
assert.ok(demo.operations.installManifests.includes('kind: Application'));
|
|
572
|
+
assert.equal(demo.resources.repository.spec.gitHosting.backend, 'gitea');
|
|
573
|
+
assert.ok(demo.operations.releaseGates.includes('docs and ontology coverage'));
|
|
574
|
+
assert.ok(demo.operations.observability.alerts.includes('runner saturation'));
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
import { execFileSync } from 'node:child_process';
|
|
578
|
+
import { readFileSync } from 'node:fs';
|
|
579
|
+
|
|
580
|
+
test('handoff summary and package metadata expose runnable lifecycle surfaces', () => {
|
|
581
|
+
const packageInfo = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
582
|
+
const demo = createKrateMvpDemo();
|
|
583
|
+
demo.smoke = runSmokeAssertions(demo);
|
|
584
|
+
const summary = createKrateHandoffSummary(demo, { packageInfo, generatedAt: 'test-time' });
|
|
585
|
+
assert.equal(packageInfo.main, './src/index.js');
|
|
586
|
+
assert.equal(packageInfo.bin['krate-demo'], './bin/krate-demo.mjs');
|
|
587
|
+
assert.equal(packageInfo.scripts.demo, 'node bin/krate-demo.mjs');
|
|
588
|
+
assert.equal(summary.entrypoints.cli, './bin/krate-demo.mjs');
|
|
589
|
+
assert.equal(summary.commands.check, 'npm run check');
|
|
590
|
+
assert.ok(summary.docs.includes('docs/architecture-spec.md'));
|
|
591
|
+
assert.ok(summary.releaseGates.includes('docs and ontology coverage'));
|
|
592
|
+
assert.equal(summary.smoke.ok, true);
|
|
593
|
+
assert.equal(summary.generatedAt, 'test-time');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('CLI demo emits machine-readable MVP handoff summary', () => {
|
|
597
|
+
const output = execFileSync(process.execPath, ['bin/krate-demo.mjs', '--json'], { encoding: 'utf8' });
|
|
598
|
+
const summary = JSON.parse(output);
|
|
599
|
+
assert.equal(summary.project, 'Krate');
|
|
600
|
+
assert.equal(summary.entrypoints.library, './src/index.js');
|
|
601
|
+
assert.equal(summary.commands.demo, 'npm run demo');
|
|
602
|
+
assert.equal(summary.smoke.ok, true);
|
|
603
|
+
assert.ok(summary.excellentFlows.includes('Open and review a PR'));
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
import { KrateRuntime, createKrateHttpServer, createKrateRuntime } from '../src/index.js';
|
|
609
|
+
|
|
610
|
+
test('runtime executes PR checks review and merge lifecycle with persisted resources', () => {
|
|
611
|
+
const runtime = createKrateRuntime();
|
|
612
|
+
const created = runtime.createPullRequest({ repository: 'krate-demo', title: 'Ship runnable lifecycle implementation' });
|
|
613
|
+
assert.equal(created.pullRequest.status.phase, 'Open');
|
|
614
|
+
assert.throws(() => runtime.mergePullRequest({ pullRequest: created.pullRequest.metadata.name }), /not mergeable/);
|
|
615
|
+
const checks = runtime.completePipeline({ pipeline: created.pipeline.metadata.name });
|
|
616
|
+
assert.equal(checks.pipeline.status.phase, 'Succeeded');
|
|
617
|
+
const review = runtime.addReview({ pullRequest: created.pullRequest.metadata.name, decision: 'approved' });
|
|
618
|
+
assert.equal(review.status.phase, 'Approved');
|
|
619
|
+
const merged = runtime.mergePullRequest({ pullRequest: created.pullRequest.metadata.name });
|
|
620
|
+
assert.equal(merged.pullRequest.status.phase, 'Merged');
|
|
621
|
+
assert.equal(merged.delivery.kind, 'WebhookDelivery');
|
|
622
|
+
const snapshot = runtime.snapshot();
|
|
623
|
+
assert.equal(snapshot.resources.PullRequest[0].status.phase, 'Merged');
|
|
624
|
+
assert.ok(snapshot.events.some((event) => event.type === 'git.receive-pack' && event.resource.backend === 'gitea'));
|
|
625
|
+
assert.equal(snapshot.resources.Repository[0].spec.gitHosting.backend, 'gitea');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('runtime snapshot export restores durable state and continues lifecycle', () => {
|
|
629
|
+
const runtime = createKrateRuntime();
|
|
630
|
+
const created = runtime.createPullRequest({ repository: 'krate-demo', title: 'Persist runtime lifecycle across restart' });
|
|
631
|
+
runtime.completePipeline({ pipeline: created.pipeline.metadata.name });
|
|
632
|
+
runtime.addReview({ pullRequest: created.pullRequest.metadata.name, decision: 'approved' });
|
|
633
|
+
runtime.mergePullRequest({ pullRequest: created.pullRequest.metadata.name });
|
|
634
|
+
const exported = runtime.exportSnapshot();
|
|
635
|
+
const restored = KrateRuntime.fromSnapshot(exported);
|
|
636
|
+
const restoredSnapshot = restored.snapshot();
|
|
637
|
+
assert.equal(restoredSnapshot.resources.PullRequest[0].status.phase, 'Merged');
|
|
638
|
+
assert.equal(restoredSnapshot.resources.WebhookDelivery.length, runtime.snapshot().resources.WebhookDelivery.length);
|
|
639
|
+
const next = restored.createPullRequest({ repository: 'krate-demo', title: 'Continue after snapshot import' });
|
|
640
|
+
assert.equal(next.pullRequest.metadata.name, 'pr-2');
|
|
641
|
+
assert.equal(restored.exportSnapshot().controlPlane.stores.postgres.some((resource) => resource.kind === 'PullRequest' && resource.metadata.name === 'pr-2'), true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test('runtime snapshot preserves git object storage and search index data', () => {
|
|
645
|
+
const runtime = createKrateRuntime();
|
|
646
|
+
runtime.git.recordObject({ repository: 'krate-demo', key: 'lfs/blob.bin', size: 512, mediaType: 'application/octet-stream' });
|
|
647
|
+
runtime.git.enqueueSearchIndex({ repository: 'krate-demo', commit: 'a'.repeat(40), paths: ['src/index.js', 'README.md'] });
|
|
648
|
+
|
|
649
|
+
const exported = runtime.exportSnapshot();
|
|
650
|
+
assert.equal(exported.git.backend.type, 'gitea');
|
|
651
|
+
assert.equal(exported.git.integrationPlans['krate-demo'].backend, 'gitea');
|
|
652
|
+
assert.equal(exported.git.stores[0].objects['krate-demo'][0].key, 'lfs/blob.bin');
|
|
653
|
+
assert.deepEqual(exported.git.stores[0].searchIndex['krate-demo'][0].paths, ['src/index.js', 'README.md']);
|
|
654
|
+
|
|
655
|
+
const restored = KrateRuntime.fromSnapshot(exported);
|
|
656
|
+
const restoredGit = restored.exportSnapshot().git;
|
|
657
|
+
assert.equal(restoredGit.stores[0].objects['krate-demo'][0].size, 512);
|
|
658
|
+
assert.equal(restoredGit.stores[0].searchIndex['krate-demo'][0].commit, 'a'.repeat(40));
|
|
659
|
+
const restoredRoute = restored.git.uploadPack({ repository: 'krate-demo' });
|
|
660
|
+
assert.equal(restoredRoute.cacheable, true);
|
|
661
|
+
assert.equal(restoredRoute.backend, 'gitea');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('HTTP API exposes executable runtime endpoints', async () => {
|
|
665
|
+
const runtime = createKrateRuntime();
|
|
666
|
+
const server = createKrateHttpServer({ runtime });
|
|
667
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
668
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
669
|
+
try {
|
|
670
|
+
const created = await fetchJson(`${base}/api/orgs/default/pullrequests`, { method: 'POST', body: { repository: 'krate-demo', title: 'Exercise HTTP lifecycle runtime' } });
|
|
671
|
+
assert.equal(created.pullRequest.kind, 'PullRequest');
|
|
672
|
+
await fetchJson(`${base}/api/orgs/default/pullrequests/${created.pullRequest.metadata.name}/checks/complete`, { method: 'POST', body: {} });
|
|
673
|
+
await fetchJson(`${base}/api/orgs/default/pullrequests/${created.pullRequest.metadata.name}/reviews`, { method: 'POST', body: { decision: 'approved' } });
|
|
674
|
+
const merged = await fetchJson(`${base}/api/orgs/default/pullrequests/${created.pullRequest.metadata.name}/merge`, { method: 'POST', body: {} });
|
|
675
|
+
assert.equal(merged.pullRequest.status.phase, 'Merged');
|
|
676
|
+
const snapshot = await fetchJson(`${base}/api/orgs/default/snapshot`);
|
|
677
|
+
assert.ok(snapshot.resources.WebhookDelivery.length >= 2);
|
|
678
|
+
const restored = await fetchJson(`${base}/api/orgs/default/snapshot`, { method: 'POST', body: snapshot.export });
|
|
679
|
+
assert.equal(restored.resources.PullRequest[0].status.phase, 'Merged');
|
|
680
|
+
assert.equal(restored.export.controlPlane.stores.postgres.some((resource) => resource.kind === 'PullRequest'), true);
|
|
681
|
+
} finally {
|
|
682
|
+
await new Promise((resolve) => server.close(resolve));
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
test('HTTP API exposes Git object storage and search indexing runtime endpoints', async () => {
|
|
688
|
+
const runtime = createKrateRuntime();
|
|
689
|
+
const server = createKrateHttpServer({ runtime });
|
|
690
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
691
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
692
|
+
try {
|
|
693
|
+
const object = await fetchJson(`${base}/api/orgs/default/repositories/krate-demo/objects`, {
|
|
694
|
+
method: 'POST',
|
|
695
|
+
body: { key: 'lfs/http.bin', size: 1024, mediaType: 'application/octet-stream' }
|
|
696
|
+
});
|
|
697
|
+
assert.equal(object.repository, 'krate-demo');
|
|
698
|
+
assert.equal(object.storage, 'object-storage');
|
|
699
|
+
|
|
700
|
+
const index = await fetchJson(`${base}/api/orgs/default/repositories/krate-demo/search-index`, {
|
|
701
|
+
method: 'POST',
|
|
702
|
+
body: { commit: 'b'.repeat(40), paths: ['src/http-server.js', 'README.md'] }
|
|
703
|
+
});
|
|
704
|
+
assert.equal(index.status, 'queued');
|
|
705
|
+
assert.deepEqual(index.paths, ['src/http-server.js', 'README.md']);
|
|
706
|
+
|
|
707
|
+
const snapshot = await fetchJson(`${base}/api/orgs/default/snapshot`);
|
|
708
|
+
assert.equal(snapshot.export.git.stores[0].objects['krate-demo'][0].key, 'lfs/http.bin');
|
|
709
|
+
assert.equal(snapshot.export.git.stores[0].searchIndex['krate-demo'][0].commit, 'b'.repeat(40));
|
|
710
|
+
} finally {
|
|
711
|
+
await new Promise((resolve) => server.close(resolve));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
async function fetchJson(url, { method = 'GET', body } = {}) {
|
|
716
|
+
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
|
717
|
+
const value = await response.json();
|
|
718
|
+
if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(value)}`);
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
|