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:
parent
17a1f81e80
commit
4c6271a21a
54
README.md
54
README.md
@ -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
166
cli/cfg
Executable 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
43
scripts/register_agent.py
Normal 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
168
worker/src/index.ts
Normal 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
7
worker/wrangler.toml
Normal file
@ -0,0 +1,7 @@
|
||||
name = "config-service"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-12-01"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "CONFIG_KV"
|
||||
id = ""
|
||||
Loading…
x
Reference in New Issue
Block a user