@r8s/paperclip 0.2.0 → 0.2.1
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/dist/index.d.ts +155 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +246 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -2
- package/__tests__/paperclip.test.ts +0 -450
- package/examples/basic.tsx +0 -8
- package/src/index.tsx +0 -278
- package/tsconfig.json +0 -15
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { type DatabaseProps } from '@r8s/recipes';
|
|
2
|
+
export interface PaperclipProps {
|
|
3
|
+
/** Resource name / Instance CR name (defaults to 'paperclip') */
|
|
4
|
+
name?: string;
|
|
5
|
+
/** Kubernetes namespace (inherited from <Platform> unless set) */
|
|
6
|
+
namespace?: string;
|
|
7
|
+
/**
|
|
8
|
+
* App image tag (defaults to 'sso-oidc' — the Berget fork branch build
|
|
9
|
+
* for SSO/OIDC work; a named moving tag, Always-pulled on purpose).
|
|
10
|
+
*/
|
|
11
|
+
version?: string;
|
|
12
|
+
/** Image repository (defaults to the Berget fork) */
|
|
13
|
+
repository?: string;
|
|
14
|
+
/** Public hostname for the web app and API (required) */
|
|
15
|
+
host: string;
|
|
16
|
+
/**
|
|
17
|
+
* Manual image pull secrets for the private ghcr image (defaults to
|
|
18
|
+
* ['ghcr-pull-secret'] — a fine-grained PAT read:packages Secret that
|
|
19
|
+
* is deliberately managed OUTSIDE Flux: rotate it by delete+recreate)
|
|
20
|
+
*/
|
|
21
|
+
pullSecrets?: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Operator chart version for the paperclip-operator (defaults to
|
|
24
|
+
* '0.19.0'). The operator owns the StatefulSet/PVC/ingress/probes —
|
|
25
|
+
* the Instance CR is the entire app definition.
|
|
26
|
+
*/
|
|
27
|
+
operatorVersion?: string;
|
|
28
|
+
/**
|
|
29
|
+
* CNPG cluster name holding paperclip's data (defaults to 'paperclip-db').
|
|
30
|
+
* Credentials are CNPG-managed: the Instance references the generated
|
|
31
|
+
* `<db>-app` secret's `fqdn-uri` key (requires CNPG ≥ 1.20).
|
|
32
|
+
*/
|
|
33
|
+
dbName?: string;
|
|
34
|
+
/** Number of CNPG instances (defaults to 2) */
|
|
35
|
+
dbInstances?: number;
|
|
36
|
+
/** CNPG data volume size (defaults to '20Gi') */
|
|
37
|
+
dbStorage?: string;
|
|
38
|
+
/** CNPG storage class (defaults to cluster default) */
|
|
39
|
+
dbStorageClass?: string;
|
|
40
|
+
/** CNPG backup configuration passed through to the Database recipe (continuous WAL + scheduled base backups to Scaleway in facit) */
|
|
41
|
+
backup?: DatabaseProps['backup'];
|
|
42
|
+
/**
|
|
43
|
+
* App-native database backups (sql dumps on the persistence volume).
|
|
44
|
+
* Defaults to facit: enabled, hourly, 7 days retention.
|
|
45
|
+
*/
|
|
46
|
+
appBackup?: {
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
intervalMinutes?: number;
|
|
49
|
+
retentionDays?: number;
|
|
50
|
+
} | false;
|
|
51
|
+
/**
|
|
52
|
+
* Operator-managed persistence volume at /paperclip (storage dir
|
|
53
|
+
* /paperclip/storage, native backups /paperclip/backups).
|
|
54
|
+
* Defaults to 10Gi; storageClass optional.
|
|
55
|
+
*/
|
|
56
|
+
storage?: {
|
|
57
|
+
size?: string;
|
|
58
|
+
storageClass?: string;
|
|
59
|
+
} | false;
|
|
60
|
+
/** Heartbeat scheduler (defaults to facit: every 30s) */
|
|
61
|
+
heartbeat?: {
|
|
62
|
+
enabled?: boolean;
|
|
63
|
+
intervalMS?: number;
|
|
64
|
+
} | false;
|
|
65
|
+
/**
|
|
66
|
+
* LLM access via the Berget gateway. `baseUrl` defaults to
|
|
67
|
+
* https://api.berget.ai/v1. The API key is provisioned through the
|
|
68
|
+
* Platform secrets backend (path `<path>/<name>/berget-ai`, key
|
|
69
|
+
* `api-key`) unless `apiKeySecretName` references a pre-created
|
|
70
|
+
* Secret (key: api-key). Rotation restarts the StatefulSet.
|
|
71
|
+
*/
|
|
72
|
+
llm?: {
|
|
73
|
+
baseUrl?: string;
|
|
74
|
+
path?: string;
|
|
75
|
+
refreshAfter?: string;
|
|
76
|
+
};
|
|
77
|
+
/** Reference a pre-created LLM API key Secret (key: api-key) instead of backend provisioning */
|
|
78
|
+
apiKeySecretName?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Better Auth secret — session signing. Provisioned through the
|
|
81
|
+
* Platform secrets backend (path `<path>/<name>/app`, key
|
|
82
|
+
* `better-auth-secret`) unless `secretsName` references a pre-created
|
|
83
|
+
* Secret (key: better-auth-secret). Rotation restarts the StatefulSet.
|
|
84
|
+
*/
|
|
85
|
+
auth?: {
|
|
86
|
+
path?: string;
|
|
87
|
+
refreshAfter?: string;
|
|
88
|
+
disableSignUp?: boolean;
|
|
89
|
+
};
|
|
90
|
+
/** Reference a pre-created app secrets bundle (key: better-auth-secret) instead of backend provisioning */
|
|
91
|
+
secretsName?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Berget model catalog env vars (OPENCODE_CONFIG_CONTENT +
|
|
94
|
+
* PAPERCLIP_ADAPTER_MODELS) wiring Paperclip to the Berget gateway.
|
|
95
|
+
* Defaults to the facit catalog (Kimi K3/K2.6, GLM-5.2/4.7, GPT-OSS
|
|
96
|
+
* 120B, Mistral Medium/Small, Qwen3.8 27B, Gemma 4 31B). Pass `false`
|
|
97
|
+
* to omit both vars.
|
|
98
|
+
*/
|
|
99
|
+
modelCatalog?: {
|
|
100
|
+
opencodeConfig?: string;
|
|
101
|
+
adapterModels?: string;
|
|
102
|
+
} | false;
|
|
103
|
+
/** Extra ingress annotations merged over the facit defaults (body-size 50m, 300s timeouts, ssl-redirect) */
|
|
104
|
+
ingressAnnotations?: Record<string, string>;
|
|
105
|
+
/** Additional hostnames accepted by the deployment (defaults to [host]) */
|
|
106
|
+
allowedHostnames?: string[];
|
|
107
|
+
/** Requested resources for the app StatefulSet (defaults to facit: 512Mi/250m → 12Gi/2) */
|
|
108
|
+
resources?: {
|
|
109
|
+
requests?: {
|
|
110
|
+
cpu?: string;
|
|
111
|
+
memory?: string;
|
|
112
|
+
};
|
|
113
|
+
limits?: {
|
|
114
|
+
cpu?: string;
|
|
115
|
+
memory?: string;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/** TLS secret for ingress (defaults to `${name}-tls` via letsencrypt-prod) */
|
|
119
|
+
tls?: {
|
|
120
|
+
secretName: string;
|
|
121
|
+
clusterIssuer: string;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Paperclip — Berget's agent platform (tasks, documents, agent orchestration),
|
|
126
|
+
* facit-aligned on the paperclip-operator.
|
|
127
|
+
*
|
|
128
|
+
* @title Paperclip
|
|
129
|
+
* @category Agent Platforms
|
|
130
|
+
*
|
|
131
|
+
* The operator owns the workload: app instance → StatefulSet with PVC,
|
|
132
|
+
* ingress, probes, security context, heartbeat and app-native backups.
|
|
133
|
+
* This package declares the operator and owns everything AROUND it:
|
|
134
|
+
* - the external CNPG cluster (`credentialsMode: 'cnpg'` — the Instance
|
|
135
|
+
* references the operator-generated `<db>-app` `fqdn-uri` like facit)
|
|
136
|
+
* - the app secrets (better-auth) + LLM gateway key via the Platform
|
|
137
|
+
* secrets backend with rotation restart of the StatefulSet
|
|
138
|
+
* - the `paperclip.inc/v1alpha1` `Instance` CR with facit spec
|
|
139
|
+
*
|
|
140
|
+
* The image is private: `pullSecrets` defaults to `['ghcr-pull-secret']`
|
|
141
|
+
* — a fine-grained PAT (read:packages) Secret managed manually outside
|
|
142
|
+
* Flux (rotate by delete+recreate).
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* import { Platform } from '@r8s/recipes'
|
|
146
|
+
* import { Paperclip } from '@r8s/paperclip'
|
|
147
|
+
*
|
|
148
|
+
* export default (
|
|
149
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'secret', path: 'paperclip' }}>
|
|
150
|
+
* <Paperclip host="paperclip.example.com" />
|
|
151
|
+
* </Platform>
|
|
152
|
+
* )
|
|
153
|
+
*/
|
|
154
|
+
export declare function Paperclip(props: PaperclipProps): import("@r8s/core").r8sElement;
|
|
155
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAML,KAAK,aAAa,EACnB,MAAM,cAAc,CAAA;AAGrB,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uDAAuD;IACvD,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qIAAqI;IACrI,MAAM,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAA;IAChC;;;OAGG;IACH,SAAS,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IAC3F;;;;OAIG;IACH,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IAC1D,yDAAyD;IACzD,SAAS,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IAC9D;;;;;;OAMG;IACH,GAAG,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAChE,gGAAgG;IAChG,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;;OAKG;IACH,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IACxE,2GAA2G;IAC3G,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IAC1E,4GAA4G;IAC5G,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC3C,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC3B,2FAA2F;IAC3F,SAAS,CAAC,EAAE;QACV,QAAQ,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;QAC5C,MAAM,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAC3C,CAAA;IACD,8EAA8E;IAC9E,GAAG,CAAC,EAAE;QACJ,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;KACtB,CAAA;CACF;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,kCAiQ9C"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Paperclip = Paperclip;
|
|
4
|
+
const core_1 = require("@r8s/core");
|
|
5
|
+
const defaults_1 = require("@r8s/core/defaults");
|
|
6
|
+
const recipes_1 = require("@r8s/recipes");
|
|
7
|
+
const DEFAULT_RESOURCES = {
|
|
8
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
9
|
+
limits: { memory: '12Gi', cpu: '2' },
|
|
10
|
+
};
|
|
11
|
+
const DEFAULT_OPENCODE_CONFIG = '{"provider":{"berget":{"npm":"@ai-sdk/openai-compatible","name":"Berget","options":{"baseURL":"https://api.berget.ai/v1","apiKey":"{env:OPENAI_API_KEY}"},"models":{"moonshotai/Kimi-K3":{"name":"Kimi K3"},"moonshotai/Kimi-K2.6":{"name":"Kimi K2.6"},"zai-org/GLM-5.2":{"name":"GLM-5.2"},"zai-org/GLM-4.7-FP8":{"name":"GLM-4.7"},"openai/gpt-oss-120b":{"name":"GPT-OSS 120B"},"mistralai/Mistral-Medium-3.5-128B":{"name":"Mistral Medium 3.5"},"mistralai/Mistral-Small-3.2-24B-Instruct-2506":{"name":"Mistral Small"},"Qwen/Qwen3.8-27B-FP8":{"name":"Qwen3.8 27B"},"google/gemma-4-31B-it":{"name":"Gemma 4 31B"}}}}}';
|
|
12
|
+
const DEFAULT_ADAPTER_MODELS = '{"opencode_local":[{"id":"berget/moonshotai/Kimi-K3","label":"Kimi K3"},{"id":"berget/moonshotai/Kimi-K2.6","label":"Kimi K2.6"},{"id":"berget/zai-org/GLM-5.2","label":"GLM-5.2"},{"id":"berget/zai-org/GLM-4.7-FP8","label":"GLM-4.7"},{"id":"berget/openai/gpt-oss-120b","label":"GPT-OSS 120B"},{"id":"berget/mistralai/Mistral-Medium-3.5-128B","label":"Mistral Medium 3.5"},{"id":"berget/mistralai/Mistral-Small-3.2-24B-Instruct-2506","label":"Mistral Small"},{"id":"berget/Qwen/Qwen3.8-27B-FP8","label":"Qwen3.8 27B"},{"id":"berget/google/gemma-4-31B-it","label":"Gemma 4 31B"}]}';
|
|
13
|
+
/**
|
|
14
|
+
* Paperclip — Berget's agent platform (tasks, documents, agent orchestration),
|
|
15
|
+
* facit-aligned on the paperclip-operator.
|
|
16
|
+
*
|
|
17
|
+
* @title Paperclip
|
|
18
|
+
* @category Agent Platforms
|
|
19
|
+
*
|
|
20
|
+
* The operator owns the workload: app instance → StatefulSet with PVC,
|
|
21
|
+
* ingress, probes, security context, heartbeat and app-native backups.
|
|
22
|
+
* This package declares the operator and owns everything AROUND it:
|
|
23
|
+
* - the external CNPG cluster (`credentialsMode: 'cnpg'` — the Instance
|
|
24
|
+
* references the operator-generated `<db>-app` `fqdn-uri` like facit)
|
|
25
|
+
* - the app secrets (better-auth) + LLM gateway key via the Platform
|
|
26
|
+
* secrets backend with rotation restart of the StatefulSet
|
|
27
|
+
* - the `paperclip.inc/v1alpha1` `Instance` CR with facit spec
|
|
28
|
+
*
|
|
29
|
+
* The image is private: `pullSecrets` defaults to `['ghcr-pull-secret']`
|
|
30
|
+
* — a fine-grained PAT (read:packages) Secret managed manually outside
|
|
31
|
+
* Flux (rotate by delete+recreate).
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* import { Platform } from '@r8s/recipes'
|
|
35
|
+
* import { Paperclip } from '@r8s/paperclip'
|
|
36
|
+
*
|
|
37
|
+
* export default (
|
|
38
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'secret', path: 'paperclip' }}>
|
|
39
|
+
* <Paperclip host="paperclip.example.com" />
|
|
40
|
+
* </Platform>
|
|
41
|
+
* )
|
|
42
|
+
*/
|
|
43
|
+
function Paperclip(props) {
|
|
44
|
+
const { name = 'paperclip', namespace: namespaceProp, version = 'sso-oidc', repository = 'ghcr.io/berget-ai/paperclip', host, pullSecrets = ['ghcr-pull-secret'], operatorVersion = '0.19.0', dbName = 'paperclip-db', dbInstances = 2, dbStorage = '20Gi', dbStorageClass, backup, appBackup, storage, heartbeat, llm, apiKeySecretName, auth, secretsName, modelCatalog, ingressAnnotations = {}, allowedHostnames, resources = DEFAULT_RESOURCES, tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' }, } = props;
|
|
45
|
+
const namespace = (0, defaults_1.useNamespace)(namespaceProp);
|
|
46
|
+
const secretProvider = (0, core_1.useContext)(defaults_1.SecretContext);
|
|
47
|
+
const sharedOperators = (0, recipes_1.useOperators)();
|
|
48
|
+
const resources_ = [];
|
|
49
|
+
// --- Operator declaration (stateful workload owner) -----------------------
|
|
50
|
+
if (!sharedOperators.some((op) => op.name === 'paperclip-operator')) {
|
|
51
|
+
const paperclipOperator = {
|
|
52
|
+
name: 'paperclip-operator',
|
|
53
|
+
version: operatorVersion,
|
|
54
|
+
description: 'Paperclip operator — Instance CR → StatefulSet + PVC + ingress',
|
|
55
|
+
source: {
|
|
56
|
+
type: 'helm',
|
|
57
|
+
chart: 'paperclip-operator',
|
|
58
|
+
repository: 'oci://ghcr.io/paperclipinc/charts',
|
|
59
|
+
version: operatorVersion,
|
|
60
|
+
values: {
|
|
61
|
+
operator: {
|
|
62
|
+
resources: {
|
|
63
|
+
limits: { cpu: '500m', memory: '512Mi' },
|
|
64
|
+
requests: { cpu: '100m', memory: '128Mi' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
metrics: { enabled: true, serviceMonitor: { enabled: false } },
|
|
68
|
+
leaderElection: { enabled: false },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
crds: ['instances.paperclip.inc'],
|
|
72
|
+
};
|
|
73
|
+
resources_.push((0, core_1.declareOperator)(paperclipOperator));
|
|
74
|
+
}
|
|
75
|
+
// --- App secrets (better-auth) + LLM key ----------------------------------
|
|
76
|
+
const appSecretsName = secretsName ?? `${name}-secrets`;
|
|
77
|
+
const apiKeyName = apiKeySecretName ?? 'berget-api-key';
|
|
78
|
+
if (!secretsName) {
|
|
79
|
+
if (!(0, recipes_1.canProvisionSecrets)(secretProvider)) {
|
|
80
|
+
throw (0, recipes_1.secretsRequiredError)('Paperclip', name, 'a Better Auth secret — it signs every user session', {
|
|
81
|
+
propName: 'secretsName',
|
|
82
|
+
exampleValue: `${name}-secrets`,
|
|
83
|
+
keys: ['better-auth-secret'],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
resources_.push((0, core_1.jsx)(recipes_1.StaticSecret, {
|
|
87
|
+
name: `${name}-secrets`,
|
|
88
|
+
namespace,
|
|
89
|
+
path: auth?.path ?? `${secretProvider.path ?? name}/${name}/app`,
|
|
90
|
+
secretName: appSecretsName,
|
|
91
|
+
refreshAfter: auth?.refreshAfter,
|
|
92
|
+
keys: ['better-auth-secret'],
|
|
93
|
+
// The operator-managed workload is a StatefulSet, not a Deployment
|
|
94
|
+
restart: [{ kind: 'StatefulSet', name }],
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
if (!apiKeySecretName) {
|
|
98
|
+
if (!(0, recipes_1.canProvisionSecrets)(secretProvider)) {
|
|
99
|
+
throw (0, recipes_1.secretsRequiredError)('Paperclip', name, 'an LLM API key for the Berget gateway (agents call models on behalf of users)', {
|
|
100
|
+
propName: 'apiKeySecretName',
|
|
101
|
+
exampleValue: 'berget-api-key',
|
|
102
|
+
keys: ['api-key'],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
resources_.push((0, core_1.jsx)(recipes_1.StaticSecret, {
|
|
106
|
+
name: `berget-api-key`,
|
|
107
|
+
namespace,
|
|
108
|
+
path: llm?.path ?? `${secretProvider.path ?? name}/${name}/berget-ai`,
|
|
109
|
+
secretName: apiKeyName,
|
|
110
|
+
refreshAfter: llm?.refreshAfter ?? '3600s',
|
|
111
|
+
keys: ['api-key'],
|
|
112
|
+
restart: [{ kind: 'StatefulSet', name }],
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
// --- External database (CNPG-managed credentials: `<db>-app` fqdn-uri) ----
|
|
116
|
+
resources_.push((0, core_1.jsx)(recipes_1.Database, {
|
|
117
|
+
name: dbName,
|
|
118
|
+
namespace,
|
|
119
|
+
instances: dbInstances,
|
|
120
|
+
storage: dbStorage,
|
|
121
|
+
...(dbStorageClass ? { storageClass: dbStorageClass } : {}),
|
|
122
|
+
parameters: { max_connections: '200', effective_cache_size: '768MB' },
|
|
123
|
+
credentialsMode: 'cnpg',
|
|
124
|
+
...(backup ? { backup } : {}),
|
|
125
|
+
}));
|
|
126
|
+
// --- App env (facit contract) ----------------------------------------------
|
|
127
|
+
const llmBaseUrl = llm?.baseUrl ?? 'https://api.berget.ai/v1';
|
|
128
|
+
const appNative = appBackup === false ? undefined : (appBackup ?? {});
|
|
129
|
+
const hb = heartbeat === false ? undefined : (heartbeat ?? {});
|
|
130
|
+
const store = storage === false ? undefined : (storage ?? {});
|
|
131
|
+
const catalog = modelCatalog === false ? undefined : (modelCatalog ?? {});
|
|
132
|
+
const env = [
|
|
133
|
+
{ name: 'PAPERCLIP_TELEMETRY_DISABLED', value: '1' },
|
|
134
|
+
{ name: 'OPENAI_BASE_URL', value: llmBaseUrl },
|
|
135
|
+
{
|
|
136
|
+
name: 'OPENAI_API_KEY',
|
|
137
|
+
valueFrom: { secretKeyRef: { name: apiKeyName, key: 'api-key' } },
|
|
138
|
+
},
|
|
139
|
+
{ name: 'PAPERCLIP_SECRETS_PROVIDER', value: 'local_encrypted' },
|
|
140
|
+
{ name: 'PAPERCLIP_STORAGE_PROVIDER', value: 'local_disk' },
|
|
141
|
+
{ name: 'PAPERCLIP_STORAGE_LOCAL_DIR', value: '/paperclip/storage' },
|
|
142
|
+
...(hb?.enabled !== false ? [{ name: 'HEARTBEAT_SCHEDULER_ENABLED', value: 'true' }] : []),
|
|
143
|
+
...(appNative?.enabled !== false
|
|
144
|
+
? [
|
|
145
|
+
{ name: 'PAPERCLIP_DB_BACKUP_ENABLED', value: 'true' },
|
|
146
|
+
{ name: 'PAPERCLIP_DB_BACKUP_DIR', value: '/paperclip/backups' },
|
|
147
|
+
]
|
|
148
|
+
: []),
|
|
149
|
+
{ name: 'PAPERCLIP_AUTH_BASE_URL_MODE', value: 'explicit' },
|
|
150
|
+
{ name: 'PAPERCLIP_AUTH_PUBLIC_BASE_URL', value: `https://${host}` },
|
|
151
|
+
...(catalog
|
|
152
|
+
? [
|
|
153
|
+
{
|
|
154
|
+
name: 'OPENCODE_CONFIG_CONTENT',
|
|
155
|
+
value: catalog.opencodeConfig ?? DEFAULT_OPENCODE_CONFIG,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'PAPERCLIP_ADAPTER_MODELS',
|
|
159
|
+
value: catalog.adapterModels ?? DEFAULT_ADAPTER_MODELS,
|
|
160
|
+
},
|
|
161
|
+
]
|
|
162
|
+
: []),
|
|
163
|
+
];
|
|
164
|
+
// --- Instance CR — the entire app definition (operator-owned) --------------
|
|
165
|
+
resources_.push((0, core_1.jsx)('Instance', {
|
|
166
|
+
apiVersion: 'paperclip.inc/v1alpha1',
|
|
167
|
+
kind: 'Instance',
|
|
168
|
+
metadata: { name, namespace, labels: { app: name } },
|
|
169
|
+
spec: {
|
|
170
|
+
image: {
|
|
171
|
+
repository,
|
|
172
|
+
tag: version,
|
|
173
|
+
pullPolicy: 'Always',
|
|
174
|
+
pullSecrets: pullSecrets.map((n) => ({ name: n })),
|
|
175
|
+
},
|
|
176
|
+
deployment: {
|
|
177
|
+
mode: 'authenticated',
|
|
178
|
+
exposure: 'public',
|
|
179
|
+
publicURL: `https://${host}`,
|
|
180
|
+
allowedHostnames: allowedHostnames ?? [host],
|
|
181
|
+
},
|
|
182
|
+
auth: {
|
|
183
|
+
disableSignUp: auth?.disableSignUp !== false,
|
|
184
|
+
secretRef: { name: appSecretsName, key: 'better-auth-secret' },
|
|
185
|
+
},
|
|
186
|
+
database: {
|
|
187
|
+
mode: 'external',
|
|
188
|
+
externalURLSecretRef: { name: `${dbName}-app`, key: 'fqdn-uri' },
|
|
189
|
+
},
|
|
190
|
+
adapters: { apiKeysSecretRef: { name: apiKeyName } },
|
|
191
|
+
storage: {
|
|
192
|
+
persistence: {
|
|
193
|
+
enabled: storage !== false,
|
|
194
|
+
size: store?.size ?? '10Gi',
|
|
195
|
+
...(store?.storageClass ? { storageClass: store.storageClass } : {}),
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
resources,
|
|
199
|
+
networking: {
|
|
200
|
+
service: { type: 'ClusterIP', port: 3100 },
|
|
201
|
+
ingress: {
|
|
202
|
+
enabled: true,
|
|
203
|
+
ingressClassName: 'nginx',
|
|
204
|
+
hosts: [host],
|
|
205
|
+
tls: [{ hosts: [host], secretName: tls.secretName }],
|
|
206
|
+
annotations: {
|
|
207
|
+
'cert-manager.io/cluster-issuer': tls.clusterIssuer,
|
|
208
|
+
'external-dns.alpha.kubernetes.io/hostname': host,
|
|
209
|
+
'nginx.ingress.kubernetes.io/ssl-redirect': 'true',
|
|
210
|
+
'nginx.ingress.kubernetes.io/proxy-body-size': '50m',
|
|
211
|
+
'nginx.ingress.kubernetes.io/proxy-read-timeout': '300',
|
|
212
|
+
'nginx.ingress.kubernetes.io/proxy-send-timeout': '300',
|
|
213
|
+
'nginx.ingress.kubernetes.io/proxy-http-version': '1.1',
|
|
214
|
+
...ingressAnnotations,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
probes: { type: 'auto' },
|
|
219
|
+
heartbeat: hb && hb.enabled !== false ? { enabled: true, intervalMS: hb.intervalMS ?? 30000 } : null,
|
|
220
|
+
backup: appNative && appNative.enabled !== false
|
|
221
|
+
? {
|
|
222
|
+
appNative: {
|
|
223
|
+
enabled: true,
|
|
224
|
+
intervalMinutes: appNative.intervalMinutes ?? 60,
|
|
225
|
+
retentionDays: appNative.retentionDays ?? 7,
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
: null,
|
|
229
|
+
security: {
|
|
230
|
+
networkPolicy: { enabled: false },
|
|
231
|
+
seLinuxRelabel: false,
|
|
232
|
+
podSecurityContext: { fsGroup: 1000 },
|
|
233
|
+
containerSecurityContext: {
|
|
234
|
+
// The image runs as root — runAsNonRoot would crash-loop it
|
|
235
|
+
allowPrivilegeEscalation: false,
|
|
236
|
+
capabilities: { drop: ['ALL'] },
|
|
237
|
+
runAsNonRoot: false,
|
|
238
|
+
runAsUser: 0,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
env,
|
|
242
|
+
},
|
|
243
|
+
}));
|
|
244
|
+
return (0, core_1.jsx)(core_1.Fragment, { children: resources_ });
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@r8s/paperclip",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Paperclip — Berget's agent platform (tasks, documents, agent orchestration)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Berget AI AB",
|
|
@@ -37,5 +37,8 @@
|
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"typescript": "^5.3.0",
|
|
39
39
|
"vitest": "^1.0.0"
|
|
40
|
-
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist"
|
|
43
|
+
]
|
|
41
44
|
}
|
|
@@ -1,450 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { render, jsx } from '@r8s/core'
|
|
3
|
-
import { Namespace, OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
|
|
4
|
-
import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
|
|
5
|
-
import { operators } from '@r8s/crds'
|
|
6
|
-
import type { r8sElement } from '@r8s/core'
|
|
7
|
-
|
|
8
|
-
// Paperclip recipe tests:
|
|
9
|
-
// 1. Operator declarations (cnpg via Database, deduped via OperatorContext)
|
|
10
|
-
// 2. Rendering: defaults, sandbox agents (hardened sandbox, TCP probes),
|
|
11
|
-
// all props, gateway/ingress adaptation
|
|
12
|
-
// 3. Namespace inheritance from the Platform context
|
|
13
|
-
// 4. Security: no plaintext credentials in rendered output
|
|
14
|
-
import { Paperclip } from '../src/index'
|
|
15
|
-
|
|
16
|
-
/** Render Paperclip inside a Platform-like secrets backend (OpenBao). */
|
|
17
|
-
function renderPaperclip(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
18
|
-
return render(
|
|
19
|
-
jsx(SecretContext.Provider, {
|
|
20
|
-
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
21
|
-
children: jsx(Paperclip, props as never),
|
|
22
|
-
})
|
|
23
|
-
)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Render Paperclip inside a Platform-like Namespace context (no explicit namespace prop). */
|
|
27
|
-
function renderPaperclipInNamespace(
|
|
28
|
-
namespaceValue: string,
|
|
29
|
-
props: Record<string, unknown>
|
|
30
|
-
): ReturnType<typeof render> {
|
|
31
|
-
return render(
|
|
32
|
-
jsx(Namespace.Provider, {
|
|
33
|
-
value: namespaceValue,
|
|
34
|
-
children: jsx(SecretContext.Provider, {
|
|
35
|
-
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
36
|
-
children: jsx(Paperclip, props as never),
|
|
37
|
-
}),
|
|
38
|
-
})
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Render Paperclip wrapped only in an OperatorContext (no secrets backend). */
|
|
43
|
-
function renderPaperclipWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
|
|
44
|
-
return jsx(OperatorContext.Provider, {
|
|
45
|
-
value: operators_,
|
|
46
|
-
children: jsx(Paperclip, props as never),
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
51
|
-
|
|
52
|
-
describe('operator declarations', () => {
|
|
53
|
-
it('declares the cnpg operator via the Database recipe', () => {
|
|
54
|
-
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
55
|
-
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('declares cnpg exactly once even with sandbox agents (no redis for paperclip)', () => {
|
|
59
|
-
const result = renderPaperclip({
|
|
60
|
-
host: 'paperclip.example.com',
|
|
61
|
-
agents: { sandboxReplicas: 3 },
|
|
62
|
-
})
|
|
63
|
-
const names = result.operators.map((op) => op.name)
|
|
64
|
-
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
65
|
-
const kinds = result.resources.map((r) => r.kind)
|
|
66
|
-
expect(kinds).not.toContain('RedisCluster')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('deduplicates operators provided via context', () => {
|
|
70
|
-
const result = render(
|
|
71
|
-
renderPaperclipWithContext([operators['cnpg']()], {
|
|
72
|
-
host: 'paperclip.example.com',
|
|
73
|
-
secretsName: 'existing-secrets',
|
|
74
|
-
})
|
|
75
|
-
)
|
|
76
|
-
const names = result.operators.map((op) => op.name)
|
|
77
|
-
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('allows version overrides through context operators', () => {
|
|
81
|
-
const result = render(
|
|
82
|
-
renderPaperclipWithContext([operators['cnpg']('1.25.0')], {
|
|
83
|
-
host: 'paperclip.example.com',
|
|
84
|
-
secretsName: 'existing-secrets',
|
|
85
|
-
})
|
|
86
|
-
)
|
|
87
|
-
const cnpg = result.operators.filter((op) => op.name === 'cnpg')
|
|
88
|
-
expect(cnpg).toHaveLength(1)
|
|
89
|
-
expect(cnpg[0].version).toBe('1.25.0')
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
describe('rendering defaults', () => {
|
|
94
|
-
it('renders main deployment, service, database and endpoint', () => {
|
|
95
|
-
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
96
|
-
const kinds = result.resources.map((r) => r.kind)
|
|
97
|
-
expect(kinds).toContain('Deployment')
|
|
98
|
-
expect(kinds).toContain('Service')
|
|
99
|
-
expect(kinds).toContain('Ingress')
|
|
100
|
-
expect(kinds).toContain('Cluster')
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('renders the main app with the paperclip image on port 3000', () => {
|
|
104
|
-
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
105
|
-
const main = result.resources.find(
|
|
106
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
107
|
-
) as any
|
|
108
|
-
expect(main).toBeDefined()
|
|
109
|
-
const container = main.spec.template.spec.containers[0]
|
|
110
|
-
expect(container.image).toBe('ghcr.io/berget-ai/paperclip:latest')
|
|
111
|
-
expect(container.ports[0].containerPort).toBe(3000)
|
|
112
|
-
expect(main.spec.replicas).toBe(2)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('probes both workloads with TCP sockets (unknown HTTP health contract)', () => {
|
|
116
|
-
const result = renderPaperclip({
|
|
117
|
-
host: 'paperclip.example.com',
|
|
118
|
-
agents: { sandboxReplicas: 2 },
|
|
119
|
-
})
|
|
120
|
-
for (const name of ['paperclip', 'paperclip-agent-sandbox']) {
|
|
121
|
-
const deployment = result.resources.find(
|
|
122
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === name
|
|
123
|
-
) as any
|
|
124
|
-
const container = deployment.spec.template.spec.containers[0]
|
|
125
|
-
expect(container.livenessProbe.tcpSocket).toEqual({ port: 3000 })
|
|
126
|
-
expect(container.readinessProbe.tcpSocket).toEqual({ port: 3000 })
|
|
127
|
-
expect(container.livenessProbe.httpGet).toBeUndefined()
|
|
128
|
-
expect(container.readinessProbe.httpGet).toBeUndefined()
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('renders no sandbox deployment when agents is not set', () => {
|
|
133
|
-
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
134
|
-
const names = result.resources
|
|
135
|
-
.filter((r) => r.kind === 'Deployment')
|
|
136
|
-
.map((d: any) => d.metadata.name)
|
|
137
|
-
expect(names).toEqual(['paperclip'])
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('renders the sandbox agent deployment when agents is set', () => {
|
|
141
|
-
const result = renderPaperclip({
|
|
142
|
-
host: 'paperclip.example.com',
|
|
143
|
-
agents: { sandboxReplicas: 3 },
|
|
144
|
-
})
|
|
145
|
-
const sandbox = result.resources.find(
|
|
146
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
147
|
-
) as any
|
|
148
|
-
expect(sandbox).toBeDefined()
|
|
149
|
-
expect(sandbox.spec.replicas).toBe(3)
|
|
150
|
-
expect(sandbox.spec.template.spec.containers[0].image).toBe(
|
|
151
|
-
'ghcr.io/berget-ai/paperclip:latest'
|
|
152
|
-
)
|
|
153
|
-
expect(sandbox.spec.template.spec.containers[0].command).toEqual([
|
|
154
|
-
'paperclip',
|
|
155
|
-
'agent',
|
|
156
|
-
'--sandbox',
|
|
157
|
-
])
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('hardens the sandbox with a locked-down securityContext', () => {
|
|
161
|
-
const result = renderPaperclip({
|
|
162
|
-
host: 'paperclip.example.com',
|
|
163
|
-
agents: {},
|
|
164
|
-
})
|
|
165
|
-
const sandbox = result.resources.find(
|
|
166
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
167
|
-
) as any
|
|
168
|
-
const podSpec = sandbox.spec.template.spec
|
|
169
|
-
expect(podSpec.securityContext).toEqual({ seccompProfile: { type: 'RuntimeDefault' } })
|
|
170
|
-
const securityContext = podSpec.containers[0].securityContext
|
|
171
|
-
expect(securityContext.runAsNonRoot).toBe(true)
|
|
172
|
-
expect(securityContext.allowPrivilegeEscalation).toBe(false)
|
|
173
|
-
expect(securityContext.seccompProfile).toEqual({ type: 'RuntimeDefault' })
|
|
174
|
-
expect(securityContext.capabilities.drop).toEqual(['ALL'])
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('gives the sandbox explicit resources by default and honours overrides', () => {
|
|
178
|
-
const defaults = renderPaperclip({ host: 'paperclip.example.com', agents: {} })
|
|
179
|
-
const defaultSandbox = defaults.resources.find(
|
|
180
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
181
|
-
) as any
|
|
182
|
-
const defaultContainer = defaultSandbox.spec.template.spec.containers[0]
|
|
183
|
-
expect(defaultContainer.resources.requests).toEqual({ cpu: '250m', memory: '256Mi' })
|
|
184
|
-
expect(defaultContainer.resources.limits).toEqual({ cpu: '1000m', memory: '2Gi' })
|
|
185
|
-
|
|
186
|
-
const result = renderPaperclip({
|
|
187
|
-
host: 'paperclip.example.com',
|
|
188
|
-
agents: {
|
|
189
|
-
sandboxReplicas: 1,
|
|
190
|
-
resources: {
|
|
191
|
-
requests: { cpu: '500m', memory: '1Gi' },
|
|
192
|
-
limits: { cpu: '2000m', memory: '4Gi' },
|
|
193
|
-
},
|
|
194
|
-
},
|
|
195
|
-
})
|
|
196
|
-
const sandbox = result.resources.find(
|
|
197
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
198
|
-
) as any
|
|
199
|
-
const container = sandbox.spec.template.spec.containers[0]
|
|
200
|
-
expect(container.resources.requests).toEqual({ cpu: '500m', memory: '1Gi' })
|
|
201
|
-
expect(container.resources.limits).toEqual({ cpu: '2000m', memory: '4Gi' })
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
it('defaults sandbox replicas to 2', () => {
|
|
205
|
-
const result = renderPaperclip({ host: 'paperclip.example.com', agents: {} })
|
|
206
|
-
const sandbox = result.resources.find(
|
|
207
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
208
|
-
) as any
|
|
209
|
-
expect(sandbox.spec.replicas).toBe(2)
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('renders gateway resources when platform uses gateway routing', () => {
|
|
213
|
-
const result = render(
|
|
214
|
-
jsx(RoutingContext.Provider, {
|
|
215
|
-
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
216
|
-
children: jsx(SecretContext.Provider, {
|
|
217
|
-
value: openbao as never,
|
|
218
|
-
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
219
|
-
}),
|
|
220
|
-
})
|
|
221
|
-
)
|
|
222
|
-
const kinds = result.resources.map((r) => r.kind)
|
|
223
|
-
expect(kinds).toContain('HTTPRoute')
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
227
|
-
const result = render(
|
|
228
|
-
jsx(RoutingContext.Provider, {
|
|
229
|
-
value: { mode: 'ingress' },
|
|
230
|
-
children: jsx(SecretContext.Provider, {
|
|
231
|
-
value: openbao as never,
|
|
232
|
-
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
233
|
-
}),
|
|
234
|
-
})
|
|
235
|
-
)
|
|
236
|
-
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
237
|
-
expect(ingress).toBeDefined()
|
|
238
|
-
expect(ingress.spec.rules[0].host).toBe('paperclip.example.com')
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('passes resource validation', () => {
|
|
242
|
-
const result = renderPaperclip({
|
|
243
|
-
host: 'paperclip.example.com',
|
|
244
|
-
agents: { sandboxReplicas: 2 },
|
|
245
|
-
})
|
|
246
|
-
for (const resource of result.resources) {
|
|
247
|
-
expect(validateResource(resource)).toEqual([])
|
|
248
|
-
}
|
|
249
|
-
})
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
describe('namespace inheritance', () => {
|
|
253
|
-
it('inherits namespace from the Platform context when namespace prop is not set', () => {
|
|
254
|
-
const result = renderPaperclipInNamespace('agents', {
|
|
255
|
-
host: 'paperclip.example.com',
|
|
256
|
-
agents: { sandboxReplicas: 1 },
|
|
257
|
-
})
|
|
258
|
-
for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
|
|
259
|
-
const resource = result.resources.find((r: any) => r.kind === kind)
|
|
260
|
-
expect(resource).toBeDefined()
|
|
261
|
-
expect(resource.metadata.namespace).toBe('agents')
|
|
262
|
-
}
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
it('explicit namespace prop wins over the Platform context', () => {
|
|
266
|
-
const result = renderPaperclipInNamespace('agents', {
|
|
267
|
-
host: 'paperclip.example.com',
|
|
268
|
-
namespace: 'paperclip-ns',
|
|
269
|
-
})
|
|
270
|
-
const main = result.resources.find(
|
|
271
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
272
|
-
) as any
|
|
273
|
-
expect(main.metadata.namespace).toBe('paperclip-ns')
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it('falls back to default when no Platform namespace is present', () => {
|
|
277
|
-
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
278
|
-
const main = result.resources.find(
|
|
279
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
280
|
-
) as any
|
|
281
|
-
expect(main.metadata.namespace).toBe('default')
|
|
282
|
-
})
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
describe('rendering with all props', () => {
|
|
286
|
-
it('accepts the full prop surface', () => {
|
|
287
|
-
const result = renderPaperclip({
|
|
288
|
-
name: 'agents',
|
|
289
|
-
namespace: 'agents',
|
|
290
|
-
version: '0.4.0',
|
|
291
|
-
host: 'agents.example.com',
|
|
292
|
-
replicas: 3,
|
|
293
|
-
dbStorage: '20Gi',
|
|
294
|
-
websockets: true,
|
|
295
|
-
agents: { sandboxReplicas: 4 },
|
|
296
|
-
resources: {
|
|
297
|
-
requests: { memory: '1Gi', cpu: '500m' },
|
|
298
|
-
limits: { memory: '4Gi', cpu: '2000m' },
|
|
299
|
-
},
|
|
300
|
-
tls: { secretName: 'agents-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
301
|
-
})
|
|
302
|
-
expect(result.resources.length).toBeGreaterThan(0)
|
|
303
|
-
|
|
304
|
-
const main = result.resources.find(
|
|
305
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'agents'
|
|
306
|
-
) as any
|
|
307
|
-
const container = main.spec.template.spec.containers[0]
|
|
308
|
-
expect(container.image).toBe('ghcr.io/berget-ai/paperclip:0.4.0')
|
|
309
|
-
expect(main.spec.replicas).toBe(3)
|
|
310
|
-
expect(container.resources.limits.memory).toBe('4Gi')
|
|
311
|
-
const websockets = container.env.find((e: any) => e.name === 'WEBSOCKETS_ENABLED')
|
|
312
|
-
expect(websockets.value).toBe('true')
|
|
313
|
-
|
|
314
|
-
const sandbox = result.resources.find(
|
|
315
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'agents-agent-sandbox'
|
|
316
|
-
) as any
|
|
317
|
-
expect(sandbox.spec.replicas).toBe(4)
|
|
318
|
-
|
|
319
|
-
const cluster = result.resources.find((r) => r.kind === 'Cluster') as any
|
|
320
|
-
expect(cluster.spec.storage.size).toBe('20Gi')
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
it('renders unique env var names (k8s rejects duplicates)', () => {
|
|
324
|
-
const result = renderPaperclip({
|
|
325
|
-
host: 'paperclip.example.com',
|
|
326
|
-
agents: { sandboxReplicas: 1 },
|
|
327
|
-
})
|
|
328
|
-
for (const name of ['paperclip', 'paperclip-agent-sandbox']) {
|
|
329
|
-
const deployment = result.resources.find(
|
|
330
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === name
|
|
331
|
-
) as any
|
|
332
|
-
const env = deployment.spec.template.spec.containers[0].env as Array<{ name: string }>
|
|
333
|
-
const names = env.map((e) => e.name)
|
|
334
|
-
expect(new Set(names).size).toBe(names.length)
|
|
335
|
-
}
|
|
336
|
-
})
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
describe('secrets handling', () => {
|
|
340
|
-
it('provisions the model API key through a secrets backend', () => {
|
|
341
|
-
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
342
|
-
const bundle = result.resources.find(
|
|
343
|
-
(r: any) => r.kind === 'OpenBaoStaticSecret' && r.metadata.name === 'paperclip-secrets'
|
|
344
|
-
) as any
|
|
345
|
-
expect(bundle).toBeDefined()
|
|
346
|
-
expect(bundle.spec.path).toBe('test/paperclip/secrets')
|
|
347
|
-
expect(bundle.spec.destination).toEqual({
|
|
348
|
-
create: true,
|
|
349
|
-
name: 'paperclip-secrets',
|
|
350
|
-
})
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it('provisions the model API key through Vault', () => {
|
|
354
|
-
const result = render(
|
|
355
|
-
jsx(SecretContext.Provider, {
|
|
356
|
-
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
357
|
-
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
358
|
-
})
|
|
359
|
-
)
|
|
360
|
-
const kinds = result.resources.map((r) => r.kind)
|
|
361
|
-
expect(kinds).toContain('VaultStaticSecret')
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
it('accepts an explicit secretsName without a backend', () => {
|
|
365
|
-
const result = render(
|
|
366
|
-
jsx(Paperclip, { host: 'paperclip.example.com', secretsName: 'existing-secrets' })
|
|
367
|
-
)
|
|
368
|
-
const kinds = result.resources.map((r) => r.kind)
|
|
369
|
-
expect(kinds).not.toContain('OpenBaoStaticSecret')
|
|
370
|
-
expect(kinds).not.toContain('VaultStaticSecret')
|
|
371
|
-
expect(result.resources.length).toBeGreaterThan(0)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
375
|
-
const result = render(
|
|
376
|
-
jsx(Paperclip, { host: 'paperclip.example.com', secretsName: 'existing-secrets' })
|
|
377
|
-
)
|
|
378
|
-
const main = result.resources.find(
|
|
379
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
380
|
-
) as any
|
|
381
|
-
const env = main.spec.template.spec.containers[0].env
|
|
382
|
-
const modelApiKey = env.find((e: any) => e.name === 'MODEL_API_KEY')
|
|
383
|
-
const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
384
|
-
expect(modelApiKey.valueFrom.secretKeyRef).toEqual({
|
|
385
|
-
name: 'existing-secrets',
|
|
386
|
-
key: 'modelApiKey',
|
|
387
|
-
})
|
|
388
|
-
expect(dbPassword.valueFrom.secretKeyRef).toEqual({
|
|
389
|
-
name: 'paperclip-db-credentials',
|
|
390
|
-
key: 'password',
|
|
391
|
-
})
|
|
392
|
-
expect(modelApiKey.value).toBeUndefined()
|
|
393
|
-
expect(dbPassword.value).toBeUndefined()
|
|
394
|
-
const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
|
|
395
|
-
expect(databaseUrl.value).toContain('$(PGPASSWORD)')
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
it('sandbox workers share the same secrets bundle', () => {
|
|
399
|
-
const result = render(
|
|
400
|
-
jsx(SecretContext.Provider, {
|
|
401
|
-
value: openbao as never,
|
|
402
|
-
children: jsx(Paperclip, {
|
|
403
|
-
host: 'paperclip.example.com',
|
|
404
|
-
agents: { sandboxReplicas: 2 },
|
|
405
|
-
}),
|
|
406
|
-
})
|
|
407
|
-
)
|
|
408
|
-
const sandbox = result.resources.find(
|
|
409
|
-
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
410
|
-
) as any
|
|
411
|
-
const env = sandbox.spec.template.spec.containers[0].env
|
|
412
|
-
const modelApiKey = env.find((e: any) => e.name === 'MODEL_API_KEY')
|
|
413
|
-
const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
414
|
-
expect(modelApiKey.valueFrom.secretKeyRef.name).toBe('paperclip-secrets')
|
|
415
|
-
expect(modelApiKey.valueFrom.secretKeyRef.key).toBe('modelApiKey')
|
|
416
|
-
expect(dbPassword.valueFrom.secretKeyRef.name).toBe('paperclip-db-credentials')
|
|
417
|
-
expect(modelApiKey.value).toBeUndefined()
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
it('renders no plaintext credentials anywhere', () => {
|
|
421
|
-
const result = renderPaperclip({
|
|
422
|
-
host: 'paperclip.example.com',
|
|
423
|
-
agents: { sandboxReplicas: 2 },
|
|
424
|
-
})
|
|
425
|
-
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
426
|
-
if (!passed) {
|
|
427
|
-
console.error('Plaintext credential violations:', errors)
|
|
428
|
-
}
|
|
429
|
-
expect(passed).toBe(true)
|
|
430
|
-
})
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
describe('validation errors', () => {
|
|
434
|
-
it('throws when no secrets backend and no model key secret', () => {
|
|
435
|
-
expect(() => render(jsx(Paperclip, { host: 'paperclip.example.com' }))).toThrow(
|
|
436
|
-
/model api key/i
|
|
437
|
-
)
|
|
438
|
-
})
|
|
439
|
-
|
|
440
|
-
it('throws for unknown secrets backends', () => {
|
|
441
|
-
expect(() =>
|
|
442
|
-
render(
|
|
443
|
-
jsx(SecretContext.Provider, {
|
|
444
|
-
value: { backend: 'unknown' as never },
|
|
445
|
-
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
446
|
-
})
|
|
447
|
-
)
|
|
448
|
-
).toThrow(/model api key/i)
|
|
449
|
-
})
|
|
450
|
-
})
|
package/examples/basic.tsx
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { Platform } from '@r8s/recipes'
|
|
2
|
-
import { Paperclip } from '@r8s/paperclip'
|
|
3
|
-
|
|
4
|
-
export default (
|
|
5
|
-
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
-
<Paperclip name="paperclip" host="paperclip.example.com" agents={{ sandboxReplicas: 3 }} />
|
|
7
|
-
</Platform>
|
|
8
|
-
)
|
package/src/index.tsx
DELETED
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import { jsx, Fragment, useContext } from '@r8s/core'
|
|
2
|
-
import { Namespace, SecretContext } from '@r8s/core/defaults'
|
|
3
|
-
import { Database, WebService, Endpoint } from '@r8s/recipes'
|
|
4
|
-
import type { SecretRef } from '@r8s/recipes'
|
|
5
|
-
|
|
6
|
-
export interface PaperclipProps {
|
|
7
|
-
/** Resource name (defaults to 'paperclip') */
|
|
8
|
-
name?: string
|
|
9
|
-
/** Kubernetes namespace (defaults to 'default') */
|
|
10
|
-
namespace?: string
|
|
11
|
-
/** Container image tag (defaults to 'latest' — pin a version in production) */
|
|
12
|
-
version?: string
|
|
13
|
-
/** Public hostname for the web app and API (required) */
|
|
14
|
-
host: string
|
|
15
|
-
/** Number of app replicas (defaults to 2) */
|
|
16
|
-
replicas?: number
|
|
17
|
-
/** Storage size for the Postgres cluster (defaults to '10Gi') */
|
|
18
|
-
dbStorage?: string
|
|
19
|
-
/** Enable websockets for live task and agent updates (defaults to false) */
|
|
20
|
-
websockets?: boolean
|
|
21
|
-
/**
|
|
22
|
-
* Sandbox agent workers. Workers run the same image with a command
|
|
23
|
-
* override (paperclip agent --sandbox) and share the model API key
|
|
24
|
-
* and database credentials via secretKeyRef. They run with a hardened
|
|
25
|
-
* securityContext (non-root, no privilege escalation, RuntimeDefault
|
|
26
|
-
* seccomp, all capabilities dropped) by default.
|
|
27
|
-
*/
|
|
28
|
-
agents?: {
|
|
29
|
-
/** Number of sandbox agent replicas (defaults to 2) */
|
|
30
|
-
sandboxReplicas?: number
|
|
31
|
-
/** Sandbox container resources (defaults to requests 256Mi/250m, limits 2Gi/1000m) */
|
|
32
|
-
resources?: {
|
|
33
|
-
requests?: { cpu?: string; memory?: string }
|
|
34
|
-
limits?: { cpu?: string; memory?: string }
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Name of an existing Secret containing key `modelApiKey`. Paperclip
|
|
39
|
-
* uses this key to call LLM providers on behalf of agents.
|
|
40
|
-
*
|
|
41
|
-
* Required unless a secrets backend (openbao/vault) is configured on
|
|
42
|
-
* the surrounding Platform — the backend then provisions the key
|
|
43
|
-
* automatically. Plaintext keys are not supported.
|
|
44
|
-
*/
|
|
45
|
-
secretsName?: string
|
|
46
|
-
/** Requested app resources */
|
|
47
|
-
resources?: {
|
|
48
|
-
requests?: { cpu?: string; memory?: string }
|
|
49
|
-
limits?: { cpu?: string; memory?: string }
|
|
50
|
-
}
|
|
51
|
-
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
52
|
-
tls?: {
|
|
53
|
-
secretName: string
|
|
54
|
-
clusterIssuer: string
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Paperclip — Berget's agent platform (tasks, documents, agent orchestration).
|
|
60
|
-
*
|
|
61
|
-
* @title Paperclip
|
|
62
|
-
* @category Agent Platforms
|
|
63
|
-
*
|
|
64
|
-
* Composes:
|
|
65
|
-
* - CNPG Postgres cluster (tasks, documents, agent state; size via
|
|
66
|
-
* `dbStorage`)
|
|
67
|
-
* - Paperclip Deployment + Service + Endpoint
|
|
68
|
-
* - Optional sandbox agent workers (same image, command override) sharing
|
|
69
|
-
* the model API key via secretKeyRef — hardened with a non-root,
|
|
70
|
-
* no-privilege-escalation securityContext, RuntimeDefault seccomp and
|
|
71
|
-
* all capabilities dropped, with explicit resources by default
|
|
72
|
-
* - Model API key provisioned through the Platform secrets backend
|
|
73
|
-
* (openbao / vault), or referenced from an existing Secret
|
|
74
|
-
* - Optional websockets support (WEBSOCKETS_ENABLED)
|
|
75
|
-
*
|
|
76
|
-
* Probing: the Paperclip image contract does not publish HTTP health
|
|
77
|
-
* endpoints, so both workloads default to TCP socket probes on the
|
|
78
|
-
* container port (3000) to avoid crash-looping on unknown /health /ready
|
|
79
|
-
* paths. If your image serves HTTP /health and /ready — or another
|
|
80
|
-
* readiness endpoint — override `probes` on the workload for stricter
|
|
81
|
-
* checks.
|
|
82
|
-
*
|
|
83
|
-
* The namespace is inherited from the surrounding `<Platform>` (via the
|
|
84
|
-
* Namespace context) unless set explicitly.
|
|
85
|
-
*
|
|
86
|
-
* Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
|
|
87
|
-
* the MODEL_API_KEY is provisioned for you. Without a backend you must
|
|
88
|
-
* point `secretsName` at a pre-created Secret.
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* import { Platform } from '@r8s/recipes'
|
|
92
|
-
* import { Paperclip } from '@r8s/paperclip'
|
|
93
|
-
*
|
|
94
|
-
* export default (
|
|
95
|
-
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
96
|
-
* <Paperclip
|
|
97
|
-
* name="paperclip"
|
|
98
|
-
* host="paperclip.example.com"
|
|
99
|
-
* agents={{ sandboxReplicas: 3 }}
|
|
100
|
-
* />
|
|
101
|
-
* </Platform>
|
|
102
|
-
* )
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* // Existing Secret holding the model API key (key: modelApiKey)
|
|
106
|
-
* import { Paperclip } from '@r8s/paperclip'
|
|
107
|
-
*
|
|
108
|
-
* export default (
|
|
109
|
-
* <Paperclip name="paperclip" host="paperclip.example.com" secretsName="paperclip-secrets" />
|
|
110
|
-
* )
|
|
111
|
-
*/
|
|
112
|
-
export function Paperclip(props: PaperclipProps) {
|
|
113
|
-
const {
|
|
114
|
-
name = 'paperclip',
|
|
115
|
-
namespace: namespaceProp,
|
|
116
|
-
version = 'latest',
|
|
117
|
-
host,
|
|
118
|
-
replicas = 2,
|
|
119
|
-
dbStorage = '10Gi',
|
|
120
|
-
websockets = false,
|
|
121
|
-
agents,
|
|
122
|
-
secretsName,
|
|
123
|
-
resources = {
|
|
124
|
-
requests: { memory: '512Mi', cpu: '250m' },
|
|
125
|
-
limits: { memory: '2Gi', cpu: '1000m' },
|
|
126
|
-
},
|
|
127
|
-
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
128
|
-
} = props
|
|
129
|
-
|
|
130
|
-
// Inherit namespace from <Platform> context if not explicitly set
|
|
131
|
-
const contextNamespace = useContext(Namespace)
|
|
132
|
-
const namespace =
|
|
133
|
-
namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
|
|
134
|
-
|
|
135
|
-
const secretProvider = useContext(SecretContext)
|
|
136
|
-
const resources_: ReturnType<typeof jsx>[] = []
|
|
137
|
-
|
|
138
|
-
const image = `ghcr.io/berget-ai/paperclip:${version}`
|
|
139
|
-
const appSecretsName = secretsName ?? `${name}-secrets`
|
|
140
|
-
const sandboxReplicas = agents?.sandboxReplicas ?? 2
|
|
141
|
-
// Sandbox workers run untrusted code — never let them float without
|
|
142
|
-
// explicit container resources.
|
|
143
|
-
const sandboxResources = agents?.resources ?? {
|
|
144
|
-
requests: { cpu: '250m', memory: '256Mi' },
|
|
145
|
-
limits: { cpu: '1000m', memory: '2Gi' },
|
|
146
|
-
}
|
|
147
|
-
// The internal image's probe contract is unknown — probe the TCP port
|
|
148
|
-
// so pods don't crash-loop on missing HTTP /health /ready endpoints.
|
|
149
|
-
const tcpProbes = {
|
|
150
|
-
liveness: { tcp: true } as const,
|
|
151
|
-
readiness: { tcp: true } as const,
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// --- Secret provisioning -------------------------------------------------
|
|
155
|
-
// The model API key lets Paperclip call LLM providers — never render
|
|
156
|
-
// it as plaintext. With a secrets backend it is provisioned through the
|
|
157
|
-
// backend (key `modelApiKey`); otherwise reference a pre-created Secret.
|
|
158
|
-
if (!secretsName) {
|
|
159
|
-
if (
|
|
160
|
-
!secretProvider ||
|
|
161
|
-
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
162
|
-
) {
|
|
163
|
-
throw new Error(
|
|
164
|
-
`Paperclip "${name}" requires a model API key (MODEL_API_KEY).\n` +
|
|
165
|
-
`\n` +
|
|
166
|
-
`Paperclip calls LLM providers with a model API key — ` +
|
|
167
|
-
`it must not be rendered as plaintext.\n` +
|
|
168
|
-
`\n` +
|
|
169
|
-
`Fix: configure a secrets backend on the Platform:\n` +
|
|
170
|
-
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
171
|
-
` <Paperclip name="${name}" host="${host}" />\n` +
|
|
172
|
-
` </Platform>\n` +
|
|
173
|
-
`\n` +
|
|
174
|
-
`Or reference a pre-created Secret (key: modelApiKey):\n` +
|
|
175
|
-
` <Paperclip name="${name}" host="${host}" secretsName="${name}-secrets" />`
|
|
176
|
-
)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const spec = {
|
|
180
|
-
...(secretProvider.backend === 'vault'
|
|
181
|
-
? { vaultAuthRef: secretProvider.authRef }
|
|
182
|
-
: { openbaoAuthRef: secretProvider.authRef }),
|
|
183
|
-
mount: secretProvider.mount,
|
|
184
|
-
type: 'kv-v2' as const,
|
|
185
|
-
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
186
|
-
destination: { create: true, name: appSecretsName },
|
|
187
|
-
}
|
|
188
|
-
resources_.push(
|
|
189
|
-
secretProvider.backend === 'vault'
|
|
190
|
-
? jsx('VaultStaticSecret', {
|
|
191
|
-
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
192
|
-
kind: 'VaultStaticSecret',
|
|
193
|
-
metadata: { name: `${name}-secrets`, namespace },
|
|
194
|
-
spec,
|
|
195
|
-
})
|
|
196
|
-
: jsx('OpenBaoStaticSecret', {
|
|
197
|
-
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
198
|
-
kind: 'OpenBaoStaticSecret',
|
|
199
|
-
metadata: { name: `${name}-secrets`, namespace },
|
|
200
|
-
spec,
|
|
201
|
-
})
|
|
202
|
-
)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// --- Env wiring ------------------------------------------------------------
|
|
206
|
-
// Every credential is referenced via secretKeyRef (declared before plain
|
|
207
|
-
// env vars by WebService, so dependent $(VAR) expansion resolves).
|
|
208
|
-
// DATABASE_URL is auto-wired from DatabaseContext inside the wrapper.
|
|
209
|
-
const appEnv: Record<string, string> = {
|
|
210
|
-
PORT: '3000',
|
|
211
|
-
...(websockets ? { WEBSOCKETS_ENABLED: 'true' } : {}),
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const appSecrets: Record<string, SecretRef> = {
|
|
215
|
-
MODEL_API_KEY: { secret: appSecretsName, key: 'modelApiKey' },
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// --- Database (CNPG) + app + sandbox agents --------------------------------
|
|
219
|
-
// Database wraps both the main app and the sandbox workers so database
|
|
220
|
-
// credentials stay consistent with the r8s Database recipe (CNPG dedicated
|
|
221
|
-
// cluster provisions the secret).
|
|
222
|
-
resources_.push(
|
|
223
|
-
jsx(Database, {
|
|
224
|
-
name,
|
|
225
|
-
namespace,
|
|
226
|
-
storage: dbStorage,
|
|
227
|
-
children: (
|
|
228
|
-
<>
|
|
229
|
-
<WebService
|
|
230
|
-
name={name}
|
|
231
|
-
namespace={namespace}
|
|
232
|
-
image={image}
|
|
233
|
-
port={3000}
|
|
234
|
-
replicas={replicas}
|
|
235
|
-
probes={tcpProbes}
|
|
236
|
-
resources={resources}
|
|
237
|
-
env={appEnv}
|
|
238
|
-
secrets={appSecrets}
|
|
239
|
-
/>
|
|
240
|
-
{agents ? (
|
|
241
|
-
<WebService
|
|
242
|
-
name={`${name}-agent-sandbox`}
|
|
243
|
-
namespace={namespace}
|
|
244
|
-
image={image}
|
|
245
|
-
port={3000}
|
|
246
|
-
replicas={sandboxReplicas}
|
|
247
|
-
command={['paperclip', 'agent', '--sandbox']}
|
|
248
|
-
probes={tcpProbes}
|
|
249
|
-
secrets={appSecrets}
|
|
250
|
-
securityContext={{
|
|
251
|
-
runAsNonRoot: true,
|
|
252
|
-
allowPrivilegeEscalation: false,
|
|
253
|
-
seccompProfile: { type: 'RuntimeDefault' },
|
|
254
|
-
capabilities: { drop: ['ALL'] },
|
|
255
|
-
}}
|
|
256
|
-
podSecurityContext={{ seccompProfile: { type: 'RuntimeDefault' } }}
|
|
257
|
-
resources={sandboxResources}
|
|
258
|
-
/>
|
|
259
|
-
) : null}
|
|
260
|
-
</>
|
|
261
|
-
),
|
|
262
|
-
})
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
// --- Endpoint ---------------------------------------------------------------
|
|
266
|
-
resources_.push(
|
|
267
|
-
<Endpoint
|
|
268
|
-
name={`${name}-endpoint`}
|
|
269
|
-
namespace={namespace}
|
|
270
|
-
host={host}
|
|
271
|
-
serviceName={name}
|
|
272
|
-
servicePort={3000}
|
|
273
|
-
tls={tls}
|
|
274
|
-
/>
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
return jsx(Fragment, { children: resources_ })
|
|
278
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../tsconfig.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"outDir": "./dist",
|
|
5
|
-
"rootDir": "./src",
|
|
6
|
-
"composite": true
|
|
7
|
-
},
|
|
8
|
-
"include": ["src/**/*"],
|
|
9
|
-
"references": [
|
|
10
|
-
{ "path": "../core" },
|
|
11
|
-
{ "path": "../k8s-types" },
|
|
12
|
-
{ "path": "../crds" },
|
|
13
|
-
{ "path": "../recipes" }
|
|
14
|
-
]
|
|
15
|
-
}
|