init: config service — layered KV store with CF Worker + CLI

- CF Worker with shared/personal scope layering
- Python CLI client (cfg) with sync/get/set/list/delete
- Agent registration script
- Auth via bearer token, sha256 hash lookup
This commit is contained in:
团子 2026-04-20 13:02:11 +00:00
parent 17a1f81e80
commit 4c6271a21a
5 changed files with 436 additions and 2 deletions

View File

@ -1,3 +1,53 @@
# config-service # Config Service
Layered KV config service with shared/personal scopes — CF Worker + KV Layered KV config store with scope-based override. Built on Cloudflare Workers + KV.
## Concept
Like git config's `system → global → local` layering:
- **shared** — team-wide config (e.g. `CF_ACCOUNT_ID`, `AWS_REGION`)
- **personal** — per-agent overrides (e.g. `GITEA_TOKEN`, `GH_TOKEN`)
Read: personal wins over shared. Write: must specify scope.
## Auth
Each agent has a token. The service stores `sha256(token) → agent_id` mappings.
Agents can read/write their own personal scope and read (but not write) the shared scope.
Shared scope writes require an admin token.
## API
```
GET /config/:key → returns personal value, fallback to shared
GET /config?scope=shared → list all shared keys
GET /config?scope=personal → list all personal keys
PUT /config/:key → write to personal scope (default)
PUT /config/:key?scope=shared → write to shared scope (admin only)
DELETE /config/:key → delete from personal scope
DELETE /config/:key?scope=shared → delete from shared (admin only)
POST /config/sync → returns all resolved keys (personal over shared)
```
Auth header: `Authorization: Bearer <token>`
## Storage Layout (KV)
```
auth:<sha256(token)> → { "agent_id": "tuanzi", "role": "agent|admin" }
shared:<key> → { "value": "...", "updated_at": "..." }
personal:<agent_id>:<key> → { "value": "...", "updated_at": "..." }
```
## CLI
```bash
cfg get <KEY> # read (personal > shared)
cfg set <KEY> <VALUE> # write to personal
cfg set --shared <KEY> <VALUE> # write to shared (admin)
cfg list # list all resolved
cfg list --scope shared # list shared only
cfg sync # sync all to local cache
cfg delete <KEY> # delete from personal
```

