From b6330e9d9553b9487a4e34ce0e82f58455cbfd55 Mon Sep 17 00:00:00 2001 From: Aaron Brewbaker Date: Wed, 1 Jul 2026 13:58:36 -0400 Subject: [PATCH] feat: port opal, fleet, schema, worksheet, and dataset commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the remaining Go-CLI parity commands (hand-authored GraphQL; codegen unavailable on this tenant): opal check / verbs / functions / validate-ingest fleet status / host / versions / auth (observe-agent via REST export) schema introspect (requires introspection enabled) worksheet list / get / create / delete dataset dry-run / impact (wired; unverified on this tenant — schema drifted) Adds shared parseDurationMs to lib/parsers (fleet --window). --- AGENTS.md | 12 ++- README.md | 24 ++++- src/app.ts | 8 ++ src/commands/dataset/dry-run.ts | 95 +++++++++++++++++ src/commands/dataset/impact.ts | 115 ++++++++++++++++++++ src/commands/dataset/index.ts | 14 ++- src/commands/fleet/fleet.test.ts | 28 +++++ src/commands/fleet/fleet.ts | 142 +++++++++++++++++++++++++ src/commands/fleet/index.ts | 31 ++++++ src/commands/opal/check.ts | 114 ++++++++++++++++++++ src/commands/opal/functions.ts | 39 +++++++ src/commands/opal/index.ts | 26 +++++ src/commands/opal/validate-ingest.ts | 74 +++++++++++++ src/commands/opal/verbs.ts | 36 +++++++ src/commands/schema/index.ts | 19 ++++ src/commands/schema/introspect.ts | 63 +++++++++++ src/commands/worksheet/create.ts | 63 +++++++++++ src/commands/worksheet/delete.ts | 50 +++++++++ src/commands/worksheet/get.ts | 46 ++++++++ src/commands/worksheet/index.ts | 26 +++++ src/commands/worksheet/list.ts | 125 ++++++++++++++++++++++ src/gql/dataset/dataset-analysis.ts | 92 ++++++++++++++++ src/gql/opal/check-queries.ts | 24 +++++ src/gql/opal/validate-ingest-filter.ts | 23 ++++ src/gql/opal/verbs-and-functions.ts | 18 ++++ src/gql/schema/introspect.ts | 77 ++++++++++++++ src/gql/worksheet/worksheet.ts | 138 ++++++++++++++++++++++++ src/lib/parsers.ts | 24 +++++ src/rest/export/run-opal-query.ts | 63 +++++++++++ 29 files changed, 1605 insertions(+), 4 deletions(-) create mode 100644 src/commands/dataset/dry-run.ts create mode 100644 src/commands/dataset/impact.ts create mode 100644 src/commands/fleet/fleet.test.ts create mode 100644 src/commands/fleet/fleet.ts create mode 100644 src/commands/fleet/index.ts create mode 100644 src/commands/opal/check.ts create mode 100644 src/commands/opal/functions.ts create mode 100644 src/commands/opal/index.ts create mode 100644 src/commands/opal/validate-ingest.ts create mode 100644 src/commands/opal/verbs.ts create mode 100644 src/commands/schema/index.ts create mode 100644 src/commands/schema/introspect.ts create mode 100644 src/commands/worksheet/create.ts create mode 100644 src/commands/worksheet/delete.ts create mode 100644 src/commands/worksheet/get.ts create mode 100644 src/commands/worksheet/index.ts create mode 100644 src/commands/worksheet/list.ts create mode 100644 src/gql/dataset/dataset-analysis.ts create mode 100644 src/gql/opal/check-queries.ts create mode 100644 src/gql/opal/validate-ingest-filter.ts create mode 100644 src/gql/opal/verbs-and-functions.ts create mode 100644 src/gql/schema/introspect.ts create mode 100644 src/gql/worksheet/worksheet.ts create mode 100644 src/rest/export/run-opal-query.ts diff --git a/AGENTS.md b/AGENTS.md index 1dbe33a..48238a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,12 +37,16 @@ src/ │ ├── auth/ # Auth commands (configure, login, logout, status) │ ├── board/ # Board (dashboard) commands (create, update, get, list, delete, scaffold, set-default, clear-default) │ ├── folder/ # Folder commands (create, get, update, delete) +│ ├── opal/ # OPAL commands (check, verbs, functions, validate-ingest) +│ ├── fleet/ # Fleet commands (status, host, versions, auth) — observe-agent status via REST export query +│ ├── schema/ # Schema commands (introspect) — requires GraphQL introspection enabled +│ ├── worksheet/ # Worksheet commands (list, get, create, delete) │ ├── cli/ # CLI management (install, uninstall, upgrade) │ ├── content/ # Content pack management (experimental: gated + hidden) │ │ ├── host/ # Host Explorer (install, view) │ │ ├── kubernetes/ # Kubernetes Explorer (install, view) │ │ └── tracing/ # Trace Explorer (install, view) -│ ├── dataset/ # Dataset commands (list, view) +│ ├── dataset/ # Dataset commands (list, view, dry-run, impact; dry-run/impact unverified — schema drifted) │ ├── datastream/ # Datastream commands (create, list, view, update) │ ├── ingest-token/ # Ingest token commands (experimental: gated + hidden) │ ├── metric/ # Metric commands (list, view) @@ -56,8 +60,11 @@ src/ │ ├── authtoken/ # Auth token mutations │ ├── board/ # Board (dashboard) queries/mutations (hand-authored; codegen unavailable) │ ├── folder/ # Folder queries/mutations (hand-authored; codegen unavailable) +│ ├── opal/ # OPAL queries (check-queries, verbs-and-functions, validate-ingest-filter) — uses generated snapshot ops +│ ├── schema/ # GraphQL introspection query (hand-authored) +│ ├── worksheet/ # Worksheet queries/mutations (hand-authored; codegen unavailable) │ ├── content/ # Content pack queries/mutations -│ ├── dataset/ # Dataset queries +│ ├── dataset/ # Dataset queries (incl. dataset-analysis: dry-run/impact, hand-authored, unverified) │ ├── datastream/ # Datastream queries/mutations │ ├── ingest-token/ # Ingest token queries/mutations │ ├── metric/ # Metric queries @@ -67,6 +74,7 @@ src/ ├── rest/ # REST API layer │ ├── generated/ # Auto-generated client (DO NOT EDIT) │ ├── client.ts # REST client factory +│ ├── export/ # OPAL export query helper (runOpalQueryCsv) used by fleet │ └── config.yaml # OpenAPI generator config └── lib/ # Shared utilities ├── auth/ # Auth flows (browser login, device code, server discovery) diff --git a/README.md b/README.md index dfa38f9..3158f3a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,14 @@ To update installed skills after edits in this repo, run `npx skills update`. > target tenant has GraphQL introspection disabled. They are commented out in > `src/app.ts` (search for "TEMP: trimmed pending full Observe schema access") > and will be restored once `bun codegen:gql` can run. The currently active -> commands are: `help`, `dataset`, `alert`, `board`, `folder`, `cli`. +> commands are: `help`, `dataset`, `alert`, `board`, `folder`, `opal`, `fleet`, +> `schema`, `worksheet`, `cli`. +> +> `schema introspect` additionally requires the tenant to have GraphQL +> introspection enabled. `dataset dry-run` and `dataset impact` are wired but +> unverified on this tenant: their underlying operations drifted +> (`saveDatasetDryRun` removed; `getDatasetsAffectedByDatasetUpdate` result +> fields renamed) and the new shapes cannot be re-derived without introspection. | Command | Description | | --------------------------------------- | ------------------------------------------------------- | @@ -50,6 +57,8 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe tag-key list` | Search tag keys in the knowledge graph | | `observe dataset list` | List datasets with optional filtering | | `observe dataset view` | View dataset details and schema | +| `observe dataset dry-run` | Dry-run a dataset pipeline change (unverified on tenant) | +| `observe dataset impact` | Report datasets affected by a change (unverified) | | `observe metric list` | Search and list metrics | | `observe metric view` | View metric details and dimensions | | `observe query` | Execute OPAL queries on datasets | @@ -74,6 +83,19 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe folder get` | Look up a folder by name and print its ID | | `observe folder update` | Update a folder's name, description, or icon URL | | `observe folder delete` | Delete a folder by ID | +| `observe opal check` | Validate an OPAL pipeline and print its result schema | +| `observe opal verbs` | List all OPAL verbs | +| `observe opal functions` | List all OPAL functions | +| `observe opal validate-ingest` | Validate an OPAL ingest filter against a dataset | +| `observe fleet status` | Current status of all observe-agent instances | +| `observe fleet host` | observe-agent details for a single host | +| `observe fleet versions` | observe-agent versions across the fleet | +| `observe fleet auth` | observe-agent auth-check status across the fleet | +| `observe schema introspect` | Dump the GraphQL schema as JSON (introspection req'd) | +| `observe worksheet list` | List worksheets in a workspace | +| `observe worksheet get` | Get a worksheet by ID as JSON | +| `observe worksheet create` | Create a worksheet from a JSON file | +| `observe worksheet delete` | Delete a worksheet by ID | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration | | `observe cli upgrade` | Upgrade to the latest version | diff --git a/src/app.ts b/src/app.ts index 10d46bc..1fb7ef6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,10 @@ import { helpCommand } from "./commands/help.js"; // import { tagValueRoutes } from "./commands/tag-value/index.js"; import { boardRoutes } from "./commands/board/index.js"; import { folderRoutes } from "./commands/folder/index.js"; +import { opalRoutes } from "./commands/opal/index.js"; +import { fleetRoutes } from "./commands/fleet/index.js"; +import { schemaRoutes } from "./commands/schema/index.js"; +import { worksheetRoutes } from "./commands/worksheet/index.js"; import { CURRENT_CLI_VERSION } from "./lib/constants.js"; import { defineRoutes } from "./lib/stricli-wrappers.js"; @@ -53,6 +57,10 @@ export const routes = defineRoutes({ // "datastream-token": datastreamTokenRoutes, board: boardRoutes, folder: folderRoutes, + opal: opalRoutes, + fleet: fleetRoutes, + schema: schemaRoutes, + worksheet: worksheetRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/dataset/dry-run.ts b/src/commands/dataset/dry-run.ts new file mode 100644 index 0000000..72a4e2b --- /dev/null +++ b/src/commands/dataset/dry-run.ts @@ -0,0 +1,95 @@ +import { buildCommand } from "@stricli/core"; +import * as fs from "node:fs"; +import type { LocalContext } from "../../context"; +import { datasetDryRun } from "../../gql/dataset/dataset-analysis"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface DatasetCmdInput { + workspaceId: string; + dataset: Record; + query: Record; +} + +function readDatasetInput(file: string): DatasetCmdInput { + try { + return JSON.parse(fs.readFileSync(file, "utf-8")) as DatasetCmdInput; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`dataset: could not read or parse "${file}": ${message}`, { + cause: error, + }); + } +} + +async function dryRun( + this: LocalContext, + _flags: Record, + file: string, +): Promise { + const { process, writer } = this; + + try { + const input = readDatasetInput(file); + const config = loadConfig(); + const result = await datasetDryRun(config, { + workspaceId: input.workspaceId, + dataset: input.dataset, + query: input.query, + }); + + if (result.dataset) { + writer.write( + `Dataset: ${result.dataset.name ?? ""} (${result.dataset.id ?? ""})`, + ); + } + for (const ds of result.dematerializedDatasets ?? []) { + writer.write(`Would rematerialize: ${ds.name ?? ""} (${ds.id ?? ""})`); + } + + let hasErrors = false; + for (const errDs of result.errorDatasets ?? []) { + writer.write( + `Error in ${errDs.dataset?.name ?? ""}: ${errDs.errorText ?? ""}`, + ); + hasErrors = true; + } + + if (hasErrors) { + process.exit(1); + } + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const dryRunCommand = buildCommand({ + loader: async () => dryRun, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to a JSON file with { workspaceId, dataset, query }", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Dry-run a dataset pipeline change and report rematerialization", + fullDescription: + "Dry-run a dataset pipeline change.\n\n" + + "NOTE: not verified against the current tenant — the saveDatasetDryRun " + + "mutation has drifted (see src/gql/dataset/dataset-analysis.ts). Works on " + + "tenants exposing the legacy schema; re-derive once introspection is " + + "available.", + }, +}); diff --git a/src/commands/dataset/impact.ts b/src/commands/dataset/impact.ts new file mode 100644 index 0000000..3561b73 --- /dev/null +++ b/src/commands/dataset/impact.ts @@ -0,0 +1,115 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import * as fs from "node:fs"; +import type { LocalContext } from "../../context"; +import { datasetImpact } from "../../gql/dataset/dataset-analysis"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../lib/formatters/table"; + +interface DatasetCmdInput { + workspaceId: string; + dataset: Record; + query: Record; +} + +interface AffectedRow { + name: string; + id: string; + dependencyType: string; +} + +function readDatasetInput(file: string): DatasetCmdInput { + try { + return JSON.parse(fs.readFileSync(file, "utf-8")) as DatasetCmdInput; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`dataset: could not read or parse "${file}": ${message}`, { + cause: error, + }); + } +} + +const col = createColumnHelper(); + +const columns: ColumnDef[] = [ + col.accessor((row) => row.name, { header: "NAME" }), + col.accessor((row) => row.id, { + header: "ID", + format: (value) => chalk.cyan(value), + }), + col.accessor((row) => row.dependencyType, { header: "DEPENDENCY" }), +]; + +async function impact( + this: LocalContext, + _flags: Record, + file: string, +): Promise { + const { process, writer } = this; + + try { + const input = readDatasetInput(file); + const config = loadConfig(); + const result = await datasetImpact(config, { + workspaceId: input.workspaceId, + dataset: input.dataset, + query: input.query, + }); + + const rows: AffectedRow[] = (result.affectedDatasets ?? []).map((a) => ({ + name: a.dataset?.name ?? "", + id: a.dataset?.id ?? "", + dependencyType: a.dependencyType ?? "", + })); + + if (rows.length > 0) { + writer.write(formatTable(rows, columns)); + } else { + writer.warn("No affected datasets."); + } + + for (const errDs of result.errorDatasets ?? []) { + writer.error( + `Error in ${errDs.dataset?.name ?? ""}: ${errDs.errorText ?? ""}`, + ); + } + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const impactCommand = buildCommand({ + loader: async () => impact, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to a JSON file with { workspaceId, dataset, query }", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Report datasets affected by a dataset pipeline change", + fullDescription: + "Report downstream datasets affected by a dataset change.\n\n" + + "NOTE: not verified against the current tenant — the " + + "getDatasetsAffectedByDatasetUpdate result schema has drifted (see " + + "src/gql/dataset/dataset-analysis.ts). Works on tenants exposing the " + + "legacy schema; re-derive once introspection is available.", + }, +}); diff --git a/src/commands/dataset/index.ts b/src/commands/dataset/index.ts index 0f2785e..3903773 100644 --- a/src/commands/dataset/index.ts +++ b/src/commands/dataset/index.ts @@ -1,14 +1,26 @@ import { defineRoutes } from "../../lib/stricli-wrappers"; import { listCommand } from "./list"; import { viewCommand } from "./view"; +import { dryRunCommand } from "./dry-run"; +import { impactCommand } from "./impact"; export const datasetRoutes = defineRoutes({ routes: { list: listCommand, view: viewCommand, + "dry-run": dryRunCommand, + impact: impactCommand, }, docs: { brief: "View observe datasets", - fullDescription: "View and manage datasets in Observe.", + fullDescription: [ + "View and manage datasets in Observe", + "", + "Commands:", + " list List datasets in Observe", + " view View details of a dataset", + " dry-run Dry-run a dataset pipeline change (unverified on this tenant)", + " impact Report datasets affected by a change (unverified on this tenant)", + ].join("\n"), }, }); diff --git a/src/commands/fleet/fleet.test.ts b/src/commands/fleet/fleet.test.ts new file mode 100644 index 0000000..095f468 --- /dev/null +++ b/src/commands/fleet/fleet.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { parseWindow } from "./fleet"; + +// parseWindow is re-exported from lib/parsers (parseDurationMs); these tests +// guard the fleet --window contract specifically. +describe("fleet parseWindow", () => { + test("parses minutes", () => { + expect(parseWindow("20m")).toBe(20 * 60 * 1000); + }); + + test("parses hours", () => { + expect(parseWindow("168h")).toBe(168 * 60 * 60 * 1000); + }); + + test("parses seconds and milliseconds", () => { + expect(parseWindow("90s")).toBe(90 * 1000); + expect(parseWindow("500ms")).toBe(500); + }); + + test("tolerates surrounding whitespace", () => { + expect(parseWindow(" 24h ")).toBe(24 * 60 * 60 * 1000); + }); + + test("throws on an invalid duration", () => { + expect(() => parseWindow("20")).toThrow(/Invalid duration/); + expect(() => parseWindow("abc")).toThrow(/Invalid duration/); + }); +}); diff --git a/src/commands/fleet/fleet.ts b/src/commands/fleet/fleet.ts new file mode 100644 index 0000000..de2a3cb --- /dev/null +++ b/src/commands/fleet/fleet.ts @@ -0,0 +1,142 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { parseDurationMs } from "../../lib/parsers"; +import { runOpalQueryCsv } from "../../rest/export/run-opal-query"; + +// The dataset and OPAL pipelines below are ported verbatim from the Go fork +// (cmd_fleet.go). They read observe-agent AgentLifecycleEvent rows from the +// fleet events dataset. +const FLEET_DATASET = "Default.Observe Agent/Events"; + +const OPAL_FLEET_STATUS = + 'filter kind = "AgentLifecycleEvent" | make_col host:string(identifiers["host.name"]), env:string(identifiers["observe.agent.environment"]), version:string(facets["observe.agent.version"]), instance_id:string(identifiers["observe.agent.instance.id"]), data_obj:parse_json(data) | make_col auth_ok:bool(data_obj.authCheck.passed) | pick_col valid_from, host, env, version, auth_ok, instance_id | sort desc(valid_from)'; + +const OPAL_FLEET_VERSIONS = + 'filter kind = "AgentLifecycleEvent" | make_col host:string(identifiers["host.name"]), env:string(identifiers["observe.agent.environment"]), version:string(facets["observe.agent.version"]) | pick_col valid_from, host, env, version | sort asc(version), asc(host)'; + +const OPAL_FLEET_AUTH = + 'filter kind = "AgentLifecycleEvent" | make_col host:string(identifiers["host.name"]), env:string(identifiers["observe.agent.environment"]), version:string(facets["observe.agent.version"]), data_obj:parse_json(data) | make_col auth_ok:bool(data_obj.authCheck.passed), auth_code:int64(data_obj.authCheck.responseCode), auth_url:string(data_obj.authCheck.url) | pick_col valid_from, host, env, version, auth_ok, auth_code, auth_url | sort asc(auth_ok), desc(valid_from)'; + +function opalFleetHost(hostname: string): string { + // Embed the hostname as a quoted OPAL string literal. + const quoted = JSON.stringify(hostname); + return `filter kind = "AgentLifecycleEvent" | filter string(identifiers["host.name"]) = ${quoted} | make_col host:string(identifiers["host.name"]), env:string(identifiers["observe.agent.environment"]), version:string(facets["observe.agent.version"]), data_obj:parse_json(data) | make_col auth_ok:bool(data_obj.authCheck.passed), start_time:from_nanoseconds(int64(data_obj.agentStartTime)*1000000000) | pick_col valid_from, host, env, version, auth_ok, start_time | sort desc(valid_from)`; +} + +const DEFAULT_WINDOW_MS = 20 * 60 * 1000; + +// The --window flag accepts a Go-style duration; parsing lives in lib/parsers. +// Re-exported as parseWindow for tests. +export const parseWindow = parseDurationMs; + +interface FleetFlags { + window: number; +} + +// Compute the [from, to) window the same way the Go fork does: anchor `to` 15s +// before now, truncated to the minute, and `from` one window earlier. +function timeWindow(windowMs: number): { startTime: string; endTime: string } { + const nowMs = Math.floor(Date.now() / 1000) * 1000; + const toMs = Math.floor((nowMs - 15_000) / 60_000) * 60_000; + const fromMs = toMs - windowMs; + return { + startTime: new Date(fromMs).toISOString(), + endTime: new Date(toMs).toISOString(), + }; +} + +async function runFleet( + this: LocalContext, + flags: FleetFlags, + pipeline: string, +): Promise { + const { process, writer } = this; + try { + const config = loadConfig(); + const { startTime, endTime } = timeWindow( + flags.window || DEFAULT_WINDOW_MS, + ); + const csv = await runOpalQueryCsv(config, { + pipeline, + datasetPath: FLEET_DATASET, + startTime, + endTime, + }); + // The export endpoint already returns CSV; emit it as-is (trim a single + // trailing newline so the writer's own newline doesn't double it). + writer.write(csv.replace(/\n$/, "")); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +const windowFlag = { + window: { + kind: "parsed" as const, + parse: parseWindow, + brief: "Time window for the query (e.g. 20m, 24h, 168h)", + default: "20m", + }, +}; + +export const statusCommand = buildCommand({ + loader: async () => { + return async function (this: LocalContext, flags: FleetFlags) { + await runFleet.call(this, flags, OPAL_FLEET_STATUS); + }; + }, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: windowFlag, + }, + docs: { brief: "Show current status of all observe-agent instances" }, +}); + +export const versionsCommand = buildCommand({ + loader: async () => { + return async function (this: LocalContext, flags: FleetFlags) { + await runFleet.call(this, flags, OPAL_FLEET_VERSIONS); + }; + }, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: windowFlag, + }, + docs: { brief: "Show observe-agent versions across the fleet" }, +}); + +export const authCommand = buildCommand({ + loader: async () => { + return async function (this: LocalContext, flags: FleetFlags) { + await runFleet.call(this, flags, OPAL_FLEET_AUTH); + }; + }, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: windowFlag, + }, + docs: { brief: "Show observe-agent auth-check status across the fleet" }, +}); + +export const hostCommand = buildCommand({ + loader: async () => { + return async function ( + this: LocalContext, + flags: FleetFlags, + hostname: string, + ) { + await runFleet.call(this, flags, opalFleetHost(hostname)); + }; + }, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Agent host name", parse: String }], + }, + flags: windowFlag, + }, + docs: { brief: "Show observe-agent details for a single host" }, +}); diff --git a/src/commands/fleet/index.ts b/src/commands/fleet/index.ts new file mode 100644 index 0000000..448d202 --- /dev/null +++ b/src/commands/fleet/index.ts @@ -0,0 +1,31 @@ +import { buildRouteMap } from "@stricli/core"; +import { + authCommand, + hostCommand, + statusCommand, + versionsCommand, +} from "./fleet"; + +export const fleetRoutes = buildRouteMap({ + routes: { + status: statusCommand, + host: hostCommand, + versions: versionsCommand, + auth: authCommand, + }, + docs: { + brief: "Query observe-agent fleet status", + fullDescription: [ + "Query the status of observe-agent instances from the fleet events", + 'dataset ("Default.Observe Agent/Events").', + "", + "Commands:", + " status Current status of all agents", + " host Details for a single host", + " versions Agent versions across the fleet", + " auth Agent auth-check status across the fleet", + "", + "All commands accept --window (default 20m).", + ].join("\n"), + }, +}); diff --git a/src/commands/opal/check.ts b/src/commands/opal/check.ts new file mode 100644 index 0000000..9e0cc30 --- /dev/null +++ b/src/commands/opal/check.ts @@ -0,0 +1,114 @@ +import { buildCommand } from "@stricli/core"; +import * as fs from "node:fs"; +import type { LocalContext } from "../../context"; +import { checkQueries } from "../../gql/opal/check-queries"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface CheckFlags { + file?: string; +} + +async function check( + this: LocalContext, + flags: CheckFlags, + pipelineArg?: string, +): Promise { + const { process, writer } = this; + + try { + let pipeline: string; + if (flags.file) { + try { + pipeline = fs.readFileSync(flags.file, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `opal check: could not read file "${flags.file}": ${message}`, + { cause: error }, + ); + } + } else if (pipelineArg) { + pipeline = pipelineArg; + } else { + writer.error( + "Error: usage: observe opal check | observe opal check --file ", + ); + process.exit(1); + return; + } + + const config = loadConfig(); + const result = await checkQueries(config, { + queries: { + outputStage: "stage-1", + // StageQueryInput uses `id` (the deprecated `stageID` alias is not in + // the generated input type). The outputStage references this id. + stages: [{ id: "stage-1", pipeline, input: [] }], + }, + }); + + const parsed = result?.parsedPipeline; + + // Errors with empty text indicate "compilation requires an input dataset" + // and are not real syntax errors; skip them. (Matches the Go fork.) + const errors = (parsed?.errors ?? []).filter((e) => e.text !== ""); + const warnings = parsed?.warnings ?? []; + + if (errors.length > 0) { + for (const e of errors) { + writer.write(`ERROR ${e.row}:${e.col}: ${e.text}`); + } + writer.error("Error: opal check: pipeline has errors"); + process.exit(1); + return; + } + + for (const w of warnings) { + writer.write(`WARN ${w.kind ?? ""} ${w.symbol.row}:${w.symbol.col}`); + } + + writer.write("OK"); + for (const f of result?.resultSchema?.fieldList ?? []) { + writer.write(` ${f.name ?? ""}`); + } + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const checkCommand = buildCommand({ + loader: async () => check, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "OPAL pipeline text (or use --file)", + parse: String, + optional: true, + }, + ], + }, + flags: { + file: { + kind: "parsed", + parse: String, + brief: "Read the OPAL pipeline from a file instead of the argument", + optional: true, + }, + }, + aliases: { + f: "file", + }, + }, + docs: { + brief: "Validate an OPAL pipeline and print its result schema", + }, +}); diff --git a/src/commands/opal/functions.ts b/src/commands/opal/functions.ts new file mode 100644 index 0000000..e309e77 --- /dev/null +++ b/src/commands/opal/functions.ts @@ -0,0 +1,39 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { verbsAndFunctions } from "../../gql/opal/verbs-and-functions"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +async function functions(this: LocalContext): Promise { + const { process, writer } = this; + + try { + const config = loadConfig(); + const { functions: funcList } = await verbsAndFunctions(config); + + const sorted = [...funcList].sort((a, b) => a.name.localeCompare(b.name)); + for (const f of sorted) { + // Tab-separated: name, categories (comma-joined), returnType, description. + writer.write( + `${f.name}\t${f.categories.join(",")}\t${f.returnType}\t${f.description}`, + ); + } + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const functionsCommand = buildCommand({ + loader: async () => functions, + parameters: { positional: { kind: "tuple", parameters: [] }, flags: {} }, + docs: { + brief: + "List all OPAL functions (name, categories, return type, description)", + }, +}); diff --git a/src/commands/opal/index.ts b/src/commands/opal/index.ts new file mode 100644 index 0000000..8726ae2 --- /dev/null +++ b/src/commands/opal/index.ts @@ -0,0 +1,26 @@ +import { buildRouteMap } from "@stricli/core"; +import { checkCommand } from "./check"; +import { verbsCommand } from "./verbs"; +import { functionsCommand } from "./functions"; +import { validateIngestCommand } from "./validate-ingest"; + +export const opalRoutes = buildRouteMap({ + routes: { + check: checkCommand, + verbs: verbsCommand, + functions: functionsCommand, + "validate-ingest": validateIngestCommand, + }, + docs: { + brief: "Validate and inspect OPAL pipelines and functions", + fullDescription: [ + "Validate and inspect OPAL pipelines, verbs, and functions.", + "", + "Commands:", + " check Validate an OPAL pipeline and print its result schema", + " verbs List all OPAL verbs", + " functions List all OPAL functions", + " validate-ingest Validate an OPAL ingest filter against a dataset", + ].join("\n"), + }, +}); diff --git a/src/commands/opal/validate-ingest.ts b/src/commands/opal/validate-ingest.ts new file mode 100644 index 0000000..119dd40 --- /dev/null +++ b/src/commands/opal/validate-ingest.ts @@ -0,0 +1,74 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { validateIngestFilter } from "../../gql/opal/validate-ingest-filter"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface ValidateIngestFlags { + dataset: string; +} + +async function validateIngest( + this: LocalContext, + flags: ValidateIngestFlags, + pipeline: string, +): Promise { + const { process, writer } = this; + + try { + const config = loadConfig(); + const diags = await validateIngestFilter(config, { + pipeline, + sourceDatasetID: flags.dataset, + }); + + // A non-empty array means errors; an empty array means valid. + let hasErrors = false; + for (const d of diags) { + writer.write(`ERROR: ${d.message}`); + hasErrors = true; + } + + if (hasErrors) { + writer.error("Error: opal validate-ingest: pipeline has errors"); + process.exit(1); + return; + } + + writer.write("OK"); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const validateIngestCommand = buildCommand({ + loader: async () => validateIngest, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "OPAL ingest filter expression", + parse: String, + }, + ], + }, + flags: { + dataset: { + kind: "parsed", + parse: String, + brief: "Source dataset ID to validate the filter against", + optional: false, + }, + }, + }, + docs: { + brief: "Validate an OPAL ingest filter expression against a dataset", + }, +}); diff --git a/src/commands/opal/verbs.ts b/src/commands/opal/verbs.ts new file mode 100644 index 0000000..baa8eca --- /dev/null +++ b/src/commands/opal/verbs.ts @@ -0,0 +1,36 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { verbsAndFunctions } from "../../gql/opal/verbs-and-functions"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +async function verbs(this: LocalContext): Promise { + const { process, writer } = this; + + try { + const config = loadConfig(); + const { verbs: verbList } = await verbsAndFunctions(config); + + const sorted = [...verbList].sort((a, b) => a.name.localeCompare(b.name)); + for (const v of sorted) { + // Tab-separated: name, categories (comma-joined), description. + writer.write(`${v.name}\t${v.categories.join(",")}\t${v.description}`); + } + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const verbsCommand = buildCommand({ + loader: async () => verbs, + parameters: { positional: { kind: "tuple", parameters: [] }, flags: {} }, + docs: { + brief: "List all OPAL verbs (name, categories, description)", + }, +}); diff --git a/src/commands/schema/index.ts b/src/commands/schema/index.ts new file mode 100644 index 0000000..79c252b --- /dev/null +++ b/src/commands/schema/index.ts @@ -0,0 +1,19 @@ +import { buildRouteMap } from "@stricli/core"; +import { introspectCommand } from "./introspect"; + +export const schemaRoutes = buildRouteMap({ + routes: { + introspect: introspectCommand, + }, + docs: { + brief: "Inspect the Observe GraphQL API schema", + fullDescription: [ + "Inspect the Observe GraphQL API schema.", + "", + "Commands:", + " introspect Dump the full schema (or one --type) as JSON", + "", + "Note: requires GraphQL introspection to be enabled on the tenant.", + ].join("\n"), + }, +}); diff --git a/src/commands/schema/introspect.ts b/src/commands/schema/introspect.ts new file mode 100644 index 0000000..baa7560 --- /dev/null +++ b/src/commands/schema/introspect.ts @@ -0,0 +1,63 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { introspectSchema } from "../../gql/schema/introspect"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface IntrospectFlags { + type?: string; +} + +async function introspect( + this: LocalContext, + flags: IntrospectFlags, +): Promise { + const { process, writer } = this; + + try { + const config = loadConfig(); + const schema = await introspectSchema(config); + + if (flags.type) { + const match = schema.types.find((t) => t.name === flags.type); + if (!match) { + writer.error( + `Error: schema introspect: type "${flags.type}" not found`, + ); + process.exit(1); + return; + } + writer.write(JSON.stringify(match, null, 2)); + return; + } + + writer.write(JSON.stringify(schema, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const introspectCommand = buildCommand({ + loader: async () => introspect, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: { + type: { + kind: "parsed", + parse: String, + brief: "Filter output to a single type by name", + optional: true, + }, + }, + }, + docs: { + brief: + "Introspect the Observe GraphQL schema (requires introspection enabled)", + }, +}); diff --git a/src/commands/worksheet/create.ts b/src/commands/worksheet/create.ts new file mode 100644 index 0000000..cf0b76c --- /dev/null +++ b/src/commands/worksheet/create.ts @@ -0,0 +1,63 @@ +import { buildCommand } from "@stricli/core"; +import * as fs from "node:fs"; +import type { LocalContext } from "../../context"; +import { saveWorksheet } from "../../gql/worksheet/worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +// Fields returned by the API that saveWorksheet does not accept as input. +const READ_ONLY_WORKSHEET_FIELDS = ["updatedDate"] as const; + +async function create( + this: LocalContext, + _flags: Record, + file: string, +): Promise { + const { process, writer } = this; + + try { + let input: Record; + try { + input = JSON.parse(fs.readFileSync(file, "utf-8")) as Record< + string, + unknown + >; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `worksheet: could not read or parse "${file}": ${message}`, + { cause: error }, + ); + } + + for (const field of READ_ONLY_WORKSHEET_FIELDS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete input[field]; + } + + const config = loadConfig(); + const worksheet = await saveWorksheet(config, input); + + writer.write(`Created: ${worksheet.name} (id: ${worksheet.id})`); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const createCommand = buildCommand({ + loader: async () => create, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Path to a worksheet JSON file", parse: String }], + }, + flags: {}, + }, + docs: { brief: "Create a worksheet from a JSON file" }, +}); diff --git a/src/commands/worksheet/delete.ts b/src/commands/worksheet/delete.ts new file mode 100644 index 0000000..e485786 --- /dev/null +++ b/src/commands/worksheet/delete.ts @@ -0,0 +1,50 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { deleteWorksheet } from "../../gql/worksheet/worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +async function del( + this: LocalContext, + _flags: Record, + id: string, +): Promise { + const { process, writer } = this; + + try { + const config = loadConfig(); + const result = await deleteWorksheet(config, id); + + // Only treat as failure when `success` is explicitly false; the API may + // populate `errorMessage` with a success notice. + if (result.success === false) { + writer.error( + `Error: worksheet delete: ${result.errorMessage ?? "failed"}`, + ); + process.exit(1); + return; + } + + writer.write(`Deleted worksheet ${id}`); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const deleteCommand = buildCommand({ + loader: async () => del, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Worksheet ID to delete", parse: String }], + }, + flags: {}, + }, + docs: { brief: "Delete a worksheet by ID" }, +}); diff --git a/src/commands/worksheet/get.ts b/src/commands/worksheet/get.ts new file mode 100644 index 0000000..6a8511c --- /dev/null +++ b/src/commands/worksheet/get.ts @@ -0,0 +1,46 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { getWorksheet } from "../../gql/worksheet/worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +async function get( + this: LocalContext, + _flags: Record, + id: string, +): Promise { + const { process, writer } = this; + + try { + const config = loadConfig(); + const worksheet = await getWorksheet(config, id); + + if (worksheet === null) { + writer.error(`Error: worksheet not found: ${id}`); + process.exit(1); + return; + } + + writer.write(JSON.stringify(worksheet, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const getCommand = buildCommand({ + loader: async () => get, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Worksheet ID", parse: String }], + }, + flags: {}, + }, + docs: { brief: "Get a worksheet by ID as JSON" }, +}); diff --git a/src/commands/worksheet/index.ts b/src/commands/worksheet/index.ts new file mode 100644 index 0000000..2a29635 --- /dev/null +++ b/src/commands/worksheet/index.ts @@ -0,0 +1,26 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list"; +import { getCommand } from "./get"; +import { createCommand } from "./create"; +import { deleteCommand } from "./delete"; + +export const worksheetRoutes = buildRouteMap({ + routes: { + list: listCommand, + get: getCommand, + create: createCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage worksheets", + fullDescription: [ + "List, read, create, and delete worksheets.", + "", + "Commands:", + " list List worksheets in a workspace", + " get Get a worksheet by ID as JSON", + " create Create a worksheet from a JSON file", + " delete Delete a worksheet by ID", + ].join("\n"), + }, +}); diff --git a/src/commands/worksheet/list.ts b/src/commands/worksheet/list.ts new file mode 100644 index 0000000..e9ad327 --- /dev/null +++ b/src/commands/worksheet/list.ts @@ -0,0 +1,125 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { + searchWorksheets, + type WorksheetListEntry, +} from "../../gql/worksheet/worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { muteStatusWriter } from "../../lib/writer"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../lib/formatters/table"; +import { renderAsCSV } from "../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; + +interface ListWorksheetsFlags { + workspace: string; + name?: string; + format?: OutputFormat; + json?: boolean; +} + +const col = createColumnHelper(); + +const columns: ColumnDef[] = [ + col.accessor((row) => row.id, { + header: "ID", + format: (value) => chalk.cyan(value), + }), + col.accessor((row) => row.name, { header: "NAME" }), + col.accessor((row) => row.workspaceId, { + header: "WORKSPACE", + format: (value) => chalk.dim(value), + }), +]; + +async function list( + this: LocalContext, + flags: ListWorksheetsFlags, +): Promise { + const format = flags.json ? ("json" as const) : flags.format; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + try { + const config = loadConfig(); + let worksheets = await searchWorksheets(config, { + workspaceId: flags.workspace, + name: flags.name, + }); + + // Server-side search may return partial matches; apply the name filter + // again client-side to mirror the Go fork. + if (flags.name) { + const needle = flags.name.toLowerCase(); + worksheets = worksheets.filter((w) => + w.name.toLowerCase().includes(needle), + ); + } + + if (format === "json") { + writer.write(JSON.stringify(worksheets, null, 2)); + return; + } + if (format === "csv") { + writer.write(renderAsCSV(worksheets)); + return; + } + if (worksheets.length === 0) { + writer.warn("No worksheets found."); + return; + } + + writer.write(chalk.green(`Found ${worksheets.length} worksheet(s):\n`)); + writer.write(formatTable(worksheets, columns)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: { + workspace: { + kind: "parsed", + parse: String, + brief: "Workspace ID to list worksheets in", + optional: false, + }, + name: { + kind: "parsed", + parse: String, + brief: "Filter worksheets by name substring", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + }, + aliases: { w: "workspace" }, + }, + docs: { brief: "List worksheets in a workspace" }, +}); diff --git a/src/gql/dataset/dataset-analysis.ts b/src/gql/dataset/dataset-analysis.ts new file mode 100644 index 0000000..e2025de --- /dev/null +++ b/src/gql/dataset/dataset-analysis.ts @@ -0,0 +1,92 @@ +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import { parse } from "graphql"; +import type { Config } from "../../lib/config"; +import { executeGraphQL } from "../gql-request"; + +// Hand-authored GraphQL operations for `dataset dry-run` and `dataset impact`, +// ported from the Go fork (cmd_dataset.go). +// +// IMPORTANT — NOT verified against the current tenant. Both operations have +// drifted vs the Go fork's schema: `saveDatasetDryRun` no longer exists as a +// Mutation field (only `saveDataset` remains, with no dryRun argument), and +// `getDatasetsAffectedByDatasetUpdate` still exists but its result type +// `DatasetsAffectedByDatasetUpdateResult` no longer exposes the +// `affectedDatasets`/`errorDatasets` selections used below. The new field +// names could not be recovered because GraphQL introspection is disabled on +// the tenant. These are kept faithful to the Go fork so they work on tenants +// that still expose the legacy schema (and as a starting point once +// introspection is restored). See task: "Restore schema-blocked functionality". + +type DatasetInput = Record; +type MultiStageQueryInput = Record; + +interface DatasetRef { + id: string | null; + name: string | null; +} + +interface ErrorDataset { + dataset: DatasetRef | null; + errorText: string | null; +} + +interface DryRunResult { + saveDatasetDryRun: { + dataset: DatasetRef | null; + dematerializedDatasets: DatasetRef[] | null; + errorDatasets: ErrorDataset[] | null; + }; +} + +interface DatasetAnalysisVariables { + workspaceId: string; + dataset: DatasetInput; + query: MultiStageQueryInput; +} + +const DryRunDocument = parse(` + mutation SaveDatasetDryRun($workspaceId: ObjectId!, $dataset: DatasetInput!, $query: MultiStageQueryInput!) { + saveDatasetDryRun(workspaceId: $workspaceId, dataset: $dataset, query: $query) { + dataset { id name } + dematerializedDatasets { id name } + errorDatasets { dataset { id name } errorText } + } + } +`) as unknown as TypedDocumentNode; + +interface AffectedDataset { + dataset: DatasetRef | null; + dependencyType: string | null; +} + +interface ImpactResult { + getDatasetsAffectedByDatasetUpdate: { + affectedDatasets: AffectedDataset[] | null; + errorDatasets: ErrorDataset[] | null; + }; +} + +const ImpactDocument = parse(` + query DatasetsAffectedByUpdate($workspaceId: ObjectId!, $dataset: DatasetInput!, $query: MultiStageQueryInput) { + getDatasetsAffectedByDatasetUpdate(workspaceId: $workspaceId, dataset: $dataset, query: $query) { + affectedDatasets { dataset { id name } dependencyType } + errorDatasets { dataset { id name } errorText } + } + } +`) as unknown as TypedDocumentNode; + +export async function datasetDryRun( + config: Config, + variables: DatasetAnalysisVariables, +) { + const response = await executeGraphQL(config, DryRunDocument, variables); + return response.data.saveDatasetDryRun; +} + +export async function datasetImpact( + config: Config, + variables: DatasetAnalysisVariables, +) { + const response = await executeGraphQL(config, ImpactDocument, variables); + return response.data.getDatasetsAffectedByDatasetUpdate; +} diff --git a/src/gql/opal/check-queries.ts b/src/gql/opal/check-queries.ts new file mode 100644 index 0000000..5b206ee --- /dev/null +++ b/src/gql/opal/check-queries.ts @@ -0,0 +1,24 @@ +import type { Config } from "../../lib/config"; +import { + CheckQueriesDocument, + type CheckQueriesQuery, + type CheckQueriesQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +// Mirrors gqlCheckQueries in the Go fork (cmd_opal.go). Uses the generated +// CheckQueries operation (one of the few present in the codegen snapshot). +export type CheckQueriesResult = CheckQueriesQuery["checkQueries"][number]; + +export async function checkQueries( + config: Config, + variables: CheckQueriesQueryVariables, +) { + const response = await executeGraphQL( + config, + CheckQueriesDocument, + variables, + ); + // checkQueries returns one result per stage; the Go fork reads index 0. + return response.data.checkQueries[0] ?? null; +} diff --git a/src/gql/opal/validate-ingest-filter.ts b/src/gql/opal/validate-ingest-filter.ts new file mode 100644 index 0000000..e6f1bff --- /dev/null +++ b/src/gql/opal/validate-ingest-filter.ts @@ -0,0 +1,23 @@ +import type { Config } from "../../lib/config"; +import { + ValidateIngestFilterDocument, + type ValidateIngestFilterQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +// Mirrors gqlValidateIngestFilter in the Go fork (cmd_opal_validate.go). Uses +// the generated ValidateIngestFilter operation (present in the codegen +// snapshot). The API returns only { message } diagnostics; an empty array +// means the expression is valid. +export async function validateIngestFilter( + config: Config, + variables: ValidateIngestFilterQueryVariables, +) { + const response = await executeGraphQL( + config, + ValidateIngestFilterDocument, + variables, + ); + // A null result means the expression is valid; normalize to an empty array. + return response.data.validateIngestFilterExpression ?? []; +} diff --git a/src/gql/opal/verbs-and-functions.ts b/src/gql/opal/verbs-and-functions.ts new file mode 100644 index 0000000..8b69055 --- /dev/null +++ b/src/gql/opal/verbs-and-functions.ts @@ -0,0 +1,18 @@ +import type { Config } from "../../lib/config"; +import { + VerbsAndFunctionsDocument, + type VerbsAndFunctionsQuery, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +// Mirrors gqlVerbsAndFunctions in the Go fork (ot_opal.go). Uses the generated +// VerbsAndFunctions operation (present in the codegen snapshot). +export type OpalVerb = + VerbsAndFunctionsQuery["verbsAndFunctions"]["verbs"][number]; +export type OpalFunction = + VerbsAndFunctionsQuery["verbsAndFunctions"]["functions"][number]; + +export async function verbsAndFunctions(config: Config) { + const response = await executeGraphQL(config, VerbsAndFunctionsDocument, {}); + return response.data.verbsAndFunctions; +} diff --git a/src/gql/schema/introspect.ts b/src/gql/schema/introspect.ts new file mode 100644 index 0000000..d43b00b --- /dev/null +++ b/src/gql/schema/introspect.ts @@ -0,0 +1,77 @@ +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import { parse } from "graphql"; +import type { Config } from "../../lib/config"; +import { executeGraphQL } from "../gql-request"; + +// Hand-authored introspection query; mirrors gqlSchemaIntrospect in the Go fork +// (cmd_schema.go). This is NOT a codegen operation (it queries the meta field +// __schema), so it is declared by hand. NOTE: it only succeeds when the tenant +// has GraphQL introspection enabled. +export interface SchemaType { + kind: string | null; + name: string | null; + description: string | null; + [key: string]: unknown; +} + +export interface IntrospectionSchema { + queryType: { name: string | null } | null; + mutationType: { name: string | null } | null; + subscriptionType: { name: string | null } | null; + types: SchemaType[]; +} + +interface IntrospectResult { + __schema: IntrospectionSchema; +} + +const IntrospectDocument = parse(` + query Schema_Introspect { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + kind + name + description + fields(includeDeprecated: true) { + name + description + isDeprecated + type { + kind + name + ofType { kind name ofType { kind name } } + } + args { + name + description + type { kind name ofType { kind name } } + } + } + inputFields { + name + description + type { + kind + name + ofType { kind name ofType { kind name } } + } + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + } + interfaces { name kind } + possibleTypes { name kind } + } + } + } +`) as unknown as TypedDocumentNode>; + +export async function introspectSchema(config: Config) { + const response = await executeGraphQL(config, IntrospectDocument, {}); + return response.data.__schema; +} diff --git a/src/gql/worksheet/worksheet.ts b/src/gql/worksheet/worksheet.ts new file mode 100644 index 0000000..82a0dbb --- /dev/null +++ b/src/gql/worksheet/worksheet.ts @@ -0,0 +1,138 @@ +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import { parse } from "graphql"; +import type { Config } from "../../lib/config"; +import { executeGraphQL } from "../gql-request"; + +// Hand-authored GraphQL operations; mirror the queries in the Go fork +// (ot_worksheet.go / cmd_worksheet.go). Codegen is unavailable on this tenant +// (introspection disabled), so the operations and their types are by hand. +export interface WorksheetListEntry { + id: string; + name: string; + workspaceId: string; + updatedDate: string | null; +} + +export interface WorksheetStage { + stageID: string | null; + pipeline: string | null; +} + +export interface Worksheet { + id: string; + name: string; + workspaceId: string; + updatedDate: string | null; + stages: WorksheetStage[] | null; +} + +export interface SavedWorksheet { + id: string; + name: string; + workspaceId: string; +} + +// A worksheet input is an arbitrary worksheet JSON document; the API validates it. +type WorksheetInput = Record; + +interface SearchResult { + worksheetSearch: { + // NOTE: the live tenant schema returns `worksheets` here, not `results` + // as the Go fork's ot_worksheet.go query assumed (schema drift). Verified + // by probing the API (WorksheetSearchResultWrapper has no `results` field). + worksheets: { + worksheet: WorksheetListEntry | null; + score: number | null; + }[]; + }; +} + +interface SearchVariables { + terms: { workspaceId: string; name?: string }; +} + +// NOTE: the Go fork declared a `$maxCount: Int` variable, but on the live +// tenant `maxCount` is typed `Int64`, so declaring it as `Int` 422s. We don't +// use maxCount, so it is omitted entirely. +const SearchDocument = parse(` + query Worksheet_Search($terms: DWSearchInput!) { + worksheetSearch(terms: $terms) { + worksheets { + worksheet { id name workspaceId updatedDate } + score + } + } + } +`) as unknown as TypedDocumentNode; + +interface GetResult { + worksheet: Worksheet | null; +} + +const GetDocument = parse(` + query Worksheet_Get($id: ObjectId!) { + worksheet(id: $id) { + id + name + workspaceId + updatedDate + stages { stageID pipeline } + } + } +`) as unknown as TypedDocumentNode; + +interface SaveResult { + saveWorksheet: SavedWorksheet; +} + +const SaveDocument = parse(` + mutation Worksheet_Save($wks: WorksheetInput!) { + saveWorksheet(wks: $wks) { + id + name + workspaceId + } + } +`) as unknown as TypedDocumentNode; + +interface DeleteResult { + deleteWorksheet: { + success: boolean | null; + errorMessage: string | null; + }; +} + +const DeleteDocument = parse(` + mutation Worksheet_Delete($id: ObjectId!) { + deleteWorksheet(wks: $id) { + success + errorMessage + } + } +`) as unknown as TypedDocumentNode; + +export async function searchWorksheets( + config: Config, + { workspaceId, name }: { workspaceId: string; name?: string }, +) { + const terms = name ? { workspaceId, name } : { workspaceId }; + const response = await executeGraphQL(config, SearchDocument, { terms }); + return response.data.worksheetSearch.worksheets + .map((r) => r.worksheet) + .filter((w): w is WorksheetListEntry => w !== null); +} + +export async function getWorksheet(config: Config, id: string) { + const response = await executeGraphQL(config, GetDocument, { id }); + return response.data.worksheet; +} + +export async function saveWorksheet(config: Config, wks: WorksheetInput) { + const response = await executeGraphQL(config, SaveDocument, { wks }); + return response.data.saveWorksheet; +} + +export async function deleteWorksheet(config: Config, id: string) { + const response = await executeGraphQL(config, DeleteDocument, { id }); + return response.data.deleteWorksheet; +} diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts index d0f8757..389eab4 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -5,3 +5,27 @@ export function parseNonNegativeInt(value: string): number { } return num; } + +const DURATION_UNIT_MS: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +/** + * Parse a Go-style duration (e.g. "20m", "24h", "7d", "90s", "500ms") to + * milliseconds. Used by `fleet --window`. + */ +export function parseDurationMs(value: string): number { + const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value.trim()); + if (!match) { + throw new Error( + `Invalid duration "${value}"; use a value like 20m, 24h, or 7d`, + ); + } + const amount = Number(match[1]); + const unitMs = DURATION_UNIT_MS[match[2] ?? ""] ?? 0; + return amount * unitMs; +} diff --git a/src/rest/export/run-opal-query.ts b/src/rest/export/run-opal-query.ts new file mode 100644 index 0000000..04cccaa --- /dev/null +++ b/src/rest/export/run-opal-query.ts @@ -0,0 +1,63 @@ +import { getApiBaseUrl, type Config } from "../../lib/config"; +import { observeApiHeaders } from "../../lib/user-agent"; +import { Configuration, ExportApi } from "../generated"; + +/** + * Run a single-stage OPAL pipeline against a dataset (by path or id) over a + * time window and return the raw CSV body. Thin wrapper over the REST + * /v1/meta/export/query endpoint (ExportApi). Used by `fleet`, mirroring + * runFleetQuery in the Go fork (cmd_fleet.go). + */ +export async function runOpalQueryCsv( + config: Config, + { + pipeline, + datasetPath, + datasetId, + startTime, + endTime, + }: { + pipeline: string; + datasetPath?: string; + datasetId?: string; + startTime: string; + endTime: string; + }, +): Promise { + // Build the ExportApi with `Accept: text/csv` baked into the configuration + // headers. These are merged by the generated runtime (Object.assign over the + // request init), unlike per-call `initOverrides` headers which REPLACE the + // computed headers and would drop Authorization (yielding a 401). + const restConfig = new Configuration({ + basePath: getApiBaseUrl(config), + accessToken: async () => `${config.customerId} ${config.token}`, + headers: observeApiHeaders({ Accept: "text/csv" }), + }); + const exportApi = new ExportApi(restConfig); + + const csv = await exportApi.exportQuery({ + startTime, + endTime, + exportQueryRequest: { + query: { + outputStage: "query", + stages: [ + { + stageID: "query", + pipeline, + input: [ + { + inputName: "_", + ...(datasetId != null + ? { datasetId } + : { datasetPath: datasetPath ?? "" }), + }, + ], + }, + ], + }, + presentation: { linkify: false }, + }, + }); + return csv ?? ""; +}