Skip to content

Commit 61947a6

Browse files
committed
feat(prom): GMP-aware tokenProvider auth + onboarding option
The Prometheus client gains a tokenProvider option that's invoked per request, so short-lived bearer tokens (Google Managed Prometheus, default ~1h TTL) refresh transparently mid-investigation. Wins over a static bearer token when both are set. google-auth-library handles the actual caching and refresh; the CLI wraps it as a stable getAccessToken() helper exported from gcp-alert.ts and reused by both the existing GCP Monitoring REST calls and the new GMP prom client wiring in bootstrap. Onboarding adds 'Google Managed Prometheus (ADC)' as a fourth auth option. Selecting it persists prom.auth = 'gcp' in config.yml and proposes a default URL of: https://monitoring.googleapis.com/v1/projects/<PROJECT>/location/global/prometheus populated from the GCP project_id when available. tools/README now annotates each Prom discovery tool with GMP support status: list_metrics + metric_metadata work against GMP; get_targets does not (gateway doesn't expose /api/v1/targets — tool surfaces that in the summary instead of erroring, and the agent can fall back to querying up{} as a metric).
1 parent 0c125fa commit 61947a6

7 files changed

Lines changed: 204 additions & 14 deletions

File tree

packages/cli/src/bootstrap.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
saveCredentials,
1919
} from "./config.ts";
2020
import { discoverProviderEnvVar } from "./discover.ts";
21+
import { getAccessToken } from "./gcp-alert.ts";
2122
import { ensureFreshOAuthToken } from "./oauth.ts";
2223

