@elinpf/dsh-ops-access-ceph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ title: "@elinpf/dsh-ops-access-ceph"
2
+ description:
3
+ zh: "运维模式 Ceph 凭证 provider — 校验 ceph.conf/keyring 条目并在保存时探测 ro/rw tier"
4
+ en: "Ceph credential provider for ops mode — validates ceph.conf/keyring entries and probes ro/rw tiers at save time"
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @elinpf/dsh-ops-access-ceph
2
+
3
+ The Ceph credential provider for DeepSeek Harness ops mode — validates `ceph` registry entries (ceph.conf + keyring), expands their paths, and probes claimed ro/rw tiers against the cluster's real cephx caps at save time.
4
+
5
+ ## What it does
6
+
7
+ One provider per credential kind, per the ops-access three-role split: core owns the registry and the `ctx.opsAccess` service; this package supplies only the ceph kind — a zod entry schema plus field processing.
8
+
9
+ - **Entry schema**: `{ conf, keyring, name? }` — the admin UI accepts ceph.conf and keyring CONTENT; core writes them to managed files under `~/.dsh-ops/credentials/` and stores the paths. `name` is the cephx entity (defaults to `client.admin`).
10
+ - **process**: expands a leading `~` in both paths for the tool's `--conf`/`--keyring` flags.
11
+ - **validateContent**: save-time paste guard — `[global]` + `mon_host` required in the conf; an indented strict-base64 `key =` line under a `[client.x]` section in the keyring. Structural only, no connectivity checks.
12
+ - **Capability probe** (ticket 10): at save time re-reads the entity's caps via `ceph auth get` and compares with the claimed tier. ro verifies only when no cap grants write (permission bundles like `rwx`/`wx` count; the `allow` keyword never does; pool qualifiers are inert). Failures degrade to `unverifiable` — a tight ro entity that cannot self-read its caps is normal, not an error.
13
+ - **derivationDoc**: the ro self-registration recipe (`client.<id>-ro` with `allow r` on mon/osd/mds/mgr), surfaced through `list_access` help.
14
+
15
+ ## Design notes
16
+
17
+ - Structural validation lives in the provider, connectivity in the probe: a garbled paste fails at save time instead of surfacing as "cannot parse buffer: Malformed input" at connection time.
18
+ - Trailing-newline normalization is core's job (`normalizeTrailingNewline: true` opts in) — the provider no longer rejects a missing trailing newline.
19
+ - Probe stderr is classified by substring and never surfaced: ceph error messages can carry file paths.
20
+
21
+ ## Configuration
22
+
23
+ Schemastery `Config`, one field:
24
+
25
+ - `probeTimeoutMs` (number, default `10000`) — timeout for the save-time `ceph auth get` call. Slow clusters may need more.
26
+
27
+ ## Installation
28
+
29
+ Provider row of the ops preset's `agent.cordis.yml`:
30
+
31
+ ```yaml
32
+ - id: ops-access-ceph
33
+ name: '@elinpf/dsh-ops-access-ceph'
34
+ ```
35
+
36
+ Registration goes through `registerAccessProvider` (deferred `ctx.inject` + effect lifecycle, so HMR unloads it) — never a static `inject` on `opsAccess`, which deadlocks the loader.
37
+
38
+ ## Testing
39
+
40
+ ```sh
41
+ npm run build # tsc → lib/
42
+ npx vitest run # schema, process, paste guard, probe classification, registration/HMR disposal
43
+ ```
44
+
45
+ No cluster needed: the live-probe test points at nonexistent paths and asserts the result degrades to `unverifiable` without leaking paths.
package/README.zh.md ADDED
@@ -0,0 +1,45 @@
1
+ # @elinpf/dsh-ops-access-ceph
2
+
3
+ 运维模式 Ceph 凭证 provider — 校验 `ceph` 注册表条目(ceph.conf + keyring)、展开路径,并在保存时用集群真实 cephx caps 探测声称的 ro/rw tier。
4
+
5
+ ## 功能
6
+
7
+ 按 ops-access 三角色拆分,一种凭证一个 provider:core 拥有注册表和 `ctx.opsAccess` 服务;本包只提供 ceph 这一种 — 一个 zod 条目 schema 加字段处理。
8
+
9
+ - **条目 schema**:`{ conf, keyring, name? }` — 管理 UI 接受 ceph.conf 和 keyring 的内容;core 将其写入 `~/.dsh-ops/credentials/` 下的受管文件并存路径。`name` 是 cephx 实体(缺省 `client.admin`)。
10
+ - **process**:展开两个路径开头的 `~`,供工具的 `--conf`/`--keyring` 参数使用。
11
+ - **validateContent**:保存时的粘贴防护 — conf 必须有 `[global]` 和 `mon_host`;keyring 必须在 `[client.x]` 段下有缩进的严格 base64 `key =` 行。只查结构,不做连通性检查。
12
+ - **能力探测**(ticket 10):保存时通过 `ceph auth get` 重读实体的 caps,与声称的 tier 对比。ro 只有在没有任何 cap 授予写权限时才通过(`rwx`/`wx` 这类权限束算写,`allow` 关键字本身不算,pool 限定符不影响判定)。失败降级为 `unverifiable` — 收紧的 ro 实体无法自读 caps 是正常现象,不是错误。
13
+ - **derivationDoc**:ro 自助注册配方(`client.<id>-ro`,mon/osd/mds/mgr 均 `allow r`),通过 `list_access` 的 help 暴露。
14
+
15
+ ## 设计要点
16
+
17
+ - 结构校验在 provider,连通性在探测:粘贴损坏在保存时就报错,而不是到连接时才冒出 "cannot parse buffer: Malformed input"。
18
+ - 末尾换行归一化是 core 的职责(`normalizeTrailingNewline: true` 选择加入)— provider 不再因缺末尾换行而拒绝。
19
+ - 探测的 stderr 只按子串分类、从不外泄:ceph 错误信息可能携带文件路径。
20
+
21
+ ## 配置
22
+
23
+ Schemastery `Config`,一个字段:
24
+
25
+ - `probeTimeoutMs`(数字,默认 `10000`)— 保存时 `ceph auth get` 调用的超时。慢集群可能需要调大。
26
+
27
+ ## 安装
28
+
29
+ ops preset 的 `agent.cordis.yml` 中的 provider 行:
30
+
31
+ ```yaml
32
+ - id: ops-access-ceph
33
+ name: '@elinpf/dsh-ops-access-ceph'
34
+ ```
35
+
36
+ 注册走 `registerAccessProvider`(延迟 `ctx.inject` + effect 生命周期,HMR 可卸载)— 绝不静态 `inject` `opsAccess`,那会死锁加载器。
37
+
38
+ ## 测试
39
+
40
+ ```sh
41
+ npm run build # tsc → lib/
42
+ npx vitest run # schema、process、粘贴防护、探测分类、注册/HMR 卸载
43
+ ```
44
+
45
+ 无需集群:活体探测测试指向不存在的路径,断言结果降级为 `unverifiable` 且不泄露路径。
@@ -0,0 +1 @@
1
+ []
package/lib/index.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Ops access provider for Ceph.
3
+ *
4
+ * Validates `ceph` registry entries (`{ conf, keyring, name? }`). The admin
5
+ * UI accepts ceph.conf and keyring CONTENT; core writes it to managed files
6
+ * under ~/.dsh-ops/credentials/ and stores the path in the registry. The
7
+ * provider expands ~ in the path for the tool's --conf/--keyring flags.
8
+ *
9
+ * @module @elinpf/dsh-ops-access-ceph
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ import z from '@deepseek-ai/schemastery';
13
+ import { z as zod } from 'zod';
14
+ import type { AccessProvider } from '@elinpf/dsh-ops-access';
15
+ import type { CapAssessment, ProbeFailure } from './types.js';
16
+ export type { CapAssessment, ProbeFailure, ProbeOutcome } from './types.js';
17
+ export declare const name = "ops-access-ceph";
18
+ export declare const inject: string[];
19
+ export declare const Config: z<Schemastery.ObjectS<{
20
+ /** Save-time probe: timeout for the `ceph auth get` call (ms). Slow clusters may need more. */
21
+ probeTimeoutMs: z<number, number>;
22
+ }>, Schemastery.ObjectT<{
23
+ /** Save-time probe: timeout for the `ceph auth get` call (ms). Slow clusters may need more. */
24
+ probeTimeoutMs: z<number, number>;
25
+ }>>;
26
+ /** Zod schema for one ceph registry entry (excluding name and envelope fields). */
27
+ export declare const entrySchema: zod.ZodObject<{
28
+ conf: zod.ZodString;
29
+ keyring: zod.ZodString;
30
+ name: zod.ZodOptional<zod.ZodString>;
31
+ }, zod.core.$strip>;
32
+ export declare const provider: AccessProvider;
33
+ /** Parse `caps <daemon> = "<value>"` lines from `ceph auth get` output. */
34
+ export declare function parseCaps(output: string): Record<string, string>;
35
+ /**
36
+ * Pure caps-vs-tier assessment (unit-tested directly). ro verifies when
37
+ * every daemon cap is read-only ('allow r', optionally with class-read —
38
+ * librbd object-class reads, ticket 14); rw verifies when at least one cap
39
+ * grants write.
40
+ */
41
+ export declare function assessCephCaps(caps: Record<string, string>, tier: 'ro' | 'rw'): CapAssessment;
42
+ /**
43
+ * Classify an auth-get failure (unit-tested directly). stderr is classified
44
+ * by substring, never surfaced: ceph error messages can carry file paths.
45
+ * A tight ro entity CANNOT self-read the auth database (EACCES) — that is
46
+ * normal, and safe: an over-privileged credential sitting in the ro slot
47
+ * would have enough privilege for auth get and would have been caught by
48
+ * the caps comparison.
49
+ */
50
+ export declare function cephProbeFailure(tier: 'ro' | 'rw', errText: string): ProbeFailure;
51
+ export declare function apply(ctx: Context, config: {
52
+ probeTimeoutMs: number;
53
+ }): void;
package/lib/index.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Ops access provider for Ceph.
3
+ *
4
+ * Validates `ceph` registry entries (`{ conf, keyring, name? }`). The admin
5
+ * UI accepts ceph.conf and keyring CONTENT; core writes it to managed files
6
+ * under ~/.dsh-ops/credentials/ and stores the path in the registry. The
7
+ * provider expands ~ in the path for the tool's --conf/--keyring flags.
8
+ *
9
+ * @module @elinpf/dsh-ops-access-ceph
10
+ */
11
+ import z from '@deepseek-ai/schemastery';
12
+ import { z as zod } from 'zod';
13
+ import { execFile } from 'node:child_process';
14
+ import { expandHome, registerAccessProvider } from '@elinpf/dsh-ops-access';
15
+ // ── Plugin identity ───────────────────────────────────────────────────────────
16
+ export const name = 'ops-access-ceph';
17
+ export const inject = [];
18
+ export const Config = z.object({
19
+ /** Save-time probe: timeout for the `ceph auth get` call (ms). Slow clusters may need more. */
20
+ probeTimeoutMs: z.number().default(10000),
21
+ });
22
+ // ── Provider ─────────────────────────────────────────────────────────────────
23
+ /** Zod schema for one ceph registry entry (excluding name and envelope fields). */
24
+ export const entrySchema = zod.object({
25
+ conf: zod.string(),
26
+ keyring: zod.string(),
27
+ name: zod.string().optional(),
28
+ });
29
+ export const provider = {
30
+ kind: 'ceph',
31
+ schema: entrySchema,
32
+ fieldsDoc: 'conf: ceph.conf content; keyring: keyring content; name: optional cephx user (e.g. client.dsh-test) — defaults to client.admin when omitted',
33
+ fileFields: ['conf', 'keyring'],
34
+ derivationDoc: "from the rw keyring: ceph auth add client.<id>-ro mon 'allow r' osd 'allow r' mds 'allow r' mgr 'allow r' (naming convention: client.<id>-ro), export it with ceph auth get client.<id>-ro, then register via register_access with the keyring content, a copy of conf, and name set to client.<id>-ro — verify with ceph status",
35
+ process(entry) {
36
+ const { conf, keyring, name } = entry;
37
+ const fields = { conf: expandHome(conf), keyring: expandHome(keyring) };
38
+ if (name !== undefined)
39
+ fields.name = name;
40
+ return fields;
41
+ },
42
+ // A missing trailing newline used to be rejected here (ceph's buffer
43
+ // parser rejects it); core now normalizes it away at write time
44
+ // (normalizeTrailingNewline). What remains is structure: key lines
45
+ // indented under their [section], strict base64 — caught here instead of
46
+ // surfacing as "cannot parse buffer: Malformed input" at connection time.
47
+ // Structural only — no connectivity checks.
48
+ normalizeTrailingNewline: true,
49
+ // Ticket 10: re-read the entity's caps at save time and compare with the tier.
50
+ probe: probeCeph,
51
+ validateContent(field, content) {
52
+ if (field !== 'conf' && field !== 'keyring')
53
+ return null;
54
+ if (field === 'conf') {
55
+ if (!/^\[global\][\t ]*$/m.test(content))
56
+ return 'no [global] section — paste the full ceph.conf';
57
+ if (!/^[\t ]*mon_host[\t ]*=/m.test(content))
58
+ return 'no mon_host set — paste the full ceph.conf';
59
+ return null;
60
+ }
61
+ // keyring
62
+ if (!/^\[client\.[^\]]+\][\t ]*$/m.test(content))
63
+ return 'no [client.<name>] section — paste the full keyring';
64
+ const keyMatch = content.match(/^[\t ]+key[\t ]*=[\t ]*(\S+)[\t ]*$/m);
65
+ if (!keyMatch) {
66
+ return 'no indented "key = <base64>" line — ceph requires the key line to be indented under its [client.x] section';
67
+ }
68
+ // Strict base64 alphabet + decoded length >= 16 bytes (cephx AES keys
69
+ // are longer; this only rules out garbled pastes). Decoded length is
70
+ // derived from the encoded length — no Buffer dependency here.
71
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyMatch[1]) || keyMatch[1].length < 24) {
72
+ return 'the key value is not valid base64 (or too short for a cephx key)';
73
+ }
74
+ return null;
75
+ },
76
+ };
77
+ // ── Capability probe (ticket 10) ─────────────────────────────────────────────
78
+ /** Parse `caps <daemon> = "<value>"` lines from `ceph auth get` output. */
79
+ export function parseCaps(output) {
80
+ const caps = {};
81
+ for (const line of output.split('\n')) {
82
+ const m = line.match(/^\s*caps (\w+) = "(.*)"\s*$/);
83
+ if (m)
84
+ caps[m[1]] = m[2];
85
+ }
86
+ return caps;
87
+ }
88
+ /**
89
+ * A cap value grants write when any permission token carries the w flag.
90
+ * Tokens after 'allow' are permission bundles (r/w/x in any order — 'w',
91
+ * 'rw', 'rwx', 'wx' are ALL writable) or '*'; qualifiers like 'pool=foo'
92
+ * never grant write by themselves. NB: the keyword 'allow' itself
93
+ * contains a 'w' — match permission-bundle tokens, never the keyword
94
+ * (review fix: the exact-token version missed 'rwx'/'wx').
95
+ */
96
+ function capIsWritable(value) {
97
+ return value.split(/\s+/).some((t) => t === '*' || (/^[rwx]+$/.test(t) && t.includes('w')));
98
+ }
99
+ /**
100
+ * Pure caps-vs-tier assessment (unit-tested directly). ro verifies when
101
+ * every daemon cap is read-only ('allow r', optionally with class-read —
102
+ * librbd object-class reads, ticket 14); rw verifies when at least one cap
103
+ * grants write.
104
+ */
105
+ export function assessCephCaps(caps, tier) {
106
+ const summary = Object.entries(caps).map(([d, v]) => d + '="' + v + '"').join(' ');
107
+ if (Object.keys(caps).length === 0)
108
+ return { status: 'mismatch', detail: 'auth get returned no caps lines' };
109
+ if (tier === 'ro') {
110
+ const writable = Object.entries(caps).filter(([, v]) => capIsWritable(v)).map(([d]) => d);
111
+ if (writable.length === 0)
112
+ return { status: 'verified' };
113
+ return { status: 'mismatch', detail: 'claims ro but caps grant write on ' + writable.join('/') + ' — ' + summary };
114
+ }
115
+ if (Object.values(caps).some(capIsWritable))
116
+ return { status: 'verified' };
117
+ return { status: 'mismatch', detail: 'claims rw but no cap grants write — ' + summary };
118
+ }
119
+ /**
120
+ * Classify an auth-get failure (unit-tested directly). stderr is classified
121
+ * by substring, never surfaced: ceph error messages can carry file paths.
122
+ * A tight ro entity CANNOT self-read the auth database (EACCES) — that is
123
+ * normal, and safe: an over-privileged credential sitting in the ro slot
124
+ * would have enough privilege for auth get and would have been caught by
125
+ * the caps comparison.
126
+ */
127
+ export function cephProbeFailure(tier, errText) {
128
+ if (tier === 'ro' && /EACCES|access denied/i.test(errText)) {
129
+ return { status: 'unverifiable', detail: 'entity cannot self-read its caps (normal for a tight ro entity — an over-privileged credential in this slot would have the privilege to auth get and would have been caught)' };
130
+ }
131
+ return { status: 'unverifiable', detail: 'ceph auth get could not run (cluster unreachable or ceph CLI missing)' };
132
+ }
133
+ async function probeCeph(fields, tier, timeoutMs = 10000) {
134
+ const name = typeof fields.name === 'string' ? fields.name : 'client.admin';
135
+ const conf = String(fields.conf ?? '');
136
+ const keyring = String(fields.keyring ?? '');
137
+ const result = await new Promise((resolve) => {
138
+ execFile('ceph', ['--conf', conf, '--keyring', keyring, '--name', name, 'auth', 'get', name], { timeout: timeoutMs }, (err, stdout, stderr) => resolve(err
139
+ ? { output: null, errText: String(stderr ?? '') + ' ' + String(err.message ?? '') }
140
+ : { output: stdout, errText: '' }));
141
+ });
142
+ if (result.output === null)
143
+ return cephProbeFailure(tier, result.errText);
144
+ return assessCephCaps(parseCaps(result.output), tier);
145
+ }
146
+ // ── Plugin apply ─────────────────────────────────────────────────────────────
147
+ export function apply(ctx, config) {
148
+ registerAccessProvider(ctx, {
149
+ ...provider,
150
+ probe: (fields, tier) => probeCeph(fields, tier, config.probeTimeoutMs),
151
+ });
152
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Invariant companion for @elinpf/dsh-ops-access-ceph.
3
+ *
4
+ * @module @elinpf/dsh-ops-access-ceph/invariant
5
+ */
6
+ /** Cordis companion plugin name. */
7
+ declare const name = "ops-access-ceph-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Context carrying the invariant service.
13
+ * @returns a promise resolving after registration.
14
+ */
15
+ declare const apply: (ctx: any) => Promise<void>;
16
+ export { apply, inject, name };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Invariant companion for @elinpf/dsh-ops-access-ceph.
3
+ *
4
+ * @module @elinpf/dsh-ops-access-ceph/invariant
5
+ */
6
+ const PACKAGE_NAME = '@elinpf/dsh-ops-access-ceph';
7
+ /** Cordis companion plugin name. */
8
+ const name = 'ops-access-ceph-invariant';
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ['invariants'];
11
+ /**
12
+ * No runtime invariant: this provider owns no session events and no durable
13
+ * shape of its own — it only validates and processes registry fields owned
14
+ * by ops-access core. Content validation is a pure paste guard, and the
15
+ * capability probe is read-only: failures degrade to 'unverifiable' instead
16
+ * of mutating any state.
17
+ */
18
+ const install = () => { };
19
+ /**
20
+ * Register this package's invariant companion.
21
+ * @param ctx - Context carrying the invariant service.
22
+ * @returns a promise resolving after registration.
23
+ */
24
+ const apply = async (ctx) => {
25
+ ctx.invariants.register(PACKAGE_NAME, install);
26
+ };
27
+ export { apply, inject, name };
package/lib/types.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Type definitions for the ops-access-ceph provider plugin.
3
+ *
4
+ * Types only — no runtime code lives here. Provider values (schema, probe
5
+ * functions, plugin apply) stay in index.ts.
6
+ *
7
+ * @module @elinpf/dsh-ops-access-ceph
8
+ */
9
+ /**
10
+ * Result of the pure caps-vs-tier assessment: the entity's real caps either
11
+ * verify the claimed tier or mismatch it.
12
+ */
13
+ export interface CapAssessment {
14
+ status: 'verified' | 'mismatch';
15
+ detail?: string;
16
+ }
17
+ /**
18
+ * A classified probe failure: the probe itself could not run (cluster
19
+ * unreachable, ceph CLI missing, or a tight ro entity that cannot self-read
20
+ * its caps). Always 'unverifiable' — never a rejection.
21
+ */
22
+ export interface ProbeFailure {
23
+ status: 'unverifiable';
24
+ detail: string;
25
+ }
26
+ /** Outcome of a save-time capability probe. */
27
+ export type ProbeOutcome = CapAssessment | ProbeFailure;
package/lib/types.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Type definitions for the ops-access-ceph provider plugin.
3
+ *
4
+ * Types only — no runtime code lives here. Provider values (schema, probe
5
+ * functions, plugin apply) stay in index.ts.
6
+ *
7
+ * @module @elinpf/dsh-ops-access-ceph
8
+ */
9
+ export {};
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@elinpf/dsh-ops-access-ceph",
3
+ "version": "0.1.0",
4
+ "description": "Ops access provider for Ceph — validates ceph registry entries and expands conf/keyring paths.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./invariant": {
14
+ "types": "./lib/invariant.d.ts",
15
+ "default": "./lib/invariant.js"
16
+ },
17
+ "./types": {
18
+ "types": "./lib/types.d.ts",
19
+ "default": "./lib/types.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "lib/index.js",
25
+ "lib/invariant.js",
26
+ "lib/types.js",
27
+ "lib/**/*.d.ts",
28
+ "cordis.patch.yml"
29
+ ],
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ }
34
+ },
35
+ "dependencies": {
36
+ "@deepseek-ai/schemastery": "^3.18.1",
37
+ "zod": "^4.4.3"
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/cordis": "^4.0.1",
41
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
42
+ "@elinpf/dsh-ops-access": "^0.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@deepseek-ai/cordis": "4.0.1",
46
+ "typescript": "^5.4.0",
47
+ "vitest": "^4.1.11",
48
+ "@types/node": "^22.0.0",
49
+ "@elinpf/dsh-ops-access": "0.1.0"
50
+ },
51
+ "license": "MIT",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc",
57
+ "typecheck": "tsc --noEmit",
58
+ "test": "vitest run"
59
+ }
60
+ }