Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,43 @@
<input id="deploy-fleet-name" class="compose-input" type="text" placeholder="e.g. support-fleet" spellcheck="false" />
</label>
<label
>Region
<input id="deploy-region" class="compose-input" type="text" placeholder="e.g. ap-east-2" spellcheck="false" />
</label>
<label
>Credential profile
<input id="deploy-profile" class="compose-input" type="text" placeholder="e.g. oab-fleet" spellcheck="false" />
</label>
<label
>Principal (optional)
<input id="deploy-principal" class="compose-input" type="text" placeholder="arn:aws:iam::…" spellcheck="false" />
>Provider
<select id="deploy-provider" class="compose-select">
<option value="aws" selected>AWS (ECS)</option>
<option value="k8s">Kubernetes</option>
</select>
</label>
<div id="deploy-aws-fields">
<label
>Region
<input id="deploy-region" class="compose-input" type="text" placeholder="e.g. ap-east-2" spellcheck="false" />
</label>
<label
>Credential profile
<input id="deploy-profile" class="compose-input" type="text" placeholder="e.g. oab-fleet" spellcheck="false" />
</label>
<label
>Principal (optional)
<input id="deploy-principal" class="compose-input" type="text" placeholder="arn:aws:iam::…" spellcheck="false" />
</label>
</div>
<div id="deploy-k8s-fields" hidden>
<label
>Context
<select id="deploy-k8s-context" class="compose-select"></select>
</label>
<label
>Namespace
<input id="deploy-k8s-namespace" class="compose-input" type="text" list="deploy-k8s-namespace-options" placeholder="pick existing or type a new one" spellcheck="false" />
<datalist id="deploy-k8s-namespace-options"></datalist>
</label>
<label
>Service account (optional)
<select id="deploy-k8s-service-account" class="compose-select">
<option value="">— namespace default —</option>
</select>
</label>
</div>
<div class="compose-actions">
<button type="submit">Next: first instance &rarr;</button>
<span class="compose-status" id="deploy-identity-status"></span>
Expand Down
135 changes: 135 additions & 0 deletions console/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ function fillOptions(sel: HTMLSelectElement, names: string[], keepNoneFirst: boo
sel.innerHTML = opts.join("");
}

// list_k8s_contexts / list_namespaces response shapes (oab-mcp, studio#104) —
// kept minimal (just what this panel reads), not the tools' full contract.
interface K8sContextsResponse {
contexts: { name: string }[];
current_context: string | null;
}
interface K8sNamespacesResponse {
namespaces: string[];
}
interface K8sServiceAccountsResponse {
service_accounts: string[];
}

export type DeployMode = { kind: "new-fleet" } | { kind: "add-instance"; fleetName: string };

// What the panel reports back once a deploy + fleets.toml write both succeed —
Expand Down Expand Up @@ -75,9 +88,22 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
const cancelBtn = document.getElementById("deploy-cancel") as HTMLButtonElement | null;
const identityForm = document.getElementById("deploy-identity-form") as HTMLFormElement | null;
const nameInput = document.getElementById("deploy-fleet-name") as HTMLInputElement | null;
const providerSel = document.getElementById("deploy-provider") as HTMLSelectElement | null;
const awsFieldsEl = document.getElementById("deploy-aws-fields");
const regionInput = document.getElementById("deploy-region") as HTMLInputElement | null;
const profileInput = document.getElementById("deploy-profile") as HTMLInputElement | null;
const principalInput = document.getElementById("deploy-principal") as HTMLInputElement | null;
const k8sFieldsEl = document.getElementById("deploy-k8s-fields");
const k8sContextSel = document.getElementById("deploy-k8s-context") as HTMLSelectElement | null;
// Namespace is a text input with a <datalist> of what already exists (not a
// <select>) — per studio#104's design, a brand-new namespace is a valid
// choice and a plain select can't express "not in this list yet".
const k8sNamespaceInput = document.getElementById("deploy-k8s-namespace") as HTMLInputElement | null;
const k8sNamespaceOptions = document.getElementById("deploy-k8s-namespace-options") as HTMLDataListElement | null;
// Service account, unlike namespace, must already exist for k8s to accept
// it as a pod's serviceAccountName — so (unlike namespace) a plain <select>
// is the right shape here, no free-text escape hatch needed.
const k8sServiceAccountSel = document.getElementById("deploy-k8s-service-account") as HTMLSelectElement | null;
const identityStatusEl = document.getElementById("deploy-identity-status");
const composeSection = document.getElementById("deploy-compose");
const composeHeading = document.getElementById("deploy-compose-heading");
Expand All @@ -97,9 +123,16 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
!cancelBtn ||
!identityForm ||
!nameInput ||
!providerSel ||
!awsFieldsEl ||
!regionInput ||
!profileInput ||
!principalInput ||
!k8sFieldsEl ||
!k8sContextSel ||
!k8sNamespaceInput ||
!k8sNamespaceOptions ||
!k8sServiceAccountSel ||
!composeSection ||
!tmplSel ||
!ovlSel ||
Expand Down Expand Up @@ -130,8 +163,98 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
setStatus(identityStatusEl, "");
setStatus(previewStatusEl, "");
setStatus(deployStatusEl, "");
// identityForm.reset() puts <select id="deploy-provider"> back to its
// `selected` default ("aws"), but doesn't touch the field-group `hidden`
// attributes this panel manages by hand — sync those too.
showProviderFields(providerSel.value);
};