2324
export interface BootstrapResult {
@@ -86,8 +87,13 @@ export async function bootstrapRealClients(): Promise<BootstrapResult> {
8687
gcp: new RealGcpLoggingClient(settings.gcp.projectId as string),
8788
prom: new RealPromClient({
8889
baseUrl: settings.prom.url,
89-
...(settings.prom.bearerToken && { bearerToken: settings.prom.bearerToken }),
90-
...(settings.prom.basicAuth && { basicAuth: settings.prom.basicAuth }),
90+
...(settings.prom.auth === "gcp"
91+
? { tokenProvider: getAccessToken }
92+
: settings.prom.bearerToken
93+
? { bearerToken: settings.prom.bearerToken }
94+
: settings.prom.basicAuth
95+
? { basicAuth: settings.prom.basicAuth }
96+
: {}),
9197
}),
9298
jaeger: new RealJaegerClient({
9399
baseUrl: settings.jaeger.url,

packages/cli/src/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ export interface OpsremedyConfig {
3030
url: string;
3131
bearer_token_env?: string; // name of env var holding token, optional
3232
user?: string;
33+
/**
34+
* Authentication mode. "gcp" mints an ADC token per request — required
35+
* for Google Managed Prometheus, whose access tokens expire (~1h) and
36+
* must be refreshed without restarting the CLI. Defaults to "static"
37+
* (use bearer_token_env or basic auth credentials).
38+
*/
39+
auth?: "static" | "gcp";
3340
};
3441
jaeger?: {
3542
url: string;
@@ -160,6 +167,8 @@ export interface ResolvedSettings {
160167
url: string;
161168
bearerToken: string | undefined;
162169
basicAuth: { user: string; password: string } | undefined;
170+
/** "gcp" wires an ADC tokenProvider in bootstrap. */
171+
auth: "static" | "gcp";
163172
};
164173
jaeger: { url: string; token: string | undefined };
165174
k8s: { kubeconfigPath: string | undefined; context: string | undefined };
@@ -199,6 +208,7 @@ export function resolveSettings(cfg: OpsremedyConfig, creds: OpsremedyCredential
199208
url: promUrl,
200209
bearerToken: promBearer,
201210
basicAuth: promUser && promPass ? { user: promUser, password: promPass } : undefined,
211+
auth: cfg.prom?.auth ?? "static",
202212
},
203213
jaeger: { url: jaegerUrl, token: jaegerToken },
204214
k8s: { kubeconfigPath: kubeconfig, context: k8sContext },

packages/cli/src/gcp-alert.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,14 @@ function inferSeverity(name: string): Alert["severity"] {
235235

236236
let cachedAuth: GoogleAuth | null = null;
237237

238-
async function getAccessToken(): Promise<string> {
238+
/**
239+
* Mint an ADC bearer token for monitoring.read + cloud-platform scopes.
240+
* `google-auth-library` caches and refreshes the token internally, so
241+
* callers can invoke this per-request safely. Used by both the GCP
242+
* Monitoring REST calls in this file and by the GMP-mode Prometheus
243+
* client (see bootstrap.ts).
244+
*/
245+
export async function getAccessToken(): Promise<string> {
239246
if (!cachedAuth) {
240247
cachedAuth = new GoogleAuth({
241248
scopes: [

packages/cli/src/onboard/sections/prom.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,56 @@ export async function sectionProm(
1010
password: string | undefined;
1111
}> {
1212
console.log("\n== Prometheus ==");
13-
const url = await input({
14-
message: `Prometheus URL${reachable ? " (default reachable)" : ""}`,
15-
default: cfg.prom?.url ?? DEFAULT_PROM_URL,
16-
});
1713

18-
const auth = await select<"none" | "bearer" | "basic">({
14+
const auth = await select<"none" | "bearer" | "basic" | "gcp">({
1915
message: "Auth",
2016
choices: [
2117
{ name: "none", value: "none" },
2218
{ name: "bearer token", value: "bearer" },
2319
{ name: "basic auth", value: "basic" },
20+
{
21+
name: "Google Managed Prometheus (ADC, refreshes per request)",
22+
value: "gcp",
23+
},
2424
],
2525
default: "none",
2626
});
2727

28+
let url: string;
29+
if (auth === "gcp") {
30+
const projectHint = cfg.gcp?.project_id ?? "<PROJECT_ID>";
31+
const defaultUrl = cfg.prom?.url?.includes("monitoring.googleapis.com")
32+
? cfg.prom.url
33+
: `https://monitoring.googleapis.com/v1/projects/${projectHint}/location/global/prometheus`;
34+
url = await input({
35+
message: "Prometheus URL (Google Managed Prometheus)",
36+
default: defaultUrl,
37+
});
38+
} else {
39+
url = await input({
40+
message: `Prometheus URL${reachable ? " (default reachable)" : ""}`,
41+
default: cfg.prom?.url ?? DEFAULT_PROM_URL,
42+
});
43+
}
44+
2845
const fileShape: NonNullable<OpsremedyConfig["prom"]> = { url };
2946
let bearerToken: string | undefined;
3047
let pw: string | undefined;
3148

3249
if (auth === "bearer") {
50+
fileShape.auth = "static";
3351
const t = await password({ message: "Bearer token", mask: "*" });
3452
bearerToken = t || undefined;
3553
} else if (auth === "basic") {
54+
fileShape.auth = "static";
3655
const user = await input({ message: "Basic-auth user", default: cfg.prom?.user ?? "" });
3756
if (user) fileShape.user = user;
3857
const t = await password({ message: "Basic-auth password", mask: "*" });
3958
pw = t || undefined;
59+
} else if (auth === "gcp") {
60+
fileShape.auth = "gcp";
61+
} else {
62+
fileShape.auth = "static";
4063
}
4164

4265
return { fileShape, bearerToken, password: pw };
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { RealPromClient } from "./real.ts";
3+
4+
const ORIGINAL_FETCH = globalThis.fetch;
5+
6+
interface CapturedRequest {
7+
url: string;
8+
headers: Record<string, string>;
9+
}
10+
11+
function installMockFetch(responder: (req: CapturedRequest) => unknown): CapturedRequest[] {
12+
const requests: CapturedRequest[] = [];
13+
globalThis.fetch = (async (input: unknown, init?: RequestInit): Promise<Response> => {
14+
const url = typeof input === "string" ? input : String(input);
15+
const headers: Record<string, string> = {};
16+
const raw = init?.headers ?? {};
17+
if (raw instanceof Headers) {
18+
raw.forEach((v, k) => {
19+
headers[k.toLowerCase()] = v;
20+
});
21+
} else if (Array.isArray(raw)) {
22+
for (const [k, v] of raw as Array<[string, string]>) headers[k.toLowerCase()] = v;
23+
} else {
24+
for (const [k, v] of Object.entries(raw as Record<string, string>)) {
25+
headers[k.toLowerCase()] = v;
26+
}
27+
}
28+
const captured: CapturedRequest = { url, headers };
29+
requests.push(captured);
30+
const data = responder(captured);
31+
return new Response(JSON.stringify({ status: "success", data }), {
32+
headers: { "content-type": "application/json" },
33+
});
34+
}) as typeof fetch;
35+
return requests;
36+
}
37+
38+
beforeEach(() => {
39+
// ensure clean state
40+
});
41+
42+
afterEach(() => {
43+
globalThis.fetch = ORIGINAL_FETCH;
44+
});
45+
46+
describe("RealPromClient.headers", () => {
47+
test("uses tokenProvider per request and overrides static bearerToken", async () => {
48+
let count = 0;
49+
const tokenProvider = async () => `t-${++count}`;
50+
const requests = installMockFetch(() => []);
51+
const client = new RealPromClient({
52+
baseUrl: "https://prom.example.com",
53+
bearerToken: "static-loses",
54+
tokenProvider,
55+
});
56+
await client.listMetrics({});
57+
await client.listMetrics({});
58+
expect(requests).toHaveLength(2);
59+
expect(requests[0]!.headers.authorization).toBe("Bearer t-1");
60+
expect(requests[1]!.headers.authorization).toBe("Bearer t-2");
61+
});
62+
63+
test("falls back to static bearerToken when no provider", async () => {
64+
const requests = installMockFetch(() => []);
65+
const client = new RealPromClient({
66+
baseUrl: "https://prom.example.com",
67+
bearerToken: "abc",
68+
});
69+
await client.listMetrics({});
70+
expect(requests[0]!.headers.authorization).toBe("Bearer abc");
71+
});
72+
73+
test("falls back to basic auth when no token sources", async () => {
74+
const requests = installMockFetch(() => []);
75+
const client = new RealPromClient({
76+
baseUrl: "https://prom.example.com",
77+
basicAuth: { user: "u", password: "p" },
78+
});
79+
await client.listMetrics({});
80+
const expected = `Basic ${Buffer.from("u:p").toString("base64")}`;
81+
expect(requests[0]!.headers.authorization).toBe(expected);
82+
});
83+
84+
test("omits Authorization when no auth configured", async () => {
85+
const requests = installMockFetch(() => []);
86+
const client = new RealPromClient({ baseUrl: "https://prom.example.com" });
87+
await client.listMetrics({});
88+
expect(requests[0]!.headers.authorization).toBeUndefined();
89+
});
90+
});
91+
92+
describe("RealPromClient parsers", () => {
93+
test("metricMetadata normalises Record<metric, items[]> shape", async () => {
94+
installMockFetch(() => ({
95+
http_requests_total: [{ type: "counter", help: "Total requests.", unit: "" }],
96+
kube_pod_status_phase: [{ type: "gauge", help: "Phase.", unit: "" }],
97+
}));
98+
const client = new RealPromClient({ baseUrl: "https://prom.example.com" });
99+
const md = await client.metricMetadata({});
100+
expect(md).toHaveLength(2);
101+
expect(md.find((m) => m.metric === "http_requests_total")?.type).toBe("counter");
102+
});
103+
104+
test("targets returns down lastError and filters by job", async () => {
105+
installMockFetch(() => ({
106+
activeTargets: [
107+
{
108+
labels: { job: "payments", instance: "i1" },
109+
health: "down",
110+
lastError: "connection refused",
111+
scrapeUrl: "http://i1/metrics",
112+
},
113+
{
114+
labels: { job: "billing", instance: "i2" },
115+
health: "up",
116+
scrapeUrl: "http://i2/metrics",
117+
},
118+
],
119+
}));
120+
const client = new RealPromClient({ baseUrl: "https://prom.example.com" });
121+
const all = await client.targets({});
122+
expect(all).toHaveLength(2);
123+
const payments = await client.targets({ job: "payments" });
124+
expect(payments).toHaveLength(1);
125+
expect(payments[0]!.lastError).toBe("connection refused");
126+
});
127+
128+
test("listMetrics filters by case-insensitive substring", async () => {
129+
installMockFetch(() => ["http_requests_total", "kube_pod_status_phase"]);
130+
const client = new RealPromClient({ baseUrl: "https://prom.example.com" });
131+
const m = await client.listMetrics({ contains: "HTTP" });
132+
expect(m).toEqual(["http_requests_total"]);
133+
});
134+
});

packages/clients/src/prom/real.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export interface RealPromClientOptions {
1818
baseUrl: string;
1919
bearerToken?: string;
2020
basicAuth?: { user: string; password: string };
21+
/**
22+
* Async token provider, called per request. Used for short-lived tokens
23+
* (e.g. Google Managed Prometheus access tokens, default TTL 1h) so a
24+
* single CLI run can keep working past expiry. Wins over `bearerToken`
25+
* when both are set.
26+
*/
27+
tokenProvider?: () => Promise<string>;
2128
}
2229

2330
interface PromApiEnvelope<T> {
@@ -197,7 +204,7 @@ export class RealPromClient implements PromClient {
197204
private async get<T>(path: string, params: URLSearchParams, signal?: AbortSignal): Promise<T> {
198205
const url = `${this.baseUrl}${path}?${params.toString()}`;
199206
const res = await fetch(url, {
200-
headers: this.headers(),
207+
headers: await this.headers(),
201208
...(signal !== undefined && { signal }),
202209
});
203210
if (!res.ok) {
@@ -211,9 +218,12 @@ export class RealPromClient implements PromClient {
211218
return env.data;
212219
}
213220

214-
private headers(): Record<string, string> {
221+
private async headers(): Promise<Record<string, string>> {
215222
const h: Record<string, string> = { Accept: "application/json" };
216-
if (this.opts.bearerToken) {
223+
if (this.opts.tokenProvider) {
224+
const token = await this.opts.tokenProvider();
225+
h.Authorization = `Bearer ${token}`;
226+
} else if (this.opts.bearerToken) {
217227
h.Authorization = `Bearer ${this.opts.bearerToken}`;
218228
} else if (this.opts.basicAuth) {
219229
const { user, password } = this.opts.basicAuth;

packages/tools/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ TypeBox parameter schema.
1313
| `query_prom_instant` | PromQL at a single timestamp |
1414
| `query_prom_range` | PromQL over a window (range query) |
1515
| `get_prom_alert_rules` | List alerting rules + state |
16-
| `list_prom_metrics` | Discover metric names by substring (resolves "no series returned" before guessing labels) |
17-
| `get_prom_metric_metadata` | Metric type/help/unit (counter vs gauge vs histogram) |
18-
| `get_prom_targets` | Scrape target health (up/down + lastError); empty under Google Managed Prometheus |
16+
| `list_prom_metrics` | Discover metric names by substring. ✅ GMP-compatible. |
17+
| `get_prom_metric_metadata` | Metric type/help/unit (counter vs gauge vs histogram). ✅ GMP-compatible. |
18+
| `get_prom_targets` | Scrape target health (up/down + lastError). ❌ Not exposed by GMP gateway — returns empty there; agent falls back to `up{}` query. |
1919
| `query_jaeger_traces` | Find traces for a service |
2020
| `get_jaeger_service_deps` | Upstream/downstream edges |
2121
| `k8s_cluster_info` | Cluster-level facts: node ready count + namespace list |

0 commit comments

Comments
 (0)