166
cli/cfg Executable file
View File

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""cfg — CLI client for the config service.
Usage:
cfg get <KEY> Read a key (personal > shared)
cfg set <KEY> <VALUE> Write to personal scope
cfg set --shared <KEY> <VALUE> Write to shared scope (admin)
cfg list [--scope shared|personal] List keys
cfg sync Sync all to local cache
cfg delete <KEY> [--shared] Delete a key
cfg token <TOKEN> Save auth token
Config: ~/.config/cfg/config.json
Cache: ~/.config/cfg/cache.json
"""
import json
import os
import sys
import urllib.request
import urllib.error
from pathlib import Path
CONFIG_DIR = Path.home() / ".config" / "cfg"
CONFIG_FILE = CONFIG_DIR / "config.json"
CACHE_FILE = CONFIG_DIR / "cache.json"
DEFAULT_ENDPOINT = "https://config-service.oc-fleet.workers.dev"
def load_config() -> dict:
if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text())
return {}
def save_config(cfg: dict):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
def get_token() -> str:
cfg = load_config()
token = cfg.get("token") or os.environ.get("CFG_TOKEN")
if not token:
print("No token configured. Run: cfg token <TOKEN>", file=sys.stderr)
sys.exit(1)
return token
def get_endpoint() -> str:
cfg = load_config()
return cfg.get("endpoint", DEFAULT_ENDPOINT)
def api(method: str, path: str, body: dict | None = None) -> dict:
url = f"{get_endpoint()}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {get_token()}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
result = json.loads(e.read())
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
sys.exit(1)
def cmd_get(args: list[str]):
if not args:
print("Usage: cfg get <KEY>", file=sys.stderr)
sys.exit(1)
result = api("GET", f"/config/{args[0]}")
print(result["value"])
def cmd_set(args: list[str]):
shared = "--shared" in args
args = [a for a in args if a != "--shared"]
if len(args) < 2:
print("Usage: cfg set [--shared] <KEY> <VALUE>", file=sys.stderr)
sys.exit(1)
key, value = args[0], args[1]
scope = "shared" if shared else "personal"
api("PUT", f"/config/{key}?scope={scope}", {"value": value})
print(f"✓ Set {key} ({scope})")
def cmd_delete(args: list[str]):
shared = "--shared" in args
args = [a for a in args if a != "--shared"]
if not args:
print("Usage: cfg delete <KEY> [--shared]", file=sys.stderr)
sys.exit(1)
scope = "shared" if shared else "personal"
api("DELETE", f"/config/{args[0]}?scope={scope}")
print(f"✓ Deleted {args[0]} ({scope})")
def cmd_list(args: list[str]):
scope = None
if "--scope" in args:
idx = args.index("--scope")
if idx + 1 < len(args):
scope = args[idx + 1]
path = "/config" + (f"?scope={scope}" if scope else "")
result = api("GET", path)
if "keys" in result:
for k in sorted(result["keys"]):
print(f" {k}")
elif "secrets" in result:
for k, v in sorted(result["secrets"].items()):
print(f" {k:<40} ({v['scope']})")
def cmd_sync(args: list[str]):
result = api("POST", "/config/sync")
# Save to cache
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
cache = {
"secrets": {k: {"value": v["value"], "updatedAt": v["updated_at"]} for k, v in result["secrets"].items()},
"lastSync": result.get("agent_id", ""),
}
CACHE_FILE.write_text(json.dumps(cache, indent=2))
print(f"✓ Synced {len(result['secrets'])} keys")
def cmd_token(args: list[str]):
if not args:
print("Usage: cfg token <TOKEN>", file=sys.stderr)
sys.exit(1)
cfg = load_config()
cfg["token"] = args[0]
save_config(cfg)
print("✓ Token saved")
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(0)
cmd = sys.argv[1]
args = sys.argv[2:]
commands = {
"get": cmd_get,
"set": cmd_set,
"delete": cmd_delete,
"list": cmd_list,
"sync": cmd_sync,
"token": cmd_token,
}
if cmd not in commands:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(__doc__)
sys.exit(1)
commands[cmd](args)
if __name__ == "__main__":
main()

43
scripts/register_agent.py Normal file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Register an agent token in the config service KV.
Usage: python3 register_agent.py <agent_id> <role> [token]
If token is omitted, a random one is generated.
Outputs the KV entry to add via wrangler CLI.
"""
import hashlib
import json
import secrets
import sys
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <agent_id> <role> [token]")
print(" role: agent or admin")
sys.exit(1)
agent_id = sys.argv[1]
role = sys.argv[2]
token = sys.argv[3] if len(sys.argv) > 3 else secrets.token_urlsafe(32)
if role not in ("agent", "admin"):
print("Role must be 'agent' or 'admin'", file=sys.stderr)
sys.exit(1)
token_hash = hashlib.sha256(token.encode()).hexdigest()
entry = json.dumps({"agent_id": agent_id, "role": role})
print(f"Agent ID: {agent_id}")
print(f"Role: {role}")
print(f"Token: {token}")
print(f"Token hash: {token_hash}")
print()
print("Add to KV:")
print(f' wrangler kv key put --binding CONFIG_KV "auth:{token_hash}" \'{entry}\'')
if __name__ == "__main__":
main()

168
worker/src/index.ts Normal file
View File

