Skip to content

Commit 1494db2

Browse files
committed
Merge branch 'feature/require-project-name' into 'main'
fix(monitoring): remove postgres-ai-monitoring default; require project name Closes #249 See merge request postgres-ai/postgresai!333
2 parents 050b516 + c3514e4 commit 1494db2

7 files changed

Lines changed: 232 additions & 39 deletions

File tree

cli/bin/postgres-ai.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2443,9 +2443,44 @@ interface MonitoringRegistration {
24432443
*
24442444
* Never throws — registration is best-effort; returns null on failure.
24452445
*/
2446+
2447+
/**
2448+
* Classify how `mon local-install` should register the monitoring instance,
2449+
* given the raw `--project` value and the resolved instance id.
2450+
*
2451+
* - With an instance id: ADOPT the provisioned instance. No project name is
2452+
* required (the platform returns the real project); a provided name is
2453+
* normalized and carried through for messaging/fallback.
2454+
* - No instance id and no project name: ERROR. The hardcoded
2455+
* "postgres-ai-monitoring" default was removed, and a nameless legacy
2456+
* self-registration is rejected by v1.monitoring_instance_register (PT400).
2457+
* - No instance id but a project name: legacy SELF-REGISTER.
2458+
*
2459+
* Pure (no I/O) so the decision is unit-testable independently of the large
2460+
* install command. `projectName` is the trimmed value, or undefined when empty.
2461+
*/
2462+
type MonRegistrationPlan =
2463+
| { kind: "adopt"; projectName: string | undefined }
2464+
| { kind: "self-register"; projectName: string }
2465+
| { kind: "error-missing-project"; projectName: undefined };
2466+
2467+
function planMonitoringRegistration(args: {
2468+
project?: string;
2469+
instanceId?: string;
2470+
}): MonRegistrationPlan {
2471+
const projectName = args.project?.trim() || undefined;
2472+
if (args.instanceId) {
2473+
return { kind: "adopt", projectName };
2474+
}
2475+
if (!projectName) {
2476+
return { kind: "error-missing-project", projectName: undefined };
2477+
}
2478+
return { kind: "self-register", projectName };
2479+
}
2480+
24462481
async function registerMonitoringInstance(
24472482
apiKey: string,
2448-
projectName: string,
2483+
projectName: string | undefined,
24492484
opts?: { apiBaseUrl?: string; debug?: boolean; instanceId?: string; retries?: number; retryDelayMs?: number }
24502485
): Promise<MonitoringRegistration | null> {
24512486
const { apiBaseUrl } = resolveBaseUrls(opts);
@@ -2457,16 +2492,25 @@ async function registerMonitoringInstance(
24572492
// moment to recover; skipped before the first attempt. Tests pass 0.
24582493
const retryDelayMs = opts?.retryDelayMs ?? 400;
24592494

2495+
// Omit project_name entirely when empty: the adopt path (instance_id present)
2496+
// relies on the platform returning the real project, and a nameless legacy
2497+
// self-registration is rejected by v1.monitoring_instance_register (PT400).
2498+
const hasProjectName = !!(projectName && projectName.trim());
2499+
24602500
if (debug) {
24612501
console.error(`\nDebug: Registering monitoring instance...`);
24622502
console.error(`Debug: POST ${url}`);
2463-
console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
2503+
console.error(
2504+
`Debug: ${hasProjectName ? `project_name=${projectName}` : "project_name=(omitted)"}${instanceId ? ` instance_id=${instanceId}` : ""}`
2505+
);
24642506
}
24652507

24662508
const requestBody: Record<string, string> = {
24672509
api_token: apiKey,
2468-
project_name: projectName,
24692510
};
2511+
if (hasProjectName) {
2512+
requestBody.project_name = projectName as string;
2513+
}
24702514
if (instanceId) {
24712515
requestBody.instance_id = instanceId;
24722516
}
@@ -3137,8 +3181,11 @@ mon
31373181
// and persisted; the legacy self-registration stays fire-and-forget
31383182
// (issue platform-all#311).
31393183
if (apiKey && !opts.demo) {
3140-
const projectName = opts.project || "postgres-ai-monitoring";
31413184
const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
3185+
const plan = planMonitoringRegistration({ project: opts.project, instanceId });
3186+
const projectName = plan.projectName;
3187+
// `instanceId` truthy ⟺ plan.kind === "adopt"; branch on it directly so
3188+
// TypeScript narrows instanceId to a defined string in the adopt path.
31423189
if (instanceId) {
31433190
const reg = await registerMonitoringInstance(apiKey, projectName, {
31443191
apiBaseUrl: globalOpts.apiBaseUrl,
@@ -3160,11 +3207,17 @@ mon
31603207
// Request succeeded but carried no usable project field — don't claim
31613208
// adoption, but don't report a hard failure either (no re-run needed).
31623209
console.error(
3163-
`⚠ Adopted provisioned instance ${instanceId} but the platform returned no project — reports will use project '${projectName}'`
3210+
`⚠ Adopted provisioned instance ${instanceId} but the platform returned no project` +
3211+
(projectName
3212+
? ` — reports will use project '${projectName}'`
3213+
: ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
31643214
);
31653215
} else {
31663216
console.error(
3167-
`⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3217+
`⚠ Could not adopt provisioned instance ${instanceId}` +
3218+
(projectName
3219+
? ` — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3220+
: ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
31683221
);
31693222
}
31703223

@@ -3188,6 +3241,17 @@ mon
31883241
`⚠ AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'\n`
31893242
);
31903243
}
3244+
} else if (plan.kind === "error-missing-project") {
3245+
// Legacy self-registration (no --instance-id) now requires a project
3246+
// name: the hardcoded "postgres-ai-monitoring" default was removed, and
3247+
// v1.monitoring_instance_register raises PT400 for a nameless legacy
3248+
// registration. Console-provisioned installs should adopt with
3249+
// --instance-id instead.
3250+
console.error(
3251+
"✗ A project name is required for self-registration (the 'postgres-ai-monitoring' default was removed). " +
3252+
"Re-run with --project <name>, or adopt a console-provisioned instance with --instance-id <uuid>."
3253+
);
3254+
process.exitCode = 1;
31913255
} else {
31923256
void registerMonitoringInstance(apiKey, projectName, {
31933257
apiBaseUrl: globalOpts.apiBaseUrl,
@@ -5488,3 +5552,4 @@ if (import.meta.main) {
54885552
// same functions used by the `mon` commands).
54895553
export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
54905554
export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };
5555+
export { planMonitoringRegistration, type MonRegistrationPlan };

cli/test/monitoring.test.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {
2020
registerMonitoringInstance,
2121
resolveAdoptedProject,
22+
planMonitoringRegistration,
2223
} from "../bin/postgres-ai";
2324

2425
/**
@@ -241,12 +242,13 @@ describe("registerMonitoringInstance", () => {
241242
});
242243

243244
// Issue platform-all#311: console-provisioned installs pass instance_id so
244-
// the platform adopts the provisioned instance instead of self-registering
245-
// a duplicate under an auto-created "postgres-ai-monitoring" project.
245+
// the platform adopts the provisioned instance and returns its real project
246+
// — the CLI sends NO project_name on the adopt path (the hardcoded
247+
// "postgres-ai-monitoring" default was removed).
246248
test("includes instance_id in body when adopting a provisioned instance", async () => {
247249
const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
248250

249-
await registerMonitoringInstance("key", "postgres-ai-monitoring", opts({ instanceId }));
251+
await registerMonitoringInstance("key", undefined, opts({ instanceId }));
250252

251253
const body = JSON.parse(fetchCalls[0].options.body as string);
252254
expect(body.instance_id).toBe(instanceId);
@@ -255,13 +257,31 @@ describe("registerMonitoringInstance", () => {
255257
expect(headers["instance-id"]).toBeUndefined();
256258
});
257259

260+
test("omits project_name from the body when undefined (adopt path)", async () => {
261+
await registerMonitoringInstance("key", undefined, opts({ instanceId: "i" }));
262+
263+
const body = JSON.parse(fetchCalls[0].options.body as string);
264+
// The adopt path sends no name; the platform returns the real project.
265+
expect("project_name" in body).toBe(false);
266+
expect(body.api_token).toBe("key");
267+
});
268+
269+
test("omits project_name from the body when empty/whitespace", async () => {
270+
await registerMonitoringInstance("key", " ", opts({ instanceId: "i" }));
271+
272+
const body = JSON.parse(fetchCalls[0].options.body as string);
273+
expect("project_name" in body).toBe(false);
274+
});
275+
258276
test("omits instance_id from the body for legacy self-registration", async () => {
259277
await registerMonitoringInstance("key", "my-project", opts());
260278

261279
const body = JSON.parse(fetchCalls[0].options.body as string);
262280
// PostgREST matches the 3-arg function via its default — the key must be
263281
// ABSENT (not null) so legacy CLIs and the new one hit the same overload.
264282
expect("instance_id" in body).toBe(false);
283+
// A real project name still rides in the body for legacy registration.
284+
expect(body.project_name).toBe("my-project");
265285
});
266286

267287
test("a 200 with {project_id, project_name} returns a populated result", async () => {
@@ -323,6 +343,37 @@ describe("registerMonitoringInstance", () => {
323343
});
324344
});
325345

346+
describe("planMonitoringRegistration — mon local-install registration decision", () => {
347+
test("instance id present → adopt, no project name required", () => {
348+
const plan = planMonitoringRegistration({ instanceId: "i-123" });
349+
expect(plan.kind).toBe("adopt");
350+
expect(plan.projectName).toBeUndefined();
351+
});
352+
353+
test("instance id present + project → adopt, carries the trimmed name", () => {
354+
const plan = planMonitoringRegistration({ instanceId: "i-123", project: " prod-db " });
355+
expect(plan.kind).toBe("adopt");
356+
expect(plan.projectName).toBe("prod-db");
357+
});
358+
359+
test("no instance id + no project → error-missing-project (exit 1 path)", () => {
360+
const plan = planMonitoringRegistration({});
361+
expect(plan.kind).toBe("error-missing-project");
362+
expect(plan.projectName).toBeUndefined();
363+
});
364+
365+
test("no instance id + empty/whitespace project → error-missing-project", () => {
366+
expect(planMonitoringRegistration({ project: "" }).kind).toBe("error-missing-project");
367+
expect(planMonitoringRegistration({ project: " " }).kind).toBe("error-missing-project");
368+
});
369+
370+
test("no instance id + real project → legacy self-register with the trimmed name", () => {
371+
const plan = planMonitoringRegistration({ project: " my-project " });
372+
expect(plan.kind).toBe("self-register");
373+
expect(plan.projectName).toBe("my-project");
374+
});
375+
});
376+
326377
describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
327378
test("prefers the numeric project_id over the name (survives renames)", () => {
328379
expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");

config/scripts/postgres-reports.sh

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use_current_time="${USE_CURRENT_TIME:-false}"
1111

1212
pgwatch_config_path="${REPORTER_PGWATCH_CONFIG_PATH:-/app/.pgwatch-config}"
1313
api_url="${REPORTER_API_URL:-https://postgres.ai/api/general}"
14-
# Project name: env var takes priority, then config file, then default
14+
# Project name: env var takes priority, then config file. No default — uploads
15+
# require a project name (the hardcoded "postgres-ai-monitoring" default was removed).
1516
project_name="${REPORTER_PROJECT_NAME:-}"
1617

1718
sleep_seconds() {
@@ -60,12 +61,11 @@ read_project_name() {
6061
return 1
6162
}
6263

63-
# Resolve project name: env var > config file > default
64+
# Resolve project name: env var > config file. No default — uploads require a
65+
# project name (the hardcoded "postgres-ai-monitoring" default was removed).
6466
if [[ -z "${project_name}" ]]; then
6567
if config_project_name="$(read_project_name)"; then
6668
project_name="${config_project_name}"
67-
else
68-
project_name="postgres-ai-monitoring"
6969
fi
7070
fi
7171

@@ -82,14 +82,20 @@ while true; do
8282
fi
8383

8484
if api_key="$(read_api_key)"; then
85-
echo "postgres-reports: generating reports (upload enabled) -> ${output_path}"
86-
python -m reporter.postgres_reports \
87-
--prometheus-url "${prometheus_url}" \
88-
--output "${output_path}" \
89-
--api-url "${api_url}" \
90-
--project-name "${project_name}" \
91-
--token "${api_key}" \
92-
${use_current_time_arg}
85+
if [[ -z "${project_name}" ]]; then
86+
# Upload requires a project name; there is no default. Skip this cycle's
87+
# upload rather than send an empty --project-name.
88+
echo "postgres-reports: ERROR project name is required for upload — set REPORTER_PROJECT_NAME or project_name in .pgwatch-config; skipping upload this cycle" >&2
89+
else
90+
echo "postgres-reports: generating reports (upload enabled) -> ${output_path}"
91+
python -m reporter.postgres_reports \
92+
--prometheus-url "${prometheus_url}" \
93+
--output "${output_path}" \
94+
--api-url "${api_url}" \
95+
--project-name "${project_name}" \
96+
--token "${api_key}" \
97+
${use_current_time_arg}
98+
fi
9399
else
94100
echo "postgres-reports: generating reports (no upload) -> ${output_path}"
95101
python -m reporter.postgres_reports \

postgres_ai_helm/templates/reporter-cronjob.yaml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,28 @@ spec:
109109
- |
110110
set -eu
111111
if [ -n "${API_KEY:-}" ]; then
112+
# Uploads require a project name; there is no default (the
113+
# hardcoded "postgres-ai-monitoring" default was removed).
114+
# Enforce it at runtime, not via Helm `required`: whether
115+
# uploads happen is decided here at runtime by the presence
116+
# of API_KEY (an optional secret key), so Helm cannot know it
117+
# at render time. A render-time `required` would also break
118+
# `helm template`/`lint` and the no-upload path. Fail loudly.
119+
# REPORTER_PROJECT comes from the env (see below), never
120+
# inlined into this script, so the value can't break out of
121+
# the shell command.
122+
if [ -z "${REPORTER_PROJECT:-}" ]; then
123+
echo "reporter.project is required for uploads (the 'postgres-ai-monitoring' default was removed); set reporter.project" >&2
124+
exit 1
125+
fi
112126
exec python postgres_reports.py \
113127
--prometheus-url "$PROMETHEUS_URL" \
114128
--postgres-sink-url "postgresql://{{ $root.Values.sinkPostgres.user }}:${POSTGRES_PASSWORD}@{{ include "postgres-ai-monitoring.fullname" $root }}-sink-postgres:5432/{{ $root.Values.sinkPostgres.database }}" \
115129
--cluster "$CLUSTER" \
116130
--node-name "$NODE_NAME" \
117131
--output /app/reports/report.json \
118132
--api-url "{{ $root.Values.reporter.apiUrl | default "https://postgres.ai/api/general" }}" \
119-
--project "{{ $root.Values.reporter.project | default "postgres-ai-monitoring" }}" \
133+
--project "$REPORTER_PROJECT" \
120134
--token "$API_KEY"
121135
else
122136
exec python postgres_reports.py \
@@ -137,6 +151,8 @@ spec:
137151
key: postgres-password
138152
- name: CLUSTER
139153
value: {{ $clusterName | default "k8s-cluster" | quote }}
154+
- name: REPORTER_PROJECT
155+
value: {{ $root.Values.reporter.project | quote }}
140156
- name: NODE_NAME
141157
{{- if $nodeName }}
142158
value: {{ $nodeName | quote }}
@@ -153,7 +169,7 @@ spec:
153169
key: pgai-api-key
154170
optional: true
155171
{{- end }}
156-
172+
157173
{{- if $root.Values.victoriaMetrics.auth.enabled }}
158174
- name: VM_AUTH_USERNAME
159175
value: {{ $root.Values.victoriaMetrics.auth.username | quote }}

postgres_ai_helm/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ reporter:
117117
# clusterName/nodeName per database in monitoredDatabases.
118118
clusterName: ""
119119
nodeName: ""
120+
# REQUIRED for uploads: the console project name reports are uploaded to.
121+
# There is no default (the hardcoded "postgres-ai-monitoring" was removed).
122+
# If uploads are enabled (an API key is present) but this is empty, the
123+
# reporter cronjob exits with an error at runtime. Chart rendering and the
124+
# no-upload path are unaffected by an empty value.
125+
project: ""
120126
apiUrl: https://postgres.ai/api/general
121127
successfulJobsHistoryLimit: 3
122128
failedJobsHistoryLimit: 3

reporter/postgres_reports.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5129,8 +5129,8 @@ def main():
51295129
help='Output file (default: stdout)')
51305130
parser.add_argument('--api-url', default='https://postgres.ai/api/general')
51315131
parser.add_argument('--token', default='')
5132-
parser.add_argument('--project-name', default='project-name',
5133-
help='Project name for API upload (default: project-name)')
5132+
parser.add_argument('--project-name', default=None,
5133+
help='Project name for API upload (required when uploading)')
51345134
parser.add_argument('--epoch', default='1')
51355135
parser.add_argument('--no-upload', action='store_true', default=False,
51365136
help='Do not upload reports to the API')
@@ -5186,12 +5186,18 @@ def main():
51865186
# Generate all reports for this cluster
51875187
report_id = None
51885188
if not args.no_upload:
5189-
# Use cluster name as project name if not specified
5190-
project_name = args.project_name if args.project_name != 'project-name' else cluster
5191-
report_id = generator.create_report(args.api_url, args.token, project_name, args.epoch)
5192-
# If report creation failed, disable uploads for this cluster
5193-
if report_id is None:
5194-
logger.info(f"Skipping API uploads for cluster {cluster}")
5189+
project_name = args.project_name
5190+
if not project_name:
5191+
# No default (cluster-name fallback removed): a project
5192+
# name is required to upload. Skip the upload for this
5193+
# cluster; report_id stays None so all downstream
5194+
# uploads are disabled too.
5195+
logger.error("project name is required for upload (--project-name); skipping upload")
5196+
else:
5197+
report_id = generator.create_report(args.api_url, args.token, project_name, args.epoch)
5198+
# If report creation failed, disable uploads for this cluster
5199+
if report_id is None:
5200+
logger.info(f"Skipping API uploads for cluster {cluster}")
51955201

51965202
reports = generator.generate_all_reports(cluster, args.node_name, combine_nodes)
51975203

@@ -5310,10 +5316,16 @@ def main():
53105316
json.dump(report, f, indent=2)
53115317
logger.info(f"Report written to {output_filename}")
53125318
if not args.no_upload:
5313-
project_name = args.project_name if args.project_name != 'project-name' else cluster
5314-
report_id = generator.create_report(args.api_url, args.token, project_name, args.epoch)
5315-
if report_id:
5316-
generator.upload_report_file(args.api_url, args.token, report_id, output_filename)
5319+
project_name = args.project_name
5320+
if not project_name:
5321+
# No default (cluster-name fallback removed): a
5322+
# project name is required to upload. Skip the
5323+
# upload for this report.
5324+
logger.error("project name is required for upload (--project-name); skipping upload")
5325+
else:
5326+
report_id = generator.create_report(args.api_url, args.token, project_name, args.epoch)
5327+
if report_id:
5328+
generator.upload_report_file(args.api_url, args.token, report_id, output_filename)
53175329

53185330
# Free memory after processing each cluster
53195331
logger.info(f"Freeing memory after processing cluster {cluster}...")

0 commit comments

Comments
 (0)