diff --git a/console/index.html b/console/index.html index ad8254e..84f4aba 100644 --- a/console/index.html +++ b/console/index.html @@ -19,6 +19,17 @@ + + + edit fleets.toml + + + Save + Cancel + + + + diff --git a/console/package-lock.json b/console/package-lock.json index a45dd0e..0ce814d 100644 --- a/console/package-lock.json +++ b/console/package-lock.json @@ -7,6 +7,13 @@ "": { "name": "studio-console", "version": "0.1.0", + "dependencies": { + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.8", + "codemirror": "^6.0.2" + }, "devDependencies": { "@types/node": "^22.10.0", "typescript": "^5.6.3", @@ -14,6 +21,96 @@ "vitest": "^2.1.8" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", + "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -463,6 +560,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1022,6 +1149,27 @@ "node": ">= 16" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1338,6 +1486,12 @@ "dev": true, "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2591,6 +2745,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/console/package.json b/console/package.json index 17853ee..dfa5d06 100644 --- a/console/package.json +++ b/console/package.json @@ -16,5 +16,12 @@ "typescript": "^5.6.3", "vite": "^6.0.7", "vitest": "^2.1.8" + }, + "dependencies": { + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.8", + "codemirror": "^6.0.2" } } diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index 9bfbca7..1060242 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -83,4 +83,19 @@ export const FIXTURE_FLEET_CONFIG: FleetConfig = { expected_principal: null, }, ], + text: `# OAB Studio fleet bindings — which credential manages which fleet. + +[[fleet]] +name = "prod" +cluster = "oab" +region = "ap-east-2" +profile = "orca-prod" +expected_principal = "arn:aws:iam::504190915686:role/openab-orca-task-role" + +[[fleet]] +name = "staging" +cluster = "oab-staging" +region = "ap-southeast-1" +profile = "orca-staging" +`, }; diff --git a/console/src/main.ts b/console/src/main.ts index b6eea23..35a098f 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -2,6 +2,10 @@ import { defaultSource } from "./source"; import { renderRoster, renderIdentity, renderFleetConfig } from "./render"; import type { FleetConfig } from "./types"; import { createPane, bindBackend, type Level } from "./log"; +import { EditorView, basicSetup } from "codemirror"; +import { EditorState } from "@codemirror/state"; +import { StreamLanguage } from "@codemirror/language"; +import { toml } from "@codemirror/legacy-modes/mode/toml"; const POLL_MS = 5000; const DEFAULT_CLUSTER = "oab"; @@ -15,6 +19,12 @@ let fleetConfig: FleetConfig | null = null; const roster = document.getElementById("roster"); const identityEl = document.getElementById("identity"); const configEl = document.getElementById("config"); +const editorSection = document.getElementById("config-editor"); +const editorMount = document.getElementById("cfg-editor-mount"); +const editorError = document.getElementById("cfg-editor-error"); +const editorPathEl = document.getElementById("cfg-editor-path"); +const saveBtn = document.getElementById("cfg-save") as HTMLButtonElement | null; +const cancelBtn = document.getElementById("cfg-cancel") as HTMLButtonElement | null; const clusterLabel = document.getElementById("cluster-label"); const pollStatus = document.getElementById("poll-status"); const logEl = document.getElementById("log"); @@ -138,10 +148,74 @@ function selectCluster(cluster: string): void { void tick(); } -// One delegated listener: a click on any fleet button switches to its cluster. +// ---- fleets.toml editor (ADR #19 slice C: the "edit" side) ------------------- +// A CodeMirror TOML editor over the raw config file. Kept imperative (CM owns +// real DOM) and separate from the re-rendered config panel, so switching fleets +// never wipes an open editor. +let editorView: EditorView | null = null; + +function showEditorError(msg: string | null): void { + if (!editorError) return; + editorError.textContent = msg ?? ""; + editorError.hidden = !msg; +} + +function openEditor(): void { + if (!editorSection || !editorMount) return; + showEditorError(null); + if (editorPathEl) editorPathEl.textContent = fleetConfig?.path ?? ""; + editorView?.destroy(); + editorView = new EditorView({ + parent: editorMount, + state: EditorState.create({ + doc: fleetConfig?.text ?? "", + extensions: [basicSetup, StreamLanguage.define(toml)], + }), + }); + editorSection.hidden = false; + editorView.focus(); +} + +function closeEditor(): void { + editorView?.destroy(); + editorView = null; + if (editorSection) editorSection.hidden = true; + showEditorError(null); +} + +async function saveEditor(): Promise { + if (!editorView || !saveBtn) return; + const text = editorView.state.doc.toString(); + saveBtn.disabled = true; + showEditorError(null); + try { + // The backend validates the TOML and rejects (without writing) on error. + fleetConfig = await source.writeFleetConfig(text); + if (configEl) renderFleetConfig(configEl, fleetConfig, activeCluster); + note("info", "fleet config saved"); + closeEditor(); + // A binding change may alter the active fleet's credential — re-observe. + void refreshIdentity(); + } catch (e) { + showEditorError(`save failed — ${errText(e)}`); + } finally { + saveBtn.disabled = false; + } +} + +saveBtn?.addEventListener("click", () => void saveEditor()); +cancelBtn?.addEventListener("click", () => closeEditor()); + +// One delegated listener on the config panel: "Edit config" opens the editor; +// a click on any fleet button switches to its cluster. if (configEl) { configEl.addEventListener("click", (ev) => { - const btn = (ev.target as HTMLElement).closest("[data-cluster]"); + const target = ev.target as HTMLElement; + if (target.closest('[data-action="edit-config"]')) { + openEditor(); + return; + } + const btn = target.closest("[data-cluster]"); if (btn?.dataset.cluster) selectCluster(btn.dataset.cluster); }); } diff --git a/console/src/render.test.ts b/console/src/render.test.ts index b0fcc23..f76e9a0 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -154,7 +154,12 @@ describe("fleetConfigHtml", () => { it("renders an empty state with the config path when no fleets", () => { const html = fleetConfigHtml( - { path: "~/.config/oab-studio/fleets.toml", default_cluster: "oab", fleets: [] }, + { + path: "~/.config/oab-studio/fleets.toml", + default_cluster: "oab", + fleets: [], + text: "", + }, "oab", ); expect(html).toContain("No fleets configured"); @@ -162,6 +167,12 @@ describe("fleetConfigHtml", () => { expect(html).not.toContain("cfg-fleet"); }); + it("always offers the Edit config action (even with fleets)", () => { + expect(fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab")).toContain( + 'data-action="edit-config"', + ); + }); + it("renders an unavailable state for null", () => { expect(fleetConfigHtml(null, "oab")).toContain("fleet config unavailable"); }); diff --git a/console/src/render.ts b/console/src/render.ts index 3821aaa..1346976 100644 --- a/console/src/render.ts +++ b/console/src/render.ts @@ -163,6 +163,7 @@ export function fleetConfigHtml( fleets ${path} + Edit config ${body} `; diff --git a/console/src/source.ts b/console/src/source.ts index f5d80eb..c2b58d0 100644 --- a/console/src/source.ts +++ b/console/src/source.ts @@ -11,6 +11,9 @@ export interface Source { listDeployments(cluster?: string): Promise; runtimeContext(cluster?: string): Promise; fleetConfig(): Promise; + // Persist the raw TOML `text` of the config file, returning the reloaded + // config. Rejects (without writing) when the text doesn't parse. + writeFleetConfig(text: string): Promise; } // Fixture-backed source for the standalone / browser build — no core required. @@ -24,6 +27,11 @@ export class MockSource implements Source { async fleetConfig(): Promise { return structuredClone(FIXTURE_FLEET_CONFIG); } + // Browser preview: no core, so "saving" just echoes the text back (no + // persistence, no server-side TOML validation). + async writeFleetConfig(text: string): Promise { + return { ...structuredClone(FIXTURE_FLEET_CONFIG), text }; + } } // Minimal shape of the Tauri global bridge (v2, `withGlobalTauri`). Accessed via @@ -54,6 +62,9 @@ export class TauriSource implements Source { async fleetConfig(): Promise { return this.invoke()("fleet_config"); } + async writeFleetConfig(text: string): Promise { + return this.invoke()("fleet_config_write", { text }); + } } // Pick a source: Tauri when running inside the shell, else the mock. diff --git a/console/src/styles.css b/console/src/styles.css index 9d0a1d5..e54362b 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -425,3 +425,74 @@ td.counts.warn { color: var(--muted); margin: 0; } +.cfg-edit { + margin-left: auto; + cursor: pointer; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--muted); + font: inherit; + font-size: 12px; + padding: 3px 10px; +} +.cfg-edit:hover { + color: var(--text); + border-color: var(--s-starting); +} + +/* ---- fleets.toml editor (slice C) ---- */ +.cfg-editor-wrap { + margin: 0 0 12px; +} +.cfg-editor-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} +.cfg-editor-path { + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.cfg-editor-spacer { + flex: 1; +} +.cfg-btn { + cursor: pointer; + border: 1px solid var(--s-starting); + border-radius: 5px; + background: var(--s-starting); + color: #fff; + font: inherit; + font-size: 12px; + padding: 4px 12px; +} +.cfg-btn:disabled { + opacity: 0.5; + cursor: default; +} +.cfg-btn-ghost { + background: var(--bg); + color: var(--muted); + border-color: var(--border); +} +.cfg-btn-ghost:hover { + color: var(--text); +} +.cfg-editor-mount { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + font-size: 13px; +} +.cfg-editor-mount .cm-editor { + max-height: 320px; +} +.cfg-editor-error { + margin-top: 6px; + color: var(--s-unhealthy); + font-size: 12px; + white-space: pre-wrap; +} diff --git a/console/src/types.ts b/console/src/types.ts index 41b7d4a..2f0dc2d 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -62,6 +62,9 @@ export interface FleetConfig { path: string | null; default_cluster: string; fleets: FleetConfigEntry[]; + // Raw TOML text of the config file (what the editor loads/saves); empty when + // no file exists yet. + text: string; } // The effective runtime identity/context the control plane resolved for a diff --git a/crates/oab-mcp/src/main.rs b/crates/oab-mcp/src/main.rs index 1957004..a9240f0 100644 --- a/crates/oab-mcp/src/main.rs +++ b/crates/oab-mcp/src/main.rs @@ -7,7 +7,7 @@ //! //! - read: `deploy_list`, `deploy_get`, `get_agent_states`, `deploy_events`, //! `runtime_context`, `fleet_config` -//! - write: `deploy_apply`, `deploy_scale`, `deploy_delete` +//! - write: `deploy_apply`, `deploy_scale`, `deploy_delete`, `fleet_config_write` //! //! Every tool is a thin dispatch into `studio-cp`; this crate owns only the //! wire (JSON) representation and argument plumbing. Cluster defaults to @@ -23,7 +23,7 @@ use rmcp::service::{RequestContext, RoleServer}; use rmcp::{ErrorData as McpError, ServiceExt}; use serde_json::{json, Map, Value}; use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use studio_cp as scp; /// The control-plane server. Holds the shared AWS config and the default @@ -35,8 +35,9 @@ struct OabMcp { aws: aws_config::SdkConfig, default_cluster: String, /// Declarative fleet → managing-credential bindings (ADR: Per-Fleet - /// managing identity). - bindings: Arc, + /// managing identity). Behind an `RwLock` so the write tools can hot-reload + /// it after editing the config file, without a restart. + bindings: Arc>, /// Where the bindings were loaded from (surfaced by `fleet_config` so the /// operator knows which file to edit); `None` when no config dir resolved. bindings_path: Option, @@ -161,6 +162,17 @@ fn tools() -> Vec { "properties": {} })), ), + Tool::new( + "fleet_config_write", + "Persist the whole fleet-binding config from raw TOML `text` (what the UI's editor holds), then hot-reload. Validates the text parses before writing — a bad edit never lands on disk — and the bytes are stored verbatim, so comments/layout are preserved. Returns the updated fleet_config. Write tool: overwrites the operator's fleets.toml.", + as_map(json!({ + "type": "object", + "properties": { + "text": { "type": "string", "description": "Full TOML document for fleets.toml (a list of [[fleet]] tables)." } + }, + "required": ["text"] + })), + ), ] } @@ -205,9 +217,14 @@ impl OabMcp { /// cluster's calls run under its credential, not whatever ambient /// `[default]` the chain resolves first. async fn aws_for(&self, cluster: &str) -> aws_config::SdkConfig { - let binding = match self.bindings.for_cluster(cluster) { - Some(b) if b.profile.is_some() || b.region.is_some() => b.clone(), - _ => return self.aws.clone(), + // Short read-lock: clone the governing binding, then drop the guard + // before any await (never hold a std lock across .await). + let binding = { + let guard = self.bindings.read().unwrap(); + match guard.for_cluster(cluster) { + Some(b) if b.profile.is_some() || b.region.is_some() => b.clone(), + _ => return self.aws.clone(), + } }; if let Some(cfg) = self.resolved.lock().unwrap().get(cluster) { return cfg.clone(); @@ -370,21 +387,27 @@ impl OabMcp { let cluster = self.cluster(args); let aws = self.aws_for(&cluster).await; let ctx = scp::observe_identity(&aws).await?; - let binding = self.bindings.for_cluster(&cluster); - let expected = binding.and_then(|b| b.expected_principal.clone()); + // Snapshot the governing binding under a short read-lock (no await held). + let (binding, expected) = { + let guard = self.bindings.read().unwrap(); + match guard.for_cluster(&cluster) { + Some(b) => ( + Some(json!({ + "name": b.name, + "profile": b.profile, + "region": b.region, + "expected_principal": b.expected_principal, + })), + b.expected_principal.clone(), + ), + None => (None, None), + } + }; // Reconcile: expected (declared) vs actual (resolved). null when no // expectation is declared. Non-blocking — a warning signal, not a gate. let identity_matches = expected .as_ref() .map(|e| scp::principal_matches(e, &ctx.principal)); - let binding = binding.map(|b| { - json!({ - "name": b.name, - "profile": b.profile, - "region": b.region, - "expected_principal": b.expected_principal, - }) - }); Ok(json!({ "cluster": cluster, "principal": ctx.principal, @@ -406,6 +429,8 @@ impl OabMcp { fn t_fleet_config(&self, _args: &Map) -> Result { let fleets: Vec = self .bindings + .read() + .unwrap() .fleets .iter() .map(|b| { @@ -418,13 +443,46 @@ impl OabMcp { }) }) .collect(); + // Raw file text so the UI's TOML editor loads the actual file (comments + // and all); empty when there's no path or the file doesn't exist yet. + let text = match &self.bindings_path { + Some(p) => scp::read_bindings_text(std::path::Path::new(p)).unwrap_or_default(), + None => String::new(), + }; Ok(json!({ "path": self.bindings_path, "default_cluster": self.default_cluster, "fleets": fleets, + "text": text, })) } + /// The config file to edit, or an error when no config dir resolved (the + /// write tool needs a concrete path). + fn bindings_path(&self) -> Result { + self.bindings_path + .as_ref() + .map(std::path::PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("no fleet config path resolved; cannot write bindings")) + } + + /// Write tool: persist the whole `fleets.toml` from the editor's `text` + /// after validating it parses (a bad edit never lands on disk), then + /// hot-reload — swap in the reparsed bindings and drop the memoized + /// per-cluster configs (a changed profile/region invalidates them). Returns + /// the new `fleet_config`. + fn t_fleet_write(&self, args: &Map) -> Result { + let text = args + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: text"))?; + let path = self.bindings_path()?; + let next = scp::save_bindings_text(&path, text)?; + *self.bindings.write().unwrap() = next; + self.resolved.lock().unwrap().clear(); + self.t_fleet_config(args) + } + async fn t_delete(&self, args: &Map) -> Result { let cluster = self.cluster(args); let resource = args @@ -492,6 +550,7 @@ impl ServerHandler for OabMcp { "deploy_delete" => self.t_delete(args).await, "runtime_context" => self.t_runtime_context(args).await, "fleet_config" => self.t_fleet_config(args), + "fleet_config_write" => self.t_fleet_write(args), other => { return Err(McpError::invalid_params( format!("unknown tool {other:?}"), @@ -528,7 +587,7 @@ async fn main() -> anyhow::Result<()> { let server = OabMcp { aws, default_cluster, - bindings: Arc::new(bindings), + bindings: Arc::new(RwLock::new(bindings)), bindings_path: bindings_path.map(|p| p.display().to_string()), resolved: Arc::new(Mutex::new(HashMap::new())), }; @@ -550,7 +609,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 9); + assert_eq!(names.len(), 10); for expected in [ "deploy_list", "deploy_get", @@ -561,6 +620,7 @@ mod tests { "deploy_delete", "runtime_context", "fleet_config", + "fleet_config_write", ] { assert!(names.contains(&expected.to_string()), "missing {expected}"); } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 8d69160..658ab21 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -354,6 +354,53 @@ fn role_identity(arn: &str) -> Option<(String, String)> { Some((account, name)) } +// ---- Fleet-binding editing (raw-text, whole-file) ------------------------ +// +// The write half of the config panel: the operator edits `fleets.toml` as text +// (a TOML editor in the UI), and we persist their exact bytes after validating +// they parse — so comments and layout are preserved trivially (verbatim write), +// with no format-preserving library. Still operator credential *selection*, not +// per-caller authz (ADR-2 authz stays deferred). + +/// The raw text of the bindings file, or an empty string when it doesn't exist +/// yet (so the editor opens a blank buffer rather than erroring). +pub fn read_bindings_text(path: &std::path::Path) -> anyhow::Result { + match std::fs::read_to_string(path) { + Ok(t) => Ok(t), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(e.into()), + } +} + +/// Write `text` to `path` atomically: create the parent dir if needed, write a +/// sibling temp file, then rename over the target so a crash can't leave a +/// half-written config. +pub fn write_bindings_atomic(path: &std::path::Path, text: &str) -> anyhow::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("fleets.toml"); + let tmp = path.with_file_name(format!("{name}.tmp")); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Validate `text` parses as a bindings file and, if so, persist it verbatim, +/// returning the parsed set for the caller to hot-reload. A parse error is +/// returned **before** anything is written, so a bad edit never lands on disk. +pub fn save_bindings_text( + path: &std::path::Path, + text: &str, +) -> anyhow::Result { + let parsed: FleetBindings = toml::from_str(text)?; + write_bindings_atomic(path, text)?; + Ok(parsed) +} + // ---- Write side (ADR-2 write model) ------------------------------------ // // The read side above observes; these mutate. Each is a thin passthrough to @@ -515,6 +562,40 @@ profile = "appier-sg" assert!(b.for_cluster("nope").is_none()); } + #[test] + fn save_bindings_round_trips_and_preserves_text_verbatim() { + let dir = std::env::temp_dir().join(format!("oab-fleets-save-{}", std::process::id())); + let path = dir.join("fleets.toml"); + let _ = std::fs::remove_dir_all(&dir); + let text = "# my fleets\n\n[[fleet]]\nname = \"prod\"\ncluster = \"oab\"\nprofile = \"orca-prod\"\n"; + let parsed = save_bindings_text(&path, text).expect("save"); + assert_eq!(parsed.fleets.len(), 1); + assert_eq!(parsed.for_cluster("oab").unwrap().profile.as_deref(), Some("orca-prod")); + // written verbatim — comment and layout preserved exactly + assert_eq!(read_bindings_text(&path).unwrap(), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_bindings_rejects_invalid_toml_without_writing() { + let dir = std::env::temp_dir().join(format!("oab-fleets-bad-{}", std::process::id())); + let path = dir.join("fleets.toml"); + let _ = std::fs::remove_dir_all(&dir); + // not-a-table for `fleet` — must fail to parse as FleetBindings + let bad = "fleet = \"nope\"\n"; + assert!(save_bindings_text(&path, bad).is_err()); + // nothing was written + assert!(!path.exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn read_bindings_text_missing_file_is_empty() { + let path = std::env::temp_dir().join("oab-fleets-does-not-exist-xyz.toml"); + let _ = std::fs::remove_file(&path); + assert_eq!(read_bindings_text(&path).unwrap(), ""); + } + #[test] fn identity_mismatch_flags_static_user_and_wrong_account() { let expected_role = "arn:aws:iam::504190915686:role/openab-orca-task-role"; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 726c5bf..9a832e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -145,6 +145,30 @@ async fn fleet_config(core: tauri::State<'_, Core>) -> Result { } } +/// Bridge command: persist the edited `fleets.toml` text (ADR #19 slice C) via +/// the sidecar's `fleet_config_write` tool, which validates + writes + hot-reloads +/// and returns the reloaded config. A parse error surfaces to the editor. +#[tauri::command] +async fn fleet_config_write(core: tauri::State<'_, Core>, text: String) -> Result { + let client = { + let guard = core.0.lock().await; + guard + .as_ref() + .cloned() + .ok_or_else(|| "core not started yet".to_string())? + }; + match client + .call_tool("fleet_config_write", json!({ "text": text })) + .await + { + Ok(v) => Ok(v), + Err(e) => { + client.log("error", &format!("fleet_config_write: {e}")); + Err(e) + } + } +} + /// What the frontend needs to render the "update available" state: the version /// on the release vs. what's running, plus the release notes. #[derive(serde::Serialize)] @@ -214,6 +238,7 @@ pub fn run() { deploy_list, runtime_context, fleet_config, + fleet_config_write, check_update, install_update ])