@ -0,0 +1,168 @@
export interface Env {
CONFIG_KV: KVNamespace;
}
interface AuthInfo {
agent_id: string;
role: "agent" | "admin";
}
interface KVEntry {
value: string;
updated_at: string;
}
async function sha256(text: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function authenticate(request: Request, kv: KVNamespace): Promise<AuthInfo | null> {
const auth = request.headers.get("Authorization");
if (!auth?.startsWith("Bearer ")) return null;
const token = auth.slice(7);
const hash = await sha256(token);
const entry = await kv.get(`auth:${hash}`, "json");
return entry as AuthInfo | null;
}
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function err(message: string, status = 400): Response {
return json({ error: message }, status);
}
// GET /config/:key — read with personal > shared fallback
async function handleGet(key: string, auth: AuthInfo, kv: KVNamespace): Promise<Response> {
// Try personal first
const personal = await kv.get<KVEntry>(`personal:${auth.agent_id}:${key}`, "json");
if (personal) {
return json({ key, value: personal.value, scope: "personal", updated_at: personal.updated_at });
}
// Fallback to shared
const shared = await kv.get<KVEntry>(`shared:${key}`, "json");
if (shared) {
return json({ key, value: shared.value, scope: "shared", updated_at: shared.updated_at });
}
return err("not found", 404);
}
// PUT /config/:key — write to specified scope
async function handlePut(
key: string,
scope: string,
auth: AuthInfo,
kv: KVNamespace,
request: Request
): Promise<Response> {
const body = await request.json<{ value: string }>();
if (!body?.value && body?.value !== "") return err("missing 'value' in body");
const entry: KVEntry = { value: body.value, updated_at: new Date().toISOString() };
if (scope === "shared") {
if (auth.role !== "admin") return err("admin required for shared scope", 403);
await kv.put(`shared:${key}`, JSON.stringify(entry));
} else {
await kv.put(`personal:${auth.agent_id}:${key}`, JSON.stringify(entry));
}
return json({ key, scope, status: "ok" });
}
// DELETE /config/:key
async function handleDelete(key: string, scope: string, auth: AuthInfo, kv: KVNamespace): Promise<Response> {
if (scope === "shared") {
if (auth.role !== "admin") return err("admin required for shared scope", 403);
await kv.delete(`shared:${key}`);
} else {
await kv.delete(`personal:${auth.agent_id}:${key}`);
}
return json({ key, scope, status: "deleted" });
}
// GET /config?scope=... — list keys
async function handleList(scope: string | null, auth: AuthInfo, kv: KVNamespace): Promise<Response> {
if (scope === "shared") {
const list = await kv.list({ prefix: "shared:" });
const keys = list.keys.map((k) => k.name.replace("shared:", ""));
return json({ scope: "shared", keys });
}
if (scope === "personal") {
const list = await kv.list({ prefix: `personal:${auth.agent_id}:` });
const keys = list.keys.map((k) => k.name.replace(`personal:${auth.agent_id}:`, ""));
return json({ scope: "personal", keys });
}
// Default: return all resolved keys
return handleSync(auth, kv);
}
// POST /config/sync — return all resolved key-values (personal > shared)
async function handleSync(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
const resolved: Record<string, { value: string; scope: string; updated_at: string }> = {};
// Load shared first
const sharedList = await kv.list({ prefix: "shared:" });
for (const k of sharedList.keys) {
const key = k.name.replace("shared:", "");
const entry = await kv.get<KVEntry>(k.name, "json");
if (entry) resolved[key] = { value: entry.value, scope: "shared", updated_at: entry.updated_at };
}
// Personal overrides
const personalList = await kv.list({ prefix: `personal:${auth.agent_id}:` });
for (const k of personalList.keys) {
const key = k.name.replace(`personal:${auth.agent_id}:`, "");
const entry = await kv.get<KVEntry>(k.name, "json");
if (entry) resolved[key] = { value: entry.value, scope: "personal", updated_at: entry.updated_at };
}
return json({ agent_id: auth.agent_id, secrets: resolved });
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Health check
if (path === "/health") return json({ status: "ok" });
// Auth required for everything else
const auth = await authenticate(request, env.CONFIG_KV);
if (!auth) return err("unauthorized", 401);
const scope = url.searchParams.get("scope") || "personal";
// POST /config/sync
if (path === "/config/sync" && method === "POST") {
return handleSync(auth, env.CONFIG_KV);
}
// GET /config — list
if (path === "/config" && method === "GET") {
return handleList(url.searchParams.get("scope"), auth, env.CONFIG_KV);
}
// /config/:key
const keyMatch = path.match(/^\/config\/(.+)$/);
if (!keyMatch) return err("not found", 404);
const key = decodeURIComponent(keyMatch[1]);
switch (method) {
case "GET":
return handleGet(key, auth, env.CONFIG_KV);
case "PUT":
return handlePut(key, scope, auth, env, request);
case "DELETE":
return handleDelete(key, scope, auth, env.CONFIG_KV);
default:
return err("method not allowed", 405);
}
},
};

7
worker/wrangler.toml Normal file
View File

@ -0,0 +1,7 @@
name = "config-service"
main = "src/index.ts"
compatibility_date = "2024-12-01"
[[kv_namespaces]]
binding = "CONFIG_KV"
id = ""