// Toggle the AWS/k8s field groups per studio#104's design: switching
// providers resets which group is visible; field *values* aren't cleared
// here (identityForm.reset() already did that on open/close) since the
// two groups have no overlapping semantics to accidentally carry over.
const showProviderFields = (provider: string): void => {
awsFieldsEl.hidden = provider !== "aws";
k8sFieldsEl.hidden = provider !== "k8s";
};

const loadK8sNamespaces = async (): Promise<void> => {
const invoke = tauriInvoke();
if (!invoke) return;
const context = k8sContextSel.value || undefined;
try {
const res = await invoke<K8sNamespacesResponse>(
"list_namespaces",
context ? { context } : {},
);
k8sNamespaceOptions.innerHTML = res.namespaces
.map((n) => `<option value="${escapeHtml(n)}"></option>`)
.join("");
} catch (e) {
// Non-fatal: the namespace field is a free-text input either way (list_
// namespaces failing just means no autocomplete suggestions).
setStatus(identityStatusEl, `namespace list unavailable: ${errText(e)}`, "err");
}
};

// Service account is scoped to (context, namespace) and per #104's design
// fails *silently* — unlike context/namespace, any error here (including an
// RBAC-denied list, which is common against a scoped-down cluster identity)
// means "leave it unset" (the namespace's default service account applies),
// not something worth surfacing a status message for.
const loadK8sServiceAccounts = async (): Promise<void> => {
const defaultOption = '<option value="">— namespace default —</option>';
const invoke = tauriInvoke();
const namespace = k8sNamespaceInput.value.trim();
if (!invoke || !namespace) {
k8sServiceAccountSel.innerHTML = defaultOption;
return;
}
const context = k8sContextSel.value || undefined;
try {
const res = await invoke<K8sServiceAccountsResponse>(
"list_service_accounts",
context ? { context, namespace } : { namespace },
);
k8sServiceAccountSel.innerHTML =
defaultOption +
res.service_accounts
.map((sa) => `<option value="${escapeHtml(sa)}">${escapeHtml(sa)}</option>`)
.join("");
} catch {
k8sServiceAccountSel.innerHTML = defaultOption;
}
};

const loadK8sContexts = async (): Promise<void> => {
const invoke = tauriInvoke();
if (!invoke) return;
try {
const res = await invoke<K8sContextsResponse>("list_k8s_contexts");
const opts = ['<option value="">— kubeconfig current-context —</option>'];
for (const c of res.contexts) {
const label = c.name === res.current_context ? `${c.name} (current)` : c.name;
opts.push(`<option value="${escapeHtml(c.name)}">${escapeHtml(label)}</option>`);
}
k8sContextSel.innerHTML = opts.join("");
} catch (e) {
setStatus(identityStatusEl, `k8s context list unavailable: ${errText(e)}`, "err");
}
void loadK8sNamespaces();
};

providerSel.addEventListener("change", () => {
showProviderFields(providerSel.value);
if (providerSel.value === "k8s") void loadK8sContexts();
});
k8sContextSel.addEventListener("change", () => {
void loadK8sNamespaces();
void loadK8sServiceAccounts();
});
// "change" (fires on commit/blur), not "input" (every keystroke) — avoids a
// tool call per character typed into the namespace field.
k8sNamespaceInput.addEventListener("change", () => void loadK8sServiceAccounts());

