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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/commands/ailogic-config-get.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect } from "chai";
import * as sinon from "sinon";

import { command } from "./ailogic-config-get";
import * as ailogic from "../gcp/ailogic";
import * as projectUtils from "../projectUtils";
import { FirebaseError } from "../error";

const PROJECT_ID = "test-project";

describe("ailogic:config:get", () => {
let enabledStub: sinon.SinonStub;
let listProvidersStub: sinon.SinonStub;
let getConfigStub: sinon.SinonStub;

beforeEach(() => {
(command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks
sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID);
enabledStub = sinon.stub(ailogic, "isAILogicApiEnabled").resolves(true);
listProvidersStub = sinon.stub(ailogic, "listProviders").resolves(["gemini-developer-api"]);
getConfigStub = sinon.stub(ailogic, "getConfig").resolves({
name: "config",
trafficFilter: { firebaseAuthRequired: true, templateOnly: false },
telemetryConfig: { mode: "ALL", samplingRate: 0.5 },
});
});

afterEach(() => sinon.restore());

it("returns a structured config with mapped values", async () => {
expect(await command.runner()(undefined, { project: PROJECT_ID })).to.deep.equal({
providers: {
"gemini-developer-api": true,
"gemini-agent-platform-api": false,
},
security: { "auth-only": true, "template-only": false },
monitoring: { state: true, "sample-rate-percentage": 50 },
});
});

it("returns a single value for a valid path", async () => {
expect(await command.runner()("security.auth-only", { project: PROJECT_ID })).to.equal(true);
});

it("returns a nested object for a group path", async () => {
expect(await command.runner()("monitoring", { project: PROJECT_ID })).to.deep.equal({
state: true,
"sample-rate-percentage": 50,
});
});

it("only checks provider enablement when the path needs it", async () => {
await command.runner()("security.auth-only", { project: PROJECT_ID });
expect(listProvidersStub).to.not.have.been.called;

await command.runner()("providers.gemini-developer-api", { project: PROJECT_ID });
expect(listProvidersStub).to.have.been.calledOnce;
});

it("throws on an unknown path before making any API calls", async () => {
await expect(command.runner()("security.authonly", { project: PROJECT_ID })).to.be.rejectedWith(
FirebaseError,
/Unknown configuration path/,
);
expect(enabledStub).to.not.have.been.called;
expect(getConfigStub).to.not.have.been.called;
expect(listProvidersStub).to.not.have.been.called;
});

it("returns early when AI Logic is not enabled", async () => {
enabledStub.resolves(false);
expect(await command.runner()(undefined, { project: PROJECT_ID })).to.be.undefined;
});
});
96 changes: 96 additions & 0 deletions src/commands/ailogic-config-get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Command } from "../command";
import { requirePermissions } from "../requirePermissions";
import { needProjectId } from "../projectUtils";
import * as ailogic from "../gcp/ailogic";
import { logger } from "../logger";

import { Options } from "../options";

