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