@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
package/src/auth.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createResource, clone } from './resource-model.js';
|
|
3
|
+
import { mapOidcIdentity } from './identity-policy.js';
|
|
4
|
+
|
|
5
|
+
const defaultScopes = {
|
|
6
|
+
github: 'read:user user:email',
|
|
7
|
+
sso: 'openid profile email groups'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createAuthProviderConfig(env = process.env) {
|
|
11
|
+
const githubEnabled = env.KRATE_AUTH_GITHUB_ENABLED !== 'false';
|
|
12
|
+
const ssoEnabled = env.KRATE_AUTH_SSO_ENABLED === 'true';
|
|
13
|
+
return {
|
|
14
|
+
session: { cookieName: env.KRATE_AUTH_COOKIE_NAME || 'krate_session' },
|
|
15
|
+
delegatedIdentity: {
|
|
16
|
+
enabled: env.KRATE_AUTH_DELEGATED_IDENTITY_ENABLED === 'true',
|
|
17
|
+
userHeader: env.KRATE_AUTH_DELEGATED_USER_HEADER || 'x-forwarded-user',
|
|
18
|
+
groupsHeader: env.KRATE_AUTH_DELEGATED_GROUPS_HEADER || 'x-forwarded-groups',
|
|
19
|
+
emailHeader: env.KRATE_AUTH_DELEGATED_EMAIL_HEADER || 'x-forwarded-email',
|
|
20
|
+
localDevelopment: {
|
|
21
|
+
enabled: delegatedLocalDevelopmentEnabled(env),
|
|
22
|
+
user: env.KRATE_AUTH_DELEGATED_LOCAL_USER || 'local-developer',
|
|
23
|
+
email: env.KRATE_AUTH_DELEGATED_LOCAL_EMAIL || '',
|
|
24
|
+
groups: env.KRATE_AUTH_DELEGATED_LOCAL_GROUPS || 'krate:repo-admins'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
providers: {
|
|
28
|
+
github: {
|
|
29
|
+
id: 'github',
|
|
30
|
+
label: 'GitHub',
|
|
31
|
+
type: 'github',
|
|
32
|
+
enabled: githubEnabled,
|
|
33
|
+
clientId: env.KRATE_AUTH_GITHUB_CLIENT_ID || '',
|
|
34
|
+
clientSecret: env.KRATE_AUTH_GITHUB_CLIENT_SECRET || '',
|
|
35
|
+
clientSecretConfigured: Boolean(env.KRATE_AUTH_GITHUB_CLIENT_SECRET),
|
|
36
|
+
authorizationUrl: env.KRATE_AUTH_GITHUB_AUTHORIZATION_URL || 'https://github.com/login/oauth/authorize',
|
|
37
|
+
tokenUrl: env.KRATE_AUTH_GITHUB_TOKEN_URL || 'https://github.com/login/oauth/access_token',
|
|
38
|
+
userInfoUrl: env.KRATE_AUTH_GITHUB_USERINFO_URL || 'https://api.github.com/user',
|
|
39
|
+
scopes: env.KRATE_AUTH_GITHUB_SCOPES || defaultScopes.github
|
|
40
|
+
},
|
|
41
|
+
sso: {
|
|
42
|
+
id: 'sso',
|
|
43
|
+
label: env.KRATE_AUTH_SSO_PROVIDER_NAME || 'Workspace SSO',
|
|
44
|
+
type: 'oidc',
|
|
45
|
+
enabled: ssoEnabled,
|
|
46
|
+
issuerUrl: env.KRATE_AUTH_SSO_ISSUER_URL || '',
|
|
47
|
+
clientId: env.KRATE_AUTH_SSO_CLIENT_ID || '',
|
|
48
|
+
clientSecret: env.KRATE_AUTH_SSO_CLIENT_SECRET || '',
|
|
49
|
+
clientSecretConfigured: Boolean(env.KRATE_AUTH_SSO_CLIENT_SECRET),
|
|
50
|
+
authorizationUrl: env.KRATE_AUTH_SSO_AUTHORIZATION_URL || '',
|
|
51
|
+
tokenUrl: env.KRATE_AUTH_SSO_TOKEN_URL || '',
|
|
52
|
+
userInfoUrl: env.KRATE_AUTH_SSO_USERINFO_URL || '',
|
|
53
|
+
scopes: env.KRATE_AUTH_SSO_SCOPES || defaultScopes.sso
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listEnabledAuthProviders(config = createAuthProviderConfig()) {
|
|
60
|
+
return Object.values(config.providers).filter((provider) => provider.enabled && provider.clientId && provider.authorizationUrl);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildAuthorizationRedirect({ provider, requestUrl, state = cryptoSafeState() }) {
|
|
64
|
+
if (!provider?.enabled) throw new Error(`${provider?.label || 'Provider'} sign-in is disabled`);
|
|
65
|
+
if (!provider.clientId) throw new Error(`${provider.label} client id is not configured`);
|
|
66
|
+
if (!provider.authorizationUrl) throw new Error(`${provider.label} authorization endpoint is not configured`);
|
|
67
|
+
const request = new URL(requestUrl || 'http://localhost/login');
|
|
68
|
+
const redirectUri = new URL(`/api/auth/callback/${provider.id}`, `${request.protocol}//${request.host}`);
|
|
69
|
+
const target = new URL(provider.authorizationUrl);
|
|
70
|
+
target.searchParams.set('response_type', 'code');
|
|
71
|
+
target.searchParams.set('client_id', provider.clientId);
|
|
72
|
+
target.searchParams.set('redirect_uri', redirectUri.toString());
|
|
73
|
+
target.searchParams.set('scope', provider.scopes || 'openid profile email');
|
|
74
|
+
target.searchParams.set('state', state);
|
|
75
|
+
return { url: target.toString(), state, redirectUri: redirectUri.toString() };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
export async function exchangeOAuthCodeForProfile({ provider, code, requestUrl, fetchImpl = globalThis.fetch }) {
|
|
80
|
+
if (!provider?.enabled) throw new Error(`${provider?.label || 'Provider'} sign-in is disabled`);
|
|
81
|
+
if (!code) throw new Error('authorization code is required');
|
|
82
|
+
if (!provider.tokenUrl || !provider.userInfoUrl) throw new Error(`${provider.label} token and profile endpoints are required`);
|
|
83
|
+
if (!provider.clientId || !provider.clientSecret) throw new Error(`${provider.label} client credentials are not configured`);
|
|
84
|
+
const request = new URL(requestUrl || 'http://localhost/login');
|
|
85
|
+
const redirectUri = new URL(`/api/auth/callback/${provider.id}`, `${request.protocol}//${request.host}`).toString();
|
|
86
|
+
const tokenResponse = await fetchImpl(provider.tokenUrl, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
89
|
+
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: provider.clientId, client_secret: provider.clientSecret })
|
|
90
|
+
});
|
|
91
|
+
if (!tokenResponse.ok) throw new Error(`${provider.label} token exchange failed with ${tokenResponse.status}`);
|
|
92
|
+
const token = await tokenResponse.json();
|
|
93
|
+
const accessToken = token.access_token;
|
|
94
|
+
if (!accessToken) throw new Error(`${provider.label} did not return an access token`);
|
|
95
|
+
const profileResponse = await fetchImpl(provider.userInfoUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` } });
|
|
96
|
+
if (!profileResponse.ok) throw new Error(`${provider.label} profile lookup failed with ${profileResponse.status}`);
|
|
97
|
+
const profile = await profileResponse.json();
|
|
98
|
+
return normalizeProviderProfile(provider, profile);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeProviderProfile(provider, profile = {}) {
|
|
102
|
+
if (provider.id === 'github' || provider.type === 'github') {
|
|
103
|
+
const username = profile.login || profile.username || profile.name;
|
|
104
|
+
return {
|
|
105
|
+
provider: provider.id,
|
|
106
|
+
subject: String(profile.id || username || profile.email),
|
|
107
|
+
email: profile.email || (username ? `${username}@users.noreply.github.com` : undefined),
|
|
108
|
+
displayName: profile.name || username || profile.email,
|
|
109
|
+
username,
|
|
110
|
+
groups: [],
|
|
111
|
+
teams: [],
|
|
112
|
+
admin: false
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const groups = Array.isArray(profile.groups) ? profile.groups : String(profile.groups || '').split(',').map((group) => group.trim()).filter(Boolean);
|
|
116
|
+
return {
|
|
117
|
+
provider: provider.id,
|
|
118
|
+
subject: profile.sub || profile.id || profile.email,
|
|
119
|
+
email: profile.email,
|
|
120
|
+
displayName: profile.name || profile.preferred_username || profile.email,
|
|
121
|
+
username: profile.preferred_username || profile.username || profile.email,
|
|
122
|
+
groups,
|
|
123
|
+
teams: [],
|
|
124
|
+
admin: groups.includes('krate:platform-engineers') || groups.includes('krate:repo-admins')
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function profileFromDelegatedHeaders(headers, config = createAuthProviderConfig(), options = {}) {
|
|
129
|
+
if (!config.delegatedIdentity.enabled) throw new Error('Delegated identity sign-in is disabled');
|
|
130
|
+
const getHeader = (name) => typeof headers.get === 'function' ? headers.get(name) : headers[name] || headers[name.toLowerCase()];
|
|
131
|
+
const localProfile = localDelegatedDevelopmentProfile(config, options);
|
|
132
|
+
const headerUser = getHeader(config.delegatedIdentity.userHeader);
|
|
133
|
+
const user = headerUser || localProfile.user;
|
|
134
|
+
if (!user) throw new Error(`Delegated identity header ${config.delegatedIdentity.userHeader} is missing`);
|
|
135
|
+
const email = getHeader(config.delegatedIdentity.emailHeader) || localProfile.email || (String(user).includes('@') ? user : undefined);
|
|
136
|
+
const groups = String(getHeader(config.delegatedIdentity.groupsHeader) || localProfile.groups || '').split(',').map((group) => group.trim()).filter(Boolean);
|
|
137
|
+
return { provider: 'delegated', subject: user, email, displayName: user, username: normalizeName(user), groups, teams: [], admin: groups.includes('krate:platform-engineers') || groups.includes('krate:repo-admins'), delegatedIdentitySource: headerUser ? 'proxy-header' : 'local-development' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function registerLoginProfile({ controller, namespace, profile }) {
|
|
141
|
+
const org = process.env.KRATE_ADMIN_ORG || process.env.KRATE_ORG || 'default';
|
|
142
|
+
const orgNamespace = namespace || `krate-org-${org}`;
|
|
143
|
+
const adminUsername = process.env.KRATE_ADMIN_USERNAME || '';
|
|
144
|
+
const isBootstrapAdmin = adminUsername && (profile.username === adminUsername || profile.email === adminUsername || normalizeName(profile.email || '') === adminUsername || normalizeName(profile.username || '') === adminUsername);
|
|
145
|
+
const mapped = mapLoginProfileToKrateIdentity({ ...profile, namespace: orgNamespace, organizationRef: org, admin: isBootstrapAdmin || profile.admin });
|
|
146
|
+
const userResult = await controller.applyResource(mapped.user);
|
|
147
|
+
const mappingResult = await controller.applyResource(mapped.mapping);
|
|
148
|
+
return { ...mapped, userResult, mappingResult };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createSessionCookie(config, profile, options = {}) {
|
|
152
|
+
const secret = options.secret || process.env.KRATE_SESSION_SECRET || '';
|
|
153
|
+
const payload = Buffer.from(JSON.stringify({ provider: profile.provider, subject: profile.subject, user: profile.username || profile.email })).toString('base64url');
|
|
154
|
+
let value;
|
|
155
|
+
if (secret) {
|
|
156
|
+
const signature = createHmac('sha256', secret).update(payload).digest('base64url');
|
|
157
|
+
value = `${payload}.${signature}`;
|
|
158
|
+
} else {
|
|
159
|
+
value = payload;
|
|
160
|
+
}
|
|
161
|
+
return `${config.session.cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseSessionCookie(config, cookieValue, options = {}) {
|
|
165
|
+
if (!cookieValue || typeof cookieValue !== 'string') return null;
|
|
166
|
+
const secret = options.secret || process.env.KRATE_SESSION_SECRET || '';
|
|
167
|
+
try {
|
|
168
|
+
const dotIndex = cookieValue.indexOf('.');
|
|
169
|
+
const isSigned = dotIndex !== -1;
|
|
170
|
+
|
|
171
|
+
if (isSigned && !secret) {
|
|
172
|
+
// Signed cookie but no secret to verify — reject
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!isSigned && secret) {
|
|
177
|
+
// No signature present but secret is configured — reject (could be tampered or unsigned legacy)
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let payload;
|
|
182
|
+
if (isSigned && secret) {
|
|
183
|
+
payload = cookieValue.slice(0, dotIndex);
|
|
184
|
+
const receivedSig = cookieValue.slice(dotIndex + 1);
|
|
185
|
+
const expectedSig = createHmac('sha256', secret).update(payload).digest('base64url');
|
|
186
|
+
// Constant-time comparison
|
|
187
|
+
const expected = Buffer.from(expectedSig, 'base64url');
|
|
188
|
+
const received = Buffer.from(receivedSig, 'base64url');
|
|
189
|
+
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
payload = cookieValue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
197
|
+
const user = typeof session.user === 'string' ? session.user.trim() : '';
|
|
198
|
+
const subject = typeof session.subject === 'string' ? session.subject.trim() : '';
|
|
199
|
+
const provider = typeof session.provider === 'string' ? session.provider.trim() : '';
|
|
200
|
+
if (!user && !subject) return null;
|
|
201
|
+
return {
|
|
202
|
+
cookieName: config.session.cookieName,
|
|
203
|
+
provider: provider || 'krate',
|
|
204
|
+
subject: subject || user,
|
|
205
|
+
user: user || subject
|
|
206
|
+
};
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function mapLoginProfileToKrateIdentity({ provider = 'sso', subject, email, displayName, username, groups = [], teams = [], admin = false, namespace = 'krate-org-default', organizationRef = 'default' }) {
|
|
213
|
+
const userName = username || normalizeName(email || subject || displayName || 'user');
|
|
214
|
+
const krateGroups = [...new Set(['krate:users', admin ? 'krate:platform-engineers' : 'krate:developers', ...groups])];
|
|
215
|
+
const identity = mapOidcIdentity({ subject, email, groups: krateGroups });
|
|
216
|
+
const user = createResource('User', { name: userName, namespace, labels: { role: admin ? 'admin' : 'member' } }, {
|
|
217
|
+
organizationRef,
|
|
218
|
+
displayName: displayName || userName,
|
|
219
|
+
email,
|
|
220
|
+
username: userName,
|
|
221
|
+
teams,
|
|
222
|
+
admin,
|
|
223
|
+
disabled: false
|
|
224
|
+
}, { phase: 'Active', lastLoginProvider: provider, groups: identity.groups });
|
|
225
|
+
const mapping = createResource('IdentityMapping', { name: `${provider}-${userName}`, namespace }, {
|
|
226
|
+
organizationRef,
|
|
227
|
+
user: userName,
|
|
228
|
+
provider,
|
|
229
|
+
subject: subject || email,
|
|
230
|
+
email,
|
|
231
|
+
workspaceIdentity: { name: identity.name, uid: identity.uid, groups: identity.groups },
|
|
232
|
+
repositoryIdentity: { username: userName, email }
|
|
233
|
+
}, { phase: 'Synced' });
|
|
234
|
+
return { identity, user, mapping };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createInviteResource({ email, role = 'member', teams = [], invitedBy = 'admin', namespace = 'krate-org-default', organizationRef = 'default', expiresInDays = 7 }) {
|
|
238
|
+
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString();
|
|
239
|
+
return createResource('Invite', { name: normalizeName(email), namespace, labels: { role } }, { organizationRef, email, role, teams, invitedBy, expiresAt }, { phase: 'Pending' });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function createTeamResource({ name, displayName = name, members = [], maintainers = [], repositoryGrants = [], namespace = 'krate-org-default', organizationRef = 'default' }) {
|
|
243
|
+
return createResource('Team', { name, namespace }, { organizationRef, displayName, members, maintainers, repositoryGrants }, { phase: 'Active', memberCount: members.length });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function createAuthProviderResources(config = createAuthProviderConfig(), namespace = 'krate-org-default', organizationRef = 'default') {
|
|
247
|
+
return Object.values(config.providers).map((provider) => createResource('AuthProvider', { name: provider.id, namespace }, {
|
|
248
|
+
organizationRef,
|
|
249
|
+
type: provider.type,
|
|
250
|
+
label: provider.label,
|
|
251
|
+
enabled: provider.enabled,
|
|
252
|
+
scopes: provider.scopes,
|
|
253
|
+
delegatedIdentity: clone(publicDelegatedIdentityConfig(config.delegatedIdentity))
|
|
254
|
+
}, { phase: provider.enabled ? 'Configured' : 'Disabled', clientConfigured: Boolean(provider.clientId) }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function identityBackendSyncPlan({ users = [], teams = [], invites = [], mappings = [], permissions = [], sshKeys = [] } = {}) {
|
|
258
|
+
return {
|
|
259
|
+
users: users.map((user) => ({ action: 'ensure-user', user: user.metadata?.name, email: user.spec?.email, disabled: Boolean(user.spec?.disabled) })),
|
|
260
|
+
teams: teams.map((team) => ({ action: 'ensure-team', team: team.metadata?.name, members: team.spec?.members || [], maintainers: team.spec?.maintainers || [] })),
|
|
261
|
+
invites: invites.map((invite) => ({ action: 'send-invite', email: invite.spec?.email, teams: invite.spec?.teams || [], role: invite.spec?.role || 'member' })),
|
|
262
|
+
mappings: mappings.map((mapping) => ({ action: 'link-identity', user: mapping.spec?.user, provider: mapping.spec?.provider, repositoryIdentity: mapping.spec?.repositoryIdentity })),
|
|
263
|
+
permissions: permissions.map((permission) => ({ action: 'sync-repository-permission', repository: permission.spec?.repository, subject: permission.spec?.subject, subjectKind: permission.spec?.subjectKind || 'user', permission: permission.spec?.permission })),
|
|
264
|
+
sshKeys: sshKeys.map((key) => ({ action: 'sync-ssh-key', owner: key.spec?.owner, repository: key.spec?.repository, scope: key.spec?.scope, title: key.spec?.title }))
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeName(value) {
|
|
269
|
+
return String(value || 'user').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) || 'user';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function localDelegatedDevelopmentProfile(config, options = {}) {
|
|
273
|
+
if (!isLocalDevelopmentRequest(options.requestUrl, options.env)) return {};
|
|
274
|
+
const localConfig = config.delegatedIdentity.localDevelopment || {};
|
|
275
|
+
if (!localConfig.enabled) return {};
|
|
276
|
+
const request = new URL(options.requestUrl);
|
|
277
|
+
const user = request.searchParams.get('user') || request.searchParams.get('username') || localConfig.user;
|
|
278
|
+
const email = request.searchParams.get('email') || localConfig.email;
|
|
279
|
+
const groups = request.searchParams.get('groups') || localConfig.groups;
|
|
280
|
+
return { user, email, groups };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function delegatedLocalDevelopmentEnabled(env = process.env) {
|
|
284
|
+
if (env.KRATE_AUTH_DELEGATED_LOCAL_DEVELOPMENT === 'true') return true;
|
|
285
|
+
if (env.KRATE_AUTH_DELEGATED_LOCAL_DEVELOPMENT === 'false') return false;
|
|
286
|
+
return env.NODE_ENV !== 'production';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isLocalDevelopmentRequest(requestUrl) {
|
|
290
|
+
if (!requestUrl) return false;
|
|
291
|
+
const { hostname } = new URL(requestUrl);
|
|
292
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0' || hostname === '::1' || hostname === '::';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function publicDelegatedIdentityConfig(delegatedIdentity) {
|
|
296
|
+
return {
|
|
297
|
+
enabled: delegatedIdentity.enabled,
|
|
298
|
+
userHeader: delegatedIdentity.userHeader,
|
|
299
|
+
groupsHeader: delegatedIdentity.groupsHeader,
|
|
300
|
+
emailHeader: delegatedIdentity.emailHeader,
|
|
301
|
+
localDevelopment: clone(delegatedIdentity.localDevelopment)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function cryptoSafeState() {
|
|
306
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
307
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const KRATE_COMPONENTS = Object.freeze([
|
|
2
|
+
{ id: 'control-plane', title: 'Control Plane', area: 'api', resources: ['Repository', 'BranchProtection', 'RefPolicy'], evidence: ['src/control-plane.js', 'charts/krate/crds'] },
|
|
3
|
+
{ id: 'data-plane', title: 'Repository Data Plane', area: 'git', resources: ['Repository'], evidence: ['src/data-plane.js', 'src/gitea-backend.js', 'charts/krate/templates/gitea.yaml'] },
|
|
4
|
+
{ id: 'identity-policy', title: 'Identity, RBAC, and Policy', area: 'security', resources: ['BranchProtection', 'RefPolicy'], evidence: ['src/identity-policy.js', 'examples/policy-kyverno-pr-title.yaml'] },
|
|
5
|
+
{ id: 'runners-ci', title: 'Runners and CI', area: 'automation', resources: ['RunnerPool', 'Pipeline', 'Job'], evidence: ['src/runners-ci.js', 'docs/components/runners-ci.md'] },
|
|
6
|
+
{ id: 'hooks-events', title: 'Hooks and Events', area: 'integrations', resources: ['WebhookSubscription', 'WebhookDelivery'], evidence: ['src/hooks-events.js', 'docs/components/hooks-events.md'] },
|
|
7
|
+
{ id: 'operations-publishing', title: 'Operations and Publishing', area: 'release', resources: ['APIService', 'Deployment'], evidence: ['src/operations.js', 'charts/krate', 'scripts/setup-minikube.mjs'] },
|
|
8
|
+
{ id: 'web-ui', title: 'Web UI', area: 'experience', resources: ['View'], evidence: ['src/web-ui.js', 'public/index.html'] }
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function createKrateComponentCatalog(demo) {
|
|
12
|
+
const resourceKinds = new Set(Object.values(demo.resources).flatMap((value) => value?.kind ? [value.kind] : [value?.pipeline?.kind, ...(value?.jobs || []).map((job) => job.kind)]).filter(Boolean));
|
|
13
|
+
return KRATE_COMPONENTS.map((component) => ({
|
|
14
|
+
...component,
|
|
15
|
+
implemented: component.resources.some((kind) => resourceKinds.has(kind)) || component.id === 'operations-publishing' || component.id === 'web-ui',
|
|
16
|
+
resourceCount: component.resources.filter((kind) => resourceKinds.has(kind)).length,
|
|
17
|
+
docs: `docs/components/${component.id}.md`
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createKrateLifecycleSnapshot(demo, { packageInfo = {}, generatedAt = new Date().toISOString() } = {}) {
|
|
22
|
+
const catalog = createKrateComponentCatalog(demo);
|
|
23
|
+
const smoke = demo.smoke || { ok: true, assertions: [] };
|
|
24
|
+
return {
|
|
25
|
+
project: 'Krate',
|
|
26
|
+
version: packageInfo.version || '0.1.0',
|
|
27
|
+
generatedAt,
|
|
28
|
+
status: smoke.ok && catalog.every((component) => component.implemented) ? 'ready-for-local-development' : 'needs-attention',
|
|
29
|
+
components: catalog,
|
|
30
|
+
resources: Object.fromEntries(Object.entries(demo.resources).map(([name, value]) => [name, value?.kind || value?.pipeline?.kind || 'workflow'])),
|
|
31
|
+
storage: demo.controlPlane.storageReport(),
|
|
32
|
+
flows: demo.ui.dashboard.excellentFlows,
|
|
33
|
+
operations: {
|
|
34
|
+
chart: demo.operations.chartPackage.chart,
|
|
35
|
+
setup: demo.operations.localSetup.script,
|
|
36
|
+
releaseGates: demo.operations.releaseGates,
|
|
37
|
+
observability: demo.operations.observability
|
|
38
|
+
},
|
|
39
|
+
validation: smoke.assertions.map(([name, passed]) => ({ name, passed }))
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { clone, matchLabels, resourceKey, storageClassForKind, toKubernetesList, validateResource } from './resource-model.js';
|
|
2
|
+
import { defaultAuthorizer, evaluateAdmission } from './identity-policy.js';
|
|
3
|
+
|
|
4
|
+
export const STORAGE_BOUNDARY_DESCRIPTIONS = { etcd: 'Kubernetes etcd CRD config', postgres: 'Aggregated API Postgres records' };
|
|
5
|
+
|
|
6
|
+
export class ControlPlane {
|
|
7
|
+
constructor({ authorizer = defaultAuthorizer(), admissionPolicies = [] } = {}) {
|
|
8
|
+
this.authorizer = authorizer;
|
|
9
|
+
this.admissionPolicies = admissionPolicies;
|
|
10
|
+
this.stores = { etcd: new Map(), postgres: new Map() };
|
|
11
|
+
this.auditLog = [];
|
|
12
|
+
this.events = [];
|
|
13
|
+
this.watchers = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
addAdmissionPolicy(policy) { this.admissionPolicies.push(policy); }
|
|
17
|
+
create(resource, user) { return this.#mutate('create', resource, user); }
|
|
18
|
+
update(resource, user) { return this.#mutate('update', resource, user); }
|
|
19
|
+
|
|
20
|
+
patchStatus(kind, namespace, name, statusPatch, user) {
|
|
21
|
+
const existing = this.get(kind, namespace, name);
|
|
22
|
+
if (!existing) throw new Error(`${kind}/${namespace}/${name} not found`);
|
|
23
|
+
const next = clone(existing);
|
|
24
|
+
next.status = { ...next.status, ...clone(statusPatch) };
|
|
25
|
+
return this.#mutate('update', next, user, { statusOnly: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(kind, namespace = 'default', name) {
|
|
29
|
+
const storage = storageClassForKind(kind);
|
|
30
|
+
return clone(this.stores[storage].get(`${kind}/${namespace}/${name}`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
list(kind, { namespace, labels } = {}) {
|
|
34
|
+
const storage = storageClassForKind(kind);
|
|
35
|
+
const items = [...this.stores[storage].values()]
|
|
36
|
+
.filter((resource) => resource.kind === kind)
|
|
37
|
+
.filter((resource) => !namespace || resource.metadata.namespace === namespace)
|
|
38
|
+
.filter((resource) => !labels || matchLabels(resource, labels))
|
|
39
|
+
.map(clone);
|
|
40
|
+
return toKubernetesList(kind, items);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
watch(kind, handler) {
|
|
44
|
+
if (!this.watchers.has(kind)) this.watchers.set(kind, new Set());
|
|
45
|
+
this.watchers.get(kind).add(handler);
|
|
46
|
+
return () => this.watchers.get(kind)?.delete(handler);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
storageReport() {
|
|
50
|
+
return {
|
|
51
|
+
etcd: [...this.stores.etcd.values()].map((resource) => resource.kind),
|
|
52
|
+
postgres: [...this.stores.postgres.values()].map((resource) => resource.kind)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exportSnapshot() {
|
|
57
|
+
return {
|
|
58
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
59
|
+
kind: 'ControlPlaneSnapshot',
|
|
60
|
+
stores: {
|
|
61
|
+
etcd: [...this.stores.etcd.values()].map(clone),
|
|
62
|
+
postgres: [...this.stores.postgres.values()].map(clone)
|
|
63
|
+
},
|
|
64
|
+
auditLog: clone(this.auditLog),
|
|
65
|
+
events: clone(this.events)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
importSnapshot(snapshot) {
|
|
70
|
+
if (!snapshot || typeof snapshot !== 'object') throw new Error('snapshot must be an object');
|
|
71
|
+
const stores = snapshot.stores || {};
|
|
72
|
+
const nextStores = { etcd: new Map(), postgres: new Map() };
|
|
73
|
+
for (const storage of ['etcd', 'postgres']) {
|
|
74
|
+
for (const resource of stores[storage] || []) {
|
|
75
|
+
const valid = validateResource(clone(resource));
|
|
76
|
+
const expectedStorage = storageClassForKind(valid.kind);
|
|
77
|
+
if (expectedStorage !== storage) throw new Error(`${valid.kind} belongs in ${expectedStorage}, not ${storage}`);
|
|
78
|
+
valid.status.storage = storage;
|
|
79
|
+
nextStores[storage].set(`${valid.kind}/${valid.metadata.namespace}/${valid.metadata.name}`, clone(valid));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.stores = nextStores;
|
|
83
|
+
this.auditLog = clone(snapshot.auditLog || []);
|
|
84
|
+
this.events = clone(snapshot.events || []);
|
|
85
|
+
this.#emit({ type: 'snapshot.imported', storage: 'control-plane', resource: createSnapshotResource(this.exportSnapshot()), audit: null });
|
|
86
|
+
return this.exportSnapshot();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#mutate(operation, resource, user, options = {}) {
|
|
90
|
+
const candidate = validateResource(clone(resource));
|
|
91
|
+
const namespace = candidate.metadata.namespace;
|
|
92
|
+
const verb = options.statusOnly ? 'update' : operation;
|
|
93
|
+
if (!this.authorizer.can(user, verb, candidate.kind, namespace)) {
|
|
94
|
+
throw new Error(`RBAC denied ${user?.name || 'anonymous'} ${verb} ${candidate.kind}`);
|
|
95
|
+
}
|
|
96
|
+
const admission = evaluateAdmission(this.admissionPolicies, { operation, resource: candidate, user, options });
|
|
97
|
+
const auditEntry = {
|
|
98
|
+
at: new Date().toISOString(),
|
|
99
|
+
operation,
|
|
100
|
+
user: user?.name || 'anonymous',
|
|
101
|
+
groups: user?.groups || [],
|
|
102
|
+
resource: resourceKey(candidate),
|
|
103
|
+
warnings: admission.warnings,
|
|
104
|
+
allowed: admission.allowed
|
|
105
|
+
};
|
|
106
|
+
this.auditLog.push(auditEntry);
|
|
107
|
+
if (!admission.allowed) {
|
|
108
|
+
const messages = admission.violations.map((violation) => violation.message).join('; ');
|
|
109
|
+
throw new Error(`Admission denied ${candidate.kind}: ${messages}`);
|
|
110
|
+
}
|
|
111
|
+
const storage = storageClassForKind(candidate.kind);
|
|
112
|
+
const key = `${candidate.kind}/${namespace}/${candidate.metadata.name}`;
|
|
113
|
+
candidate.metadata.resourceVersion = String((Number(this.stores[storage].get(key)?.metadata?.resourceVersion || 0) || 0) + 1);
|
|
114
|
+
candidate.status.storage = storage;
|
|
115
|
+
this.stores[storage].set(key, clone(candidate));
|
|
116
|
+
this.#emit({ type: operation, storage, resource: candidate, audit: auditEntry });
|
|
117
|
+
return clone(candidate);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#emit(event) {
|
|
121
|
+
const publicEvent = { ...event, resource: clone(event.resource) };
|
|
122
|
+
this.events.push(publicEvent);
|
|
123
|
+
for (const handler of this.watchers.get(event.resource.kind) || []) handler(publicEvent);
|
|
124
|
+
for (const handler of this.watchers.get('*') || []) handler(publicEvent);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createSnapshotResource(snapshot) {
|
|
129
|
+
return {
|
|
130
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
131
|
+
kind: 'ControlPlaneSnapshot',
|
|
132
|
+
metadata: { namespace: 'default', name: 'latest', labels: {}, annotations: {}, resourceVersion: '1' },
|
|
133
|
+
spec: { resourceCounts: Object.fromEntries(Object.entries(snapshot.stores).map(([storage, resources]) => [storage, resources.length])) },
|
|
134
|
+
status: { storage: 'control-plane', phase: 'Imported' }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createControllerUiModel } from './controller-ui.js';
|
|
2
|
+
import { createKrateApiController } from './api-controller.js';
|
|
3
|
+
import { createKubernetesResourceGateway } from './kubernetes-resource-gateway.js';
|
|
4
|
+
import { clearSnapshotCache, staleWhileRevalidate } from './snapshot-cache.js';
|
|
5
|
+
import { getControllerSnapshotAsync } from './kubernetes-controller-async.js';
|
|
6
|
+
|
|
7
|
+
export { clearSnapshotCache };
|
|
8
|
+
|
|
9
|
+
const CONTROLLER_REQUEST_TIMEOUT_MS = Number(process.env.KRATE_CONTROLLER_REQUEST_TIMEOUT_MS || 5_000);
|
|
10
|
+
|
|
11
|
+
export async function fetchControllerUiModel({ controllerUrl = process.env.KRATE_CONTROLLER_URL, fetchImpl = globalThis.fetch, controller = createKrateApiController({ resourceGateway: createKubernetesResourceGateway() }), organization = process.env.KRATE_ORG || null, localFallback = true, requestTimeoutMs = CONTROLLER_REQUEST_TIMEOUT_MS, useCache = true, swrOptions = {} } = {}) {
|
|
12
|
+
const revalidateFn = async () => {
|
|
13
|
+
if (controllerUrl) {
|
|
14
|
+
try {
|
|
15
|
+
const target = new URL('/api/controller', controllerUrl);
|
|
16
|
+
if (organization) target.searchParams.set('org', organization);
|
|
17
|
+
const signal = requestTimeoutMs > 0 && globalThis.AbortSignal?.timeout ? AbortSignal.timeout(requestTimeoutMs) : undefined;
|
|
18
|
+
const response = await fetchImpl(target, { cache: 'no-store', ...(signal ? { signal } : {}) });
|
|
19
|
+
if (!response.ok) throw new Error(`controller API ${response.status}`);
|
|
20
|
+
return await response.json();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return createControllerUiModel({
|
|
23
|
+
source: 'kubernetes',
|
|
24
|
+
namespace: process.env.KRATE_NAMESPACE || 'krate-system',
|
|
25
|
+
kubectl: { available: false, context: null, errors: [error.message] },
|
|
26
|
+
resources: {}, crds: [], events: [], permissions: [], storage: {}, commands: []
|
|
27
|
+
}, { organization });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!localFallback) return unavailableControllerModel('KRATE_CONTROLLER_URL is not configured', organization);
|
|
31
|
+
return fallbackControllerModel(controller, null, organization);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (!useCache) return revalidateFn();
|
|
35
|
+
return staleWhileRevalidate(organization, revalidateFn, swrOptions);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fallbackControllerModel(controller, connectionError = null, organization = null) {
|
|
39
|
+
try {
|
|
40
|
+
const snapshot = await getControllerSnapshotAsync().catch(() => controller.snapshot());
|
|
41
|
+
const model = createControllerUiModel(snapshot, { organization });
|
|
42
|
+
if (connectionError) model.controller.connection.errors = [connectionError.message, ...(model.controller.connection.errors || [])];
|
|
43
|
+
return model;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return createControllerUiModel({
|
|
46
|
+
source: 'kubernetes',
|
|
47
|
+
namespace: process.env.KRATE_NAMESPACE || 'krate-system',
|
|
48
|
+
kubectl: { available: false, context: null, errors: [connectionError?.message, error.message].filter(Boolean) },
|
|
49
|
+
resources: {},
|
|
50
|
+
crds: [],
|
|
51
|
+
events: [],
|
|
52
|
+
permissions: [],
|
|
53
|
+
storage: {},
|
|
54
|
+
commands: []
|
|
55
|
+
}, { organization });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unavailableControllerModel(messages, organization = null) {
|
|
60
|
+
const errors = Array.isArray(messages) ? messages : [messages];
|
|
61
|
+
return createControllerUiModel({
|
|
62
|
+
source: 'kubernetes',
|
|
63
|
+
namespace: process.env.KRATE_NAMESPACE || 'krate-system',
|
|
64
|
+
kubectl: { available: false, context: null, errors: errors.filter(Boolean) },
|
|
65
|
+
resources: {},
|
|
66
|
+
crds: [],
|
|
67
|
+
events: [],
|
|
68
|
+
permissions: [],
|
|
69
|
+
storage: {},
|
|
70
|
+
commands: []
|
|
71
|
+
}, { organization });
|
|
72
|
+
}
|