// Everything `config:get` can read: the writable paths plus their group prefixes
// and the read-only provider status derived from API enablement.
const READABLE_CONFIG_PATHS = [
"providers",
...ailogic.PROVIDER_TYPES.map((p) => `providers.${p}`),
"security",
...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("security.")),
"monitoring",
...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("monitoring.")),
];

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export const command = new Command("ailogic:config:get [path]")
.description("read AI Logic configuration")
.help(
`prints the full AI Logic configuration for the active project as JSON. If [path] is given, prints only that section or value.

Valid values for [path]:

${READABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n")}

For example, to check whether requests are restricted to authenticated users:

firebase ailogic:config:get security.auth-only`,
)
.before(requirePermissions, ["firebasevertexai.config.get", "serviceusage.services.get"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add detailed help text explaining how to use this command using the .help() method. Would be particularly nice to explain what values are allowed for [path]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added help text listing all the readable paths (built from the path list so it stays in sync) plus an example.

.action(async (path: string | undefined, options: Options) => {
const projectId = needProjectId(options);

// Validate the path up front so bad input fails fast, before any API calls.
if (path) {
ailogic.assertKnownConfigPath(path, READABLE_CONFIG_PATHS);
}

if (!(await ailogic.isAILogicApiEnabled(projectId))) {
logger.info("Firebase AI Logic is not enabled on this project.");
return;
}
const config = await ailogic.getConfig(projectId);

const monitoringState = config.telemetryConfig?.mode === "ALL";
// The API stores samplingRate as a fraction in (0,1]; the CLI displays an
// integer percentage. An unset samplingRate is displayed as 100% (full sampling).
const sampleRatePercent =
config.telemetryConfig?.samplingRate !== undefined
? Math.round(config.telemetryConfig.samplingRate * 100)
: 100;

// Provider status needs extra Service Usage checks, so fetch it only when the
// requested path is under `providers` (or the whole config was requested).
const needsProviders = !path || path === "providers" || path.startsWith("providers.");
const enabledProviders = needsProviders ? await ailogic.listProviders(projectId) : [];

const configObj = {
...(needsProviders && {
providers: Object.fromEntries(
ailogic.PROVIDER_TYPES.map((p) => [p, enabledProviders.includes(p)]),
),
}),
security: {
"auth-only": config.trafficFilter?.firebaseAuthRequired ?? false,
"template-only": config.trafficFilter?.templateOnly ?? false,
},
monitoring: {
state: monitoringState,
"sample-rate-percentage": sampleRatePercent,
},
};

if (!path) {
logger.info(JSON.stringify(configObj, null, 2));
return configObj;
}

let val: unknown = configObj;
for (const part of path.split(".")) {
if (!isRecord(val)) {
val = undefined;
break;
}
val = val[part];
}
logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val));
return val;
});
167 changes: 167 additions & 0 deletions src/commands/ailogic-config-set.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { expect } from "chai";
import * as sinon from "sinon";

import { command } from "./ailogic-config-set";
import * as ailogic from "../gcp/ailogic";
import * as projectUtils from "../projectUtils";
import * as prompt from "../prompt";
import * as utils from "../utils";
import { FirebaseError } from "../error";

const PROJECT_ID = "test-project";

describe("ailogic:config:set", () => {
let updateStub: sinon.SinonStub;
let getConfigStub: sinon.SinonStub;
let confirmStub: sinon.SinonStub;
let ensureStub: sinon.SinonStub;

beforeEach(() => {
(command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks
sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID);
ensureStub = sinon.stub(ailogic, "ensureAILogicApiEnabled").resolves();
sinon.stub(utils, "logSuccess");
getConfigStub = sinon.stub(ailogic, "getConfig").resolves({ name: "config" });
updateStub = sinon.stub(ailogic, "updateConfig").resolves({ name: "config" });
confirmStub = sinon.stub(prompt, "confirm").resolves(true);
});

afterEach(() => sinon.restore());

it("throws on an unknown path listing the writable paths", async () => {
await expect(
command.runner()("security.authonly", "true", { project: PROJECT_ID }),
).to.be.rejectedWith(FirebaseError, /Unknown configuration path/);
});

it("rejects a non-boolean value for a security path", async () => {
await expect(
command.runner()("security.auth-only", "yes", { project: PROJECT_ID }),
).to.be.rejectedWith(FirebaseError, /must be 'true' or 'false'/);
});

it("validates input before triggering the API-enablement flow (fail-fast)", async () => {
await expect(
command.runner()("monitoring.sample-rate-percentage", "500", { project: PROJECT_ID }),
).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/);
expect(ensureStub).to.not.have.been.called;
});

it("prompts when tightening auth-only from false to true, then updates", async () => {
getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } });
await command.runner()("security.auth-only", "true", {
project: PROJECT_ID,
interactive: true,
});
expect(confirmStub).to.have.been.calledOnce;
expect(updateStub).to.have.been.calledWith(
PROJECT_ID,
{ trafficFilter: { firebaseAuthRequired: true } },
["trafficFilter.firebaseAuthRequired"],
);
});