const loadLibraryAndPickers = async (): Promise<void> => {
const invoke = tauriInvoke();
if (!invoke) {
Expand Down Expand Up @@ -184,6 +307,18 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
setStatus(identityStatusEl, "fleet name is required", "err");
return;
}
// k8s provisioning isn't wired end-to-end yet (deploy_provision has no
// k8s dispatch — see openabdev/studio#104's discussion of why this isn't
// just "swap the driver"). Block here rather than let the wizard proceed
// into a Compose step that would fail at the final deploy_provision call.
if (providerSel.value === "k8s") {
setStatus(
identityStatusEl,
"Kubernetes provisioning isn't available yet — tracked in openabdev/studio#104",
"err",
);
return;
}
identityForm.hidden = true;
composeSection.hidden = false;
if (composeHeading) composeHeading.textContent = "Step 2 — first instance";
Expand Down
5 changes: 4 additions & 1 deletion console/src/fleetToml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
// regex-based (not a full TOML parser) so it's unit-testable and only ever
// touches the one array/block it means to.

function quote(s: string): string {
// Exported so fleetsK8sToml.ts (fleets-k8s.toml's client-side edits, same
// `[fleet.<name>]` shape) can reuse it instead of duplicating a one-line
// helper.
export function quote(s: string): string {
return JSON.stringify(s);
}

Expand Down
57 changes: 57 additions & 0 deletions console/src/fleetsK8sToml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { appendMember, appendK8sFleetBlock } from "./fleetsK8sToml";

describe("appendMember (reused from fleetToml.ts)", () => {
it("works against fleets-k8s.toml's [fleet.<name>] shape too", () => {
const text = `[fleet.orbstack-dev]
context = "orbstack"
namespace = "dev"
members = ["scratch-agent"]
`;
const out = appendMember(text, "orbstack-dev", "scratch-agent-2");
expect(out).toContain('members = ["scratch-agent", "scratch-agent-2"]');
expect(out).toContain('context = "orbstack"');
expect(out).toContain('namespace = "dev"');
});
});

describe("appendK8sFleetBlock", () => {
it("appends a new [fleet.<name>] block with context, namespace, members, expected_principal", () => {
const out = appendK8sFleetBlock('default_cluster = "oab"\n', {
name: "orbstack-dev",
member: "oab-dev-scratch-agent",
context: "orbstack",
namespace: "dev",
expectedPrincipal: "system:serviceaccount:dev:oab-agent",
});
expect(out).toContain("[fleet.orbstack-dev]");
expect(out).toContain('context = "orbstack"');
expect(out).toContain('namespace = "dev"');
expect(out).toContain('members = ["oab-dev-scratch-agent"]');
expect(out).toContain('expected_principal = "system:serviceaccount:dev:oab-agent"');
});

it("omits context and expected_principal when not provided, but always writes namespace", () => {
const out = appendK8sFleetBlock("", {
name: "orca-k8s",
member: "oab-prod-orca",
context: null,
namespace: "prod",
expectedPrincipal: null,
});
expect(out).not.toContain("context =");
expect(out).not.toContain("expected_principal =");
expect(out).toContain('namespace = "prod"');
});

it("separates the new block from existing content with exactly one blank line", () => {
const out = appendK8sFleetBlock('default_cluster = "oab"\n', {
name: "x",
member: "m",
context: null,
namespace: "ns",
expectedPrincipal: null,
});
expect(out).toBe('default_cluster = "oab"\n\n[fleet.x]\nnamespace = "ns"\nmembers = ["m"]\n');
});
});
39 changes: 39 additions & 0 deletions console/src/fleetsK8sToml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Pure text-level edits to fleets-k8s.toml's `[fleet.<name>]` blocks — the
// k8s counterpart to fleetToml.ts, same rationale (studio#104: k8s_fleet_
// config_write has no partial/append primitive, so the client computes the
// new/edited TOML and calls it with the full updated text).
//
// `[fleet.<name>]` block lookup/append-member is identical between fleets.
// toml and fleets-k8s.toml (same table shape, same `members = [...]` array —
// neither `findFleetBlock` nor `appendMember` reference any AWS-specific
// field), so this module reuses fleetToml.ts's `appendMember` rather than
// duplicating it. Only "create a brand-new fleet block" differs, since the
// two files' required/optional fields differ (context+namespace vs
// region+profile).

import { quote, appendMember } from "./fleetToml";

export { appendMember };

export interface NewK8sFleetEntry {
name: string;
member: string;
context: string | null;
namespace: string;
expectedPrincipal: string | null;
}

// Append a brand-new `[fleet.<name>]` block to the end of the file, with the
// one member — the first instance just deployed. `context` and
// `expected_principal` are optional fields, omitted rather than written as
// empty strings (mirrors fleetToml.ts's appendFleetBlock).
export function appendK8sFleetBlock(text: string, entry: NewK8sFleetEntry): string {
const lines = [`[fleet.${entry.name}]`];
if (entry.context) lines.push(`context = ${quote(entry.context)}`);
lines.push(`namespace = ${quote(entry.namespace)}`);
lines.push(`members = [${quote(entry.member)}]`);
if (entry.expectedPrincipal) lines.push(`expected_principal = ${quote(entry.expectedPrincipal)}`);
const block = `${lines.join("\n")}\n`;
const trimmed = text.replace(/\s*$/, "");
return trimmed.length ? `${trimmed}\n\n${block}` : block;
}
Loading
Loading