it("does not prompt when auth-only is already true", async () => {
getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: true } });
await command.runner()("security.auth-only", "true", {
project: PROJECT_ID,
interactive: true,
});
expect(confirmStub).to.not.have.been.called;
expect(updateStub).to.have.been.calledOnce;
});

it("does not prompt when relaxing auth-only to false", async () => {
await command.runner()("security.auth-only", "false", { project: PROJECT_ID });
expect(confirmStub).to.not.have.been.called;
expect(updateStub).to.have.been.calledWith(
PROJECT_ID,
{ trafficFilter: { firebaseAuthRequired: false } },
["trafficFilter.firebaseAuthRequired"],
);
});

it("propagates confirm() aborting in non-interactive mode without --force", async () => {
// confirm() throws in non-interactive mode when no --force is given; the command
// must surface that and not proceed to write.
getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } });
confirmStub.rejects(new FirebaseError("cannot be answered in non-interactive mode"));
await expect(
command.runner()("security.auth-only", "true", { project: PROJECT_ID, nonInteractive: true }),
).to.be.rejectedWith(FirebaseError, /non-interactive/);
expect(updateStub).to.not.have.been.called;
});

it("prompts when tightening template-only from false to true, then updates", async () => {
getConfigStub.resolves({ name: "config", trafficFilter: { templateOnly: false } });
await command.runner()("security.template-only", "true", {
project: PROJECT_ID,
interactive: true,
});
expect(confirmStub).to.have.been.calledOnce;
expect(updateStub).to.have.been.calledWith(
PROJECT_ID,
{ trafficFilter: { templateOnly: true } },
["trafficFilter.templateOnly"],
);
});

it("accepts a case-insensitive boolean value", async () => {
await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID });
expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [
"telemetryConfig.mode",
]);
});

it("maps monitoring.state true to telemetryConfig.mode ALL", async () => {
await command.runner()("monitoring.state", "true", { project: PROJECT_ID });
expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [
"telemetryConfig.mode",
]);
});

it("maps monitoring.state false to telemetryConfig.mode NONE without prompting", async () => {
await command.runner()("monitoring.state", "false", { project: PROJECT_ID });
expect(confirmStub).to.not.have.been.called;
expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "NONE" } }, [
"telemetryConfig.mode",
]);
});

it("maps a sample-rate percentage to a (0,1] sampling fraction", async () => {
await command.runner()("monitoring.sample-rate-percentage", "50", { project: PROJECT_ID });
expect(updateStub).to.have.been.calledWith(
PROJECT_ID,
{ telemetryConfig: { samplingRate: 0.5 } },
["telemetryConfig.samplingRate"],
);
});

it("rejects an out-of-range or non-integer sample rate", async () => {
// "1e2", "0x32", and " 50 " all coerce to valid integers via Number(), so the
// strict decimal check must reject them too.
for (const bad of ["0", "101", "1.5", "abc", "1e2", "0x32", " 50 ", "50%"]) {
await expect(
command.runner()("monitoring.sample-rate-percentage", bad, { project: PROJECT_ID }),
).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/);
}
expect(updateStub).to.not.have.been.called;
});

it("normalizes a zero-padded sample rate in the echoed value", async () => {
expect(
await command.runner()("monitoring.sample-rate-percentage", "007", { project: PROJECT_ID }),
).to.deep.equal({ path: "monitoring.sample-rate-percentage", value: "7" });
expect(updateStub).to.have.been.calledWith(
PROJECT_ID,
{ telemetryConfig: { samplingRate: 0.07 } },
["telemetryConfig.samplingRate"],
);
});

it("returns the normalized value for --json output", async () => {
expect(
await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID }),
).to.deep.equal({ path: "monitoring.state", value: "true" });
});
});
Loading
Loading