jui-tools-mcp-server 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/tools/infra/infra_cost.js +65 -2
- package/dist/tools/infra/infra_validate.d.ts +0 -5
- package/dist/tools/infra/infra_validate.js +41 -4
- package/package.json +1 -1
- package/scripts/record-infra-fixtures.sh +65 -0
- package/src/tools/infra/infra_cost.ts +57 -2
- package/src/tools/infra/infra_validate.ts +39 -4
- package/tests/fixtures/infra/RECORDED_FROM +1 -1
- package/tests/fixtures/infra/answers-without-plan.json +9 -0
- package/tests/fixtures/infra/check-infra.json +8 -6
- package/tests/fixtures/infra/cost-infra.day.json +2 -2
- package/tests/fixtures/infra/cost-infra.month-by-node.json +10 -2
- package/tests/fixtures/infra/cost-infra.month.json +21 -4
- package/tests/fixtures/infra/cost-infra.plan-bad.json +16 -0
- package/tests/fixtures/infra/cost-infra.plan-move-account.json +167 -0
- package/tests/fixtures/infra/cost-infra.plan-move.json +174 -0
- package/tests/fixtures/infra/cost-infra.plan-none.json +16 -0
- package/tests/fixtures/infra/cost-infra.plan-shrink.json +162 -0
- package/tests/fixtures/infra/validate-infra-load.errors.json +2 -1
- package/tests/fixtures/infra/validate-infra-load.unreadable.json +1 -0
- package/tests/fixtures/infra/validate-infra.errors.json +6 -2
- package/tests/fixtures/infra/validate-infra.ok.json +1 -1
- package/tests/fixtures/infra/validate-infra.plan-both.json +504 -0
- package/tests/fixtures/infra/validate-infra.plan-duplicate.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-edge-end.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-half.json +49 -0
- package/tests/fixtures/infra/validate-infra.plan-move.json +506 -0
- package/tests/fixtures/infra/validate-infra.plan-none.json +49 -0
- package/tests/fixtures/infra/validate-infra.plan-order-unknown.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-remove-missing.json +539 -0
- package/tests/fixtures/infra/validate-infra.plans.json +506 -0
- package/tests/tools_infra.test.ts +208 -0
package/README.md
CHANGED
|
@@ -146,12 +146,12 @@ Claude Code のセッションを見るローカルのビューア(`jui viz`
|
|
|
146
146
|
|
|
147
147
|
| Tool | Description | CLI |
|
|
148
148
|
|------|-------------|-----|
|
|
149
|
-
| `infra_validate` | モデルを読めるか(読めない時は理由)、使ったファイル・数・flow の数、problems を重さとコードで数え、最初の 20
|
|
149
|
+
| `infra_validate` | モデルを読めるか(読めない時は理由)、使ったファイル・数・flow の数、problems を重さとコードで数え、最初の 20 件。移管計画(`docs/infra/migrations/<名前>`)がある部屋では、計画ごとの problems(`migration.*` と、新の構成に今の検査を当てた分)・数・ステップの順も(`plan` で 1 つだけ、2.3.20 以降) | `jsonui-doc validate infra <project> --json [--migration <名前>]` |
|
|
150
150
|
| `infra_load_validate` | 負荷のシナリオ(id・環境・月・時間・行・edge)と problems(node・hour・quantity つき) | `jsonui-doc validate infra-load <project> --json` |
|
|
151
151
|
| `infra_prices_validate` | 部屋の単価のファイル・単価の数・problems | `jsonui-doc validate infra-prices <project> --json` |
|
|
152
152
|
| `infra_seed` | IaC・設定ファイル・コードから下書き。数・node・読んだ物・置けなかった物(unmatched)・skipped・notes。既定では一時フォルダに書いて読み戻し、プロジェクトは変えない(`write: true` で `<infra.dir>/seed`) | `jsonui-doc seed infra <project> -o <dir> [--force]` |
|
|
153
153
|
| `infra_check` | 宣言した `builtin:infra-iac` / `infra-code` / `infra-load` の結果(status・数・ok でない結果の最初の 20 件・レポートの道) | `jsonui-doc check infra -p <project> --json` |
|
|
154
|
-
| `infra_cost` | 月の合計・provider(か node)ごとの合計・上位 node・一番高い日・単価なし/規則なし/負荷なし・容量を超えた時間・注意。`date` で 1 日(上位 node の meter ごとの額と単価の層、ピークの時刻)。負荷の problems
|
|
154
|
+
| `infra_cost` | 月の合計・provider(か node)ごとの合計・上位 node・一番高い日・単価なし/規則なし/負荷なし・容量を超えた時間・注意。`date` で 1 日(上位 node の meter ごとの額と単価の層、ピークの時刻)。負荷の problems と単価の警告も。`plan` で移管計画の月の旧・新・差(node ごとの増減の大きい順・provider ごと・ステップごと。ステップの差はそのステップでの月額全体の変化で、動いたノード・線は moved。旧か新で値が付かない変えたノードは unpricedChanged、`account: true` でアカウントの月(その環境をまとめて値付け)も、2.3.20 以降) | `jsonui-doc cost infra <project> --json [--scenario] [--date] [--by] [--migration <名前> [--account]]` |
|
|
155
155
|
|
|
156
156
|
> 単価の取得(`jui viz cloud-prices fetch`)はネットへ出て viz のホームに書くので **MCP 非公開**(CLI で行う)。jsonui-doc が古くて `--json` の形が違うと、どのキーが無いかを名指しして失敗する。
|
|
157
157
|
|
|
@@ -8,7 +8,54 @@ import { FIRST, failure, need, problems, runDocJson, text } from "./infra_common
|
|
|
8
8
|
* hours over capacity, the notes, the load problems and the price-book warnings — and for a day, each top node's
|
|
9
9
|
* charge by meter with the price layer it came from. Exit 0: simulated; 1: simulated, a load file has an error (left
|
|
10
10
|
* out); 2: nothing simulated (reason).
|
|
11
|
+
*
|
|
12
|
+
* With `plan` (jsonui-cli 2.3.20+, `--migration <name>`): the same engine run on the old model and on the new one (old
|
|
13
|
+
* + changes − removals), and the difference — the totals, per node (what the plan added, replaced, removed or left),
|
|
14
|
+
* per provider, after each step of the plan, and with `account` the account's month (its environments priced
|
|
15
|
+
* together). Nothing is computed here.
|
|
16
|
+
* A plan with an error is not priced: the CLI refuses it (exit 2, reason bad_plan, the plan's problems), as it does a
|
|
17
|
+
* plan name with no folder (no_plan) — both answered by the refusal path below, problems counted.
|
|
11
18
|
*/
|
|
19
|
+
function migrationSummary(data) {
|
|
20
|
+
need(data, ["migration", "scenario", "old", "new", "diff", "byNode", "byProvider", "steps", "account", "unpricedChanged"], "cost infra --migration");
|
|
21
|
+
for (const k of ["old", "new", "diff"])
|
|
22
|
+
need(data[k], ["usd"], `cost infra --migration ${k}`);
|
|
23
|
+
for (const k of ["old", "new"])
|
|
24
|
+
need(data[k], ["noPrice", "noRule", "loadUndefined"], `cost infra --migration ${k}`);
|
|
25
|
+
for (const n of data.byNode)
|
|
26
|
+
need(n, ["node", "change", "unpriced", "old", "new", "diff"], "cost infra --migration node");
|
|
27
|
+
for (const s of data.steps) {
|
|
28
|
+
need(s, ["index", "node", "usd", "diff", "moved"], "cost infra --migration step");
|
|
29
|
+
for (const m of s.moved)
|
|
30
|
+
need(m, ["node", "diff"], "cost infra --migration step moved");
|
|
31
|
+
}
|
|
32
|
+
if (data.account !== null)
|
|
33
|
+
need(data.account, ["on", "old", "new", "diff"], "cost infra --migration account");
|
|
34
|
+
const changed = data.byNode.filter((n) => n.change !== "same" || n.diff !== 0);
|
|
35
|
+
const byChange = {};
|
|
36
|
+
for (const n of data.byNode)
|
|
37
|
+
byChange[n.change] = (byChange[n.change] ?? 0) + 1;
|
|
38
|
+
return {
|
|
39
|
+
migration: data.migration,
|
|
40
|
+
scenario: data.scenario,
|
|
41
|
+
usd: { old: data.old.usd, new: data.new.usd, diff: data.diff.usd },
|
|
42
|
+
// what either model cannot price (no price, no rule, no load): its part is 0 there, so not in the difference
|
|
43
|
+
oldUnpriced: { noPrice: data.old.noPrice, noRule: data.old.noRule, loadUndefined: data.old.loadUndefined },
|
|
44
|
+
newUnpriced: { noPrice: data.new.noPrice, noRule: data.new.noRule, loadUndefined: data.new.loadUndefined },
|
|
45
|
+
// the nodes the plan changes whose month is not all priced: the difference leaves their part out — not a saving
|
|
46
|
+
unpricedChanged: data.unpricedChanged,
|
|
47
|
+
nodes: byChange,
|
|
48
|
+
// the biggest movements first, whichever way
|
|
49
|
+
topChanges: [...changed].sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)).slice(0, FIRST),
|
|
50
|
+
changedCount: changed.length,
|
|
51
|
+
byProvider: data.byProvider,
|
|
52
|
+
// a step's diff is the whole month's change at that step, not its node's own: a neighbour can move with it (a
|
|
53
|
+
// shared free amount); moved names what moved, biggest first (a node, or "a>b" for a line's transfer)
|
|
54
|
+
steps: data.steps.map((s) => ({ index: s.index, node: s.node, usd: s.usd, diff: s.diff,
|
|
55
|
+
moved: s.moved.slice(0, FIRST), movedCount: s.moved.length })),
|
|
56
|
+
account: data.account,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
12
59
|
function monthSummary(m) {
|
|
13
60
|
need(m, ["scenario", "month", "usd", "by", "keys", "days", "noPrice", "noRule", "loadUndefined", "overflowHours",
|
|
14
61
|
"warnings", "warningCount"], "cost infra month");
|
|
@@ -65,15 +112,25 @@ function daySummary(day) {
|
|
|
65
112
|
};
|
|
66
113
|
}
|
|
67
114
|
export function register(server, config) {
|
|
68
|
-
server.tool("infra_cost", "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). Needs jsonui-cli 2.3.15
|
|
115
|
+
server.tool("infra_cost", "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). With plan (a migration plan's name, docs/infra/migrations/<name>): the month's old, new and difference — per node (added / replaced / removed / same, the biggest changes first), per provider, after each step of the plan (a step's difference is the whole month's change at that step, not its node's own: moved names each node or line's transfer that moved), what is not priced in the old or the new (unpricedChanged: changed nodes whose part the difference leaves out — not a saving), and with account the account's month too (its environments priced together); date and by do not apply. Needs jsonui-cli 2.3.15+ (plan: 2.3.20+).", {
|
|
69
116
|
project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
|
|
70
117
|
scenario: z.string().optional().describe("Load scenario id (default: the first)"),
|
|
71
118
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One day of the scenario's month (YYYY-MM-DD)"),
|
|
72
119
|
by: z.enum(["provider", "node"]).optional().describe("Group the month's totals by provider (default) or node"),
|
|
73
|
-
|
|
120
|
+
plan: z.string().optional().describe("A migration plan's name: the month's old / new / difference instead"),
|
|
121
|
+
account: z.boolean().optional().describe("With plan: the account's month too — its environments priced together, old / new / difference"),
|
|
122
|
+
}, async ({ project_dir, scenario, date, by, plan, account }) => {
|
|
74
123
|
try {
|
|
124
|
+
if (!plan && account)
|
|
125
|
+
return failure(new Error("account applies with plan only"));
|
|
126
|
+
if (plan && (date || by))
|
|
127
|
+
return failure(new Error("date and by do not apply with plan: it answers by node and by provider at once, for the month"));
|
|
75
128
|
const projectDir = config.resolveProjectDir(project_dir);
|
|
76
129
|
const args = ["cost", "infra", projectDir, "--json"];
|
|
130
|
+
if (plan)
|
|
131
|
+
args.push("--migration", plan);
|
|
132
|
+
if (account)
|
|
133
|
+
args.push("--account");
|
|
77
134
|
if (scenario)
|
|
78
135
|
args.push("--scenario", scenario);
|
|
79
136
|
if (date)
|
|
@@ -90,6 +147,12 @@ export function register(server, config) {
|
|
|
90
147
|
need(data, ["dir", "scenario", "scenarios", "problems", "prices"], "cost infra");
|
|
91
148
|
need(data.problems, ["count", "errors", "first"], "cost infra problems");
|
|
92
149
|
need(data.prices, ["warnings", "warningCount"], "cost infra prices");
|
|
150
|
+
if (plan) {
|
|
151
|
+
// the load problems and price warnings are the old model's reading, as without plan
|
|
152
|
+
return text({ ok: exitCode === 0, exit: exitCode, dir: data.dir, scenarios: data.scenarios,
|
|
153
|
+
...migrationSummary(data), loadProblems: data.problems, priceWarnings: data.prices.warnings,
|
|
154
|
+
priceWarningCount: data.prices.warningCount });
|
|
155
|
+
}
|
|
93
156
|
const out = {
|
|
94
157
|
ok: exitCode === 0,
|
|
95
158
|
exit: exitCode,
|
|
@@ -1,8 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ServerConfig } from "../../config.js";
|
|
3
|
-
/**
|
|
4
|
-
* `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
|
|
5
|
-
* read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
|
|
6
|
-
* walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
|
|
7
|
-
*/
|
|
8
3
|
export declare function register(server: McpServer, config: ServerConfig): void;
|
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { failure, need, problems, runDocJson, text } from "./infra_common.js";
|
|
2
|
+
import { ContractError, FIRST, failure, need, problems, runDocJson, text } from "./infra_common.js";
|
|
3
3
|
/**
|
|
4
4
|
* `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
|
|
5
5
|
* read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
|
|
6
6
|
* walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
|
|
7
|
+
*
|
|
8
|
+
* Migration plans (jsonui-cli 2.3.20+, docs/infra/migrations/<name>/changes.json + removals.json): the CLI checks every
|
|
9
|
+
* plan with the model, or one with `--migration <name>`, and lists them under "migrations". A plan's error makes the
|
|
10
|
+
* exit 1 while the model's own problems are empty, so a room that has plans gets their summary here — without it the
|
|
11
|
+
* exit would name an error the answer does not show. A room with none answers exactly as before.
|
|
7
12
|
*/
|
|
13
|
+
function migrationSummary(m) {
|
|
14
|
+
need(m, ["name", "dir", "ok", "problems", "steps", "counts"], "validate infra migration");
|
|
15
|
+
return {
|
|
16
|
+
name: m.name,
|
|
17
|
+
dir: m.dir,
|
|
18
|
+
ok: m.ok,
|
|
19
|
+
counts: m.counts,
|
|
20
|
+
problems: problems(m.problems, "validate infra migration"),
|
|
21
|
+
steps: m.steps.length,
|
|
22
|
+
firstSteps: m.steps.slice(0, FIRST).map((s) => {
|
|
23
|
+
need(s, ["index", "kind", "node"], "validate infra migration step");
|
|
24
|
+
return { index: s.index, kind: s.kind, node: s.node,
|
|
25
|
+
...(s.edgesRemoved?.length ? { edgesRemoved: s.edgesRemoved } : {}),
|
|
26
|
+
...(s.edgesAdded?.length ? { edgesAdded: s.edgesAdded } : {}) };
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function migrations(data, plan) {
|
|
31
|
+
if (plan)
|
|
32
|
+
need(data, ["migrations"], "validate infra --migration");
|
|
33
|
+
const list = data.migrations;
|
|
34
|
+
if (list === undefined)
|
|
35
|
+
return {}; // a room with no plan: the CLI prints no key, and nor does this
|
|
36
|
+
if (!Array.isArray(list))
|
|
37
|
+
throw new ContractError('validate infra: "migrations" is not a list');
|
|
38
|
+
return { migrations: list.map(migrationSummary) };
|
|
39
|
+
}
|
|
8
40
|
export function register(server, config) {
|
|
9
|
-
server.tool("infra_validate", "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. Needs jsonui-cli 2.3.15
|
|
41
|
+
server.tool("infra_validate", "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. When the room has migration plans (docs/infra/migrations/<name>/changes.json + removals.json), each plan's check too: its problems (migration.remove_missing / duplicate / both / order_unknown / edge_end, and the infra checks on the merged new model), its counts (added, replaced, removed, edges added / removed) and its steps in order; `plan` checks that one plan only. Needs jsonui-cli 2.3.15+ (plans: 2.3.20+).", {
|
|
10
42
|
project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
|
|
11
|
-
|
|
43
|
+
plan: z.string().optional().describe("One migration plan's name (its folder under docs/infra/migrations); default: every plan"),
|
|
44
|
+
}, async ({ project_dir, plan }) => {
|
|
12
45
|
try {
|
|
13
46
|
const projectDir = config.resolveProjectDir(project_dir);
|
|
14
|
-
const
|
|
47
|
+
const args = ["validate", "infra", projectDir, "--json"];
|
|
48
|
+
if (plan)
|
|
49
|
+
args.push("--migration", plan);
|
|
50
|
+
const { exitCode, data } = await runDocJson(args, projectDir);
|
|
15
51
|
need(data, ["ok"], "validate infra");
|
|
16
52
|
if (!data.ok && data.reason !== "invalid") {
|
|
17
53
|
need(data, ["reason", "message"], "validate infra");
|
|
@@ -28,6 +64,7 @@ export function register(server, config) {
|
|
|
28
64
|
counts: data.counts ?? null,
|
|
29
65
|
flows: Array.isArray(data.flows) ? data.flows.length : null,
|
|
30
66
|
problems: problems(data.problems, "validate infra"),
|
|
67
|
+
...migrations(data, plan),
|
|
31
68
|
});
|
|
32
69
|
}
|
|
33
70
|
catch (e) {
|
package/package.json
CHANGED
|
@@ -65,6 +65,71 @@ doc "${WORK}/room" cost infra --date 2026-11-03 --json > "${OUT}/cost-infra.day.
|
|
|
65
65
|
doc "${WORK}/room" cost infra --scenario nope --json > "${OUT}/cost-infra.refused.json"
|
|
66
66
|
doc "${WORK}/room" check infra --json 2>/dev/null > "${OUT}/check-infra.json"
|
|
67
67
|
|
|
68
|
+
# Migration plans (jsonui-cli 2.3.20+), in a copy of their own so the room above stays a room without plans: one plan
|
|
69
|
+
# that holds (replace db-prod, add worker-prod and its edge, remove api-stg with its edges, in a written order), and one
|
|
70
|
+
# plan per migration.* code with exactly that error.
|
|
71
|
+
plan() { # plan <room> <name> <changes json> <removals json>
|
|
72
|
+
mkdir -p "$1/docs/infra/migrations/$2"
|
|
73
|
+
printf '%s' "$3" > "$1/docs/infra/migrations/$2/changes.json"
|
|
74
|
+
printf '%s' "$4" > "$1/docs/infra/migrations/$2/removals.json"
|
|
75
|
+
}
|
|
76
|
+
cp -R "${FIX}/aws-three-tier" "${WORK}/plans"
|
|
77
|
+
DB='{"id": "db-prod", "category": "database.relational", "service": "rds.postgres", "group": "private-prod", "ha": "multi-az", "environments": ["production"], "spec": [{"key": "instance_class", "value": "db.r6g.large"}, {"key": "storage_gb", "value": "200"}]}'
|
|
78
|
+
WORKER='{"id": "worker-prod", "category": "compute.container", "service": "ecs.fargate", "group": "private-prod", "count": 1, "environments": ["production"], "spec": [{"key": "cpu", "value": "512"}, {"key": "memory", "value": "1024"}, {"key": "tasks", "value": "1"}]}'
|
|
79
|
+
NONE='{"version": 0, "nodes": [], "groups": [], "edges": []}'
|
|
80
|
+
plan "${WORK}/plans" move "{\"version\": 0, \"order\": [\"db-prod\", \"worker-prod\", \"api-stg\"], \"nodes\": [${DB}, ${WORKER}], \"edges\": [{\"from\": \"api-prod\", \"to\": \"worker-prod\", \"protocol\": \"https\", \"port\": 443}]}" \
|
|
81
|
+
'{"version": 0, "nodes": ["api-stg"], "groups": [], "edges": []}'
|
|
82
|
+
# the account's month needs a load in two environments: staging gets one here (only in this copy)
|
|
83
|
+
python3 - "${WORK}/plans/docs/infra/load" <<'EOF'
|
|
84
|
+
import json, sys
|
|
85
|
+
d = json.load(open(f"{sys.argv[1]}/ordinary.json", encoding="utf-8"))
|
|
86
|
+
d.update(id="staging", label="staging", environment="staging",
|
|
87
|
+
loads=[{"node": "alb-stg", "quantities": {"lcu": 1}}, {"node": "api-stg", "quantities": {"vcpu": 0.25, "memory_gb": 0.5}},
|
|
88
|
+
{"node": "db-stg", "sku": "db.t4g.medium", "quantities": {"storage_gb": 20}}], edges=[])
|
|
89
|
+
json.dump(d, open(f"{sys.argv[1]}/staging.json", "w", encoding="utf-8"), ensure_ascii=False)
|
|
90
|
+
EOF
|
|
91
|
+
cp -R "${FIX}/aws-three-tier" "${WORK}/badplans"
|
|
92
|
+
plan "${WORK}/badplans" remove-missing "{\"version\": 0, \"nodes\": [${WORKER}]}" '{"version": 0, "nodes": ["nope"], "groups": [], "edges": []}'
|
|
93
|
+
plan "${WORK}/badplans" duplicate "{\"version\": 0, \"nodes\": [${WORKER}, ${WORKER}]}" "${NONE}"
|
|
94
|
+
plan "${WORK}/badplans" both "{\"version\": 0, \"nodes\": [${DB}]}" '{"version": 0, "nodes": ["db-prod"], "groups": [], "edges": []}'
|
|
95
|
+
plan "${WORK}/badplans" order-unknown "{\"version\": 0, \"order\": [\"ghost\"], \"nodes\": [${WORKER}]}" "${NONE}"
|
|
96
|
+
plan "${WORK}/badplans" edge-end "{\"version\": 0, \"nodes\": [${WORKER}], \"edges\": [{\"from\": \"worker-prod\", \"to\": \"ghost\", \"protocol\": \"https\", \"port\": 443}]}" "${NONE}"
|
|
97
|
+
doc "${WORK}/plans" validate infra --json > "${OUT}/validate-infra.plans.json"
|
|
98
|
+
doc "${WORK}/plans" validate infra --migration move --json > "${OUT}/validate-infra.plan-move.json"
|
|
99
|
+
doc "${WORK}/plans" validate infra --migration nope --json > "${OUT}/validate-infra.plan-none.json"
|
|
100
|
+
for code in remove-missing duplicate both order-unknown edge-end; do
|
|
101
|
+
doc "${WORK}/badplans" validate infra --migration "${code}" --json > "${OUT}/validate-infra.plan-${code}.json"
|
|
102
|
+
done
|
|
103
|
+
doc "${WORK}/plans" cost infra --migration move --json > "${OUT}/cost-infra.plan-move.json"
|
|
104
|
+
doc "${WORK}/plans" cost infra --migration move --account --scenario sale --json > "${OUT}/cost-infra.plan-move-account.json"
|
|
105
|
+
doc "${WORK}/badplans" cost infra --migration both --json > "${OUT}/cost-infra.plan-bad.json"
|
|
106
|
+
doc "${WORK}/plans" cost infra --migration nope --json > "${OUT}/cost-infra.plan-none.json"
|
|
107
|
+
|
|
108
|
+
# A plan whose biggest change is a fall, and an unchanged node that moves with it: batch-prod (a node of this copy
|
|
109
|
+
# only) comes after api-prod and shares the account's 100 GB internet egress with it (the free amount goes to the
|
|
110
|
+
# first nodes); the plan shrinks the database and removes api-prod, so batch-prod takes more of the free amount.
|
|
111
|
+
cp -R "${FIX}/aws-three-tier" "${WORK}/shrink"
|
|
112
|
+
python3 - "${WORK}/shrink/docs/infra" <<'EOF'
|
|
113
|
+
import json, sys
|
|
114
|
+
infra = sys.argv[1]
|
|
115
|
+
m = json.load(open(f"{infra}/main.json", encoding="utf-8"))
|
|
116
|
+
m["nodes"].append({"id": "batch-prod", "category": "compute.container", "service": "ecs.fargate", "group": "private-prod", "count": 1,
|
|
117
|
+
"environments": ["production"], "spec": [{"key": "cpu", "value": "256"}, {"key": "memory", "value": "512"}, {"key": "tasks", "value": "1"}]})
|
|
118
|
+
json.dump(m, open(f"{infra}/main.json", "w", encoding="utf-8"), ensure_ascii=False)
|
|
119
|
+
d = json.load(open(f"{infra}/load/ordinary.json", encoding="utf-8"))
|
|
120
|
+
for row in d["loads"]:
|
|
121
|
+
if row.get("node") == "api-prod":
|
|
122
|
+
row["quantities"]["egress_gb"] = {"amount": 2.7, "per": "day"}
|
|
123
|
+
d["loads"].append({"node": "batch-prod", "quantities": {"vcpu": 0.25, "memory_gb": 0.5, "egress_gb": {"amount": 2.7, "per": "day"}}})
|
|
124
|
+
json.dump(d, open(f"{infra}/load/ordinary.json", "w", encoding="utf-8"), ensure_ascii=False)
|
|
125
|
+
EOF
|
|
126
|
+
SMALL='{"id": "db-prod", "category": "database.relational", "service": "rds.mysql", "group": "private-prod", "environments": ["production"], "spec": [{"key": "instance_class", "value": "db.t4g.medium"}, {"key": "storage_gb", "value": "200"}]}'
|
|
127
|
+
plan "${WORK}/shrink" shrink "{\"version\": 0, \"nodes\": [${SMALL}]}" '{"version": 0, "nodes": ["api-prod"], "groups": [], "edges": []}'
|
|
128
|
+
doc "${WORK}/shrink" cost infra --migration shrink --json > "${OUT}/cost-infra.plan-shrink.json"
|
|
129
|
+
# a plan folder with nothing in it yet: listed, not ok, its read problem said (an empty migrations folder lists none)
|
|
130
|
+
mkdir -p "${WORK}/badplans/docs/infra/migrations/half"
|
|
131
|
+
doc "${WORK}/badplans" validate infra --migration half --json > "${OUT}/validate-infra.plan-half.json"
|
|
132
|
+
|
|
68
133
|
# The temp folder's path is not part of the contract
|
|
69
134
|
for f in "${OUT}"/*.json; do
|
|
70
135
|
python3 - "$f" "${WORK}" <<'EOF'
|
|
@@ -11,8 +11,51 @@ import { FIRST, failure, need, problems, runDocJson, text } from "./infra_common
|
|
|
11
11
|
* hours over capacity, the notes, the load problems and the price-book warnings — and for a day, each top node's
|
|
12
12
|
* charge by meter with the price layer it came from. Exit 0: simulated; 1: simulated, a load file has an error (left
|
|
13
13
|
* out); 2: nothing simulated (reason).
|
|
14
|
+
*
|
|
15
|
+
* With `plan` (jsonui-cli 2.3.20+, `--migration <name>`): the same engine run on the old model and on the new one (old
|
|
16
|
+
* + changes − removals), and the difference — the totals, per node (what the plan added, replaced, removed or left),
|
|
17
|
+
* per provider, after each step of the plan, and with `account` the account's month (its environments priced
|
|
18
|
+
* together). Nothing is computed here.
|
|
19
|
+
* A plan with an error is not priced: the CLI refuses it (exit 2, reason bad_plan, the plan's problems), as it does a
|
|
20
|
+
* plan name with no folder (no_plan) — both answered by the refusal path below, problems counted.
|
|
14
21
|
*/
|
|
15
22
|
|
|
23
|
+
function migrationSummary(data: any) {
|
|
24
|
+
need(data, ["migration", "scenario", "old", "new", "diff", "byNode", "byProvider", "steps", "account", "unpricedChanged"],
|
|
25
|
+
"cost infra --migration");
|
|
26
|
+
for (const k of ["old", "new", "diff"]) need(data[k], ["usd"], `cost infra --migration ${k}`);
|
|
27
|
+
for (const k of ["old", "new"]) need(data[k], ["noPrice", "noRule", "loadUndefined"], `cost infra --migration ${k}`);
|
|
28
|
+
for (const n of data.byNode) need(n, ["node", "change", "unpriced", "old", "new", "diff"], "cost infra --migration node");
|
|
29
|
+
for (const s of data.steps) {
|
|
30
|
+
need(s, ["index", "node", "usd", "diff", "moved"], "cost infra --migration step");
|
|
31
|
+
for (const m of s.moved) need(m, ["node", "diff"], "cost infra --migration step moved");
|
|
32
|
+
}
|
|
33
|
+
if (data.account !== null) need(data.account, ["on", "old", "new", "diff"], "cost infra --migration account");
|
|
34
|
+
const changed = data.byNode.filter((n: any) => n.change !== "same" || n.diff !== 0);
|
|
35
|
+
const byChange: Record<string, number> = {};
|
|
36
|
+
for (const n of data.byNode) byChange[n.change] = (byChange[n.change] ?? 0) + 1;
|
|
37
|
+
return {
|
|
38
|
+
migration: data.migration,
|
|
39
|
+
scenario: data.scenario,
|
|
40
|
+
usd: { old: data.old.usd, new: data.new.usd, diff: data.diff.usd },
|
|
41
|
+
// what either model cannot price (no price, no rule, no load): its part is 0 there, so not in the difference
|
|
42
|
+
oldUnpriced: { noPrice: data.old.noPrice, noRule: data.old.noRule, loadUndefined: data.old.loadUndefined },
|
|
43
|
+
newUnpriced: { noPrice: data.new.noPrice, noRule: data.new.noRule, loadUndefined: data.new.loadUndefined },
|
|
44
|
+
// the nodes the plan changes whose month is not all priced: the difference leaves their part out — not a saving
|
|
45
|
+
unpricedChanged: data.unpricedChanged,
|
|
46
|
+
nodes: byChange,
|
|
47
|
+
// the biggest movements first, whichever way
|
|
48
|
+
topChanges: [...changed].sort((a: any, b: any) => Math.abs(b.diff) - Math.abs(a.diff)).slice(0, FIRST),
|
|
49
|
+
changedCount: changed.length,
|
|
50
|
+
byProvider: data.byProvider,
|
|
51
|
+
// a step's diff is the whole month's change at that step, not its node's own: a neighbour can move with it (a
|
|
52
|
+
// shared free amount); moved names what moved, biggest first (a node, or "a>b" for a line's transfer)
|
|
53
|
+
steps: data.steps.map((s: any) => ({ index: s.index, node: s.node, usd: s.usd, diff: s.diff,
|
|
54
|
+
moved: s.moved.slice(0, FIRST), movedCount: s.moved.length })),
|
|
55
|
+
account: data.account,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
16
59
|
function monthSummary(m: any) {
|
|
17
60
|
need(m, ["scenario", "month", "usd", "by", "keys", "days", "noPrice", "noRule", "loadUndefined", "overflowHours",
|
|
18
61
|
"warnings", "warningCount"], "cost infra month");
|
|
@@ -71,17 +114,23 @@ function daySummary(day: any) {
|
|
|
71
114
|
export function register(server: McpServer, config: ServerConfig) {
|
|
72
115
|
server.tool(
|
|
73
116
|
"infra_cost",
|
|
74
|
-
"Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). Needs jsonui-cli 2.3.15
|
|
117
|
+
"Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). With plan (a migration plan's name, docs/infra/migrations/<name>): the month's old, new and difference — per node (added / replaced / removed / same, the biggest changes first), per provider, after each step of the plan (a step's difference is the whole month's change at that step, not its node's own: moved names each node or line's transfer that moved), what is not priced in the old or the new (unpricedChanged: changed nodes whose part the difference leaves out — not a saving), and with account the account's month too (its environments priced together); date and by do not apply. Needs jsonui-cli 2.3.15+ (plan: 2.3.20+).",
|
|
75
118
|
{
|
|
76
119
|
project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
|
|
77
120
|
scenario: z.string().optional().describe("Load scenario id (default: the first)"),
|
|
78
121
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One day of the scenario's month (YYYY-MM-DD)"),
|
|
79
122
|
by: z.enum(["provider", "node"]).optional().describe("Group the month's totals by provider (default) or node"),
|
|
123
|
+
plan: z.string().optional().describe("A migration plan's name: the month's old / new / difference instead"),
|
|
124
|
+
account: z.boolean().optional().describe("With plan: the account's month too — its environments priced together, old / new / difference"),
|
|
80
125
|
},
|
|
81
|
-
async ({ project_dir, scenario, date, by }) => {
|
|
126
|
+
async ({ project_dir, scenario, date, by, plan, account }) => {
|
|
82
127
|
try {
|
|
128
|
+
if (!plan && account) return failure(new Error("account applies with plan only"));
|
|
129
|
+
if (plan && (date || by)) return failure(new Error("date and by do not apply with plan: it answers by node and by provider at once, for the month"));
|
|
83
130
|
const projectDir = config.resolveProjectDir(project_dir);
|
|
84
131
|
const args = ["cost", "infra", projectDir, "--json"];
|
|
132
|
+
if (plan) args.push("--migration", plan);
|
|
133
|
+
if (account) args.push("--account");
|
|
85
134
|
if (scenario) args.push("--scenario", scenario);
|
|
86
135
|
if (date) args.push("--date", date);
|
|
87
136
|
if (by) args.push("--by", by);
|
|
@@ -95,6 +144,12 @@ export function register(server: McpServer, config: ServerConfig) {
|
|
|
95
144
|
need(data, ["dir", "scenario", "scenarios", "problems", "prices"], "cost infra");
|
|
96
145
|
need(data.problems, ["count", "errors", "first"], "cost infra problems");
|
|
97
146
|
need(data.prices, ["warnings", "warningCount"], "cost infra prices");
|
|
147
|
+
if (plan) {
|
|
148
|
+
// the load problems and price warnings are the old model's reading, as without plan
|
|
149
|
+
return text({ ok: exitCode === 0, exit: exitCode, dir: data.dir, scenarios: data.scenarios,
|
|
150
|
+
...migrationSummary(data), loadProblems: data.problems, priceWarnings: data.prices.warnings,
|
|
151
|
+
priceWarningCount: data.prices.warningCount });
|
|
152
|
+
}
|
|
98
153
|
const out: Record<string, unknown> = {
|
|
99
154
|
ok: exitCode === 0,
|
|
100
155
|
exit: exitCode,
|
|
@@ -1,24 +1,58 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ServerConfig } from "../../config.js";
|
|
4
|
-
import { failure, need, problems, runDocJson, text } from "./infra_common.js";
|
|
4
|
+
import { ContractError, FIRST, failure, need, problems, runDocJson, text } from "./infra_common.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
|
|
8
8
|
* read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
|
|
9
9
|
* walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
|
|
10
|
+
*
|
|
11
|
+
* Migration plans (jsonui-cli 2.3.20+, docs/infra/migrations/<name>/changes.json + removals.json): the CLI checks every
|
|
12
|
+
* plan with the model, or one with `--migration <name>`, and lists them under "migrations". A plan's error makes the
|
|
13
|
+
* exit 1 while the model's own problems are empty, so a room that has plans gets their summary here — without it the
|
|
14
|
+
* exit would name an error the answer does not show. A room with none answers exactly as before.
|
|
10
15
|
*/
|
|
16
|
+
|
|
17
|
+
function migrationSummary(m: any) {
|
|
18
|
+
need(m, ["name", "dir", "ok", "problems", "steps", "counts"], "validate infra migration");
|
|
19
|
+
return {
|
|
20
|
+
name: m.name,
|
|
21
|
+
dir: m.dir,
|
|
22
|
+
ok: m.ok,
|
|
23
|
+
counts: m.counts,
|
|
24
|
+
problems: problems(m.problems, "validate infra migration"),
|
|
25
|
+
steps: m.steps.length,
|
|
26
|
+
firstSteps: m.steps.slice(0, FIRST).map((s: any) => {
|
|
27
|
+
need(s, ["index", "kind", "node"], "validate infra migration step");
|
|
28
|
+
return { index: s.index, kind: s.kind, node: s.node,
|
|
29
|
+
...(s.edgesRemoved?.length ? { edgesRemoved: s.edgesRemoved } : {}),
|
|
30
|
+
...(s.edgesAdded?.length ? { edgesAdded: s.edgesAdded } : {}) };
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function migrations(data: any, plan: string | undefined) {
|
|
35
|
+
if (plan) need(data, ["migrations"], "validate infra --migration");
|
|
36
|
+
const list = data.migrations;
|
|
37
|
+
if (list === undefined) return {}; // a room with no plan: the CLI prints no key, and nor does this
|
|
38
|
+
if (!Array.isArray(list)) throw new ContractError('validate infra: "migrations" is not a list');
|
|
39
|
+
return { migrations: list.map(migrationSummary) };
|
|
40
|
+
}
|
|
41
|
+
|
|
11
42
|
export function register(server: McpServer, config: ServerConfig) {
|
|
12
43
|
server.tool(
|
|
13
44
|
"infra_validate",
|
|
14
|
-
"Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. Needs jsonui-cli 2.3.15
|
|
45
|
+
"Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. When the room has migration plans (docs/infra/migrations/<name>/changes.json + removals.json), each plan's check too: its problems (migration.remove_missing / duplicate / both / order_unknown / edge_end, and the infra checks on the merged new model), its counts (added, replaced, removed, edges added / removed) and its steps in order; `plan` checks that one plan only. Needs jsonui-cli 2.3.15+ (plans: 2.3.20+).",
|
|
15
46
|
{
|
|
16
47
|
project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
|
|
48
|
+
plan: z.string().optional().describe("One migration plan's name (its folder under docs/infra/migrations); default: every plan"),
|
|
17
49
|
},
|
|
18
|
-
async ({ project_dir }) => {
|
|
50
|
+
async ({ project_dir, plan }) => {
|
|
19
51
|
try {
|
|
20
52
|
const projectDir = config.resolveProjectDir(project_dir);
|
|
21
|
-
const
|
|
53
|
+
const args = ["validate", "infra", projectDir, "--json"];
|
|
54
|
+
if (plan) args.push("--migration", plan);
|
|
55
|
+
const { exitCode, data } = await runDocJson(args, projectDir);
|
|
22
56
|
need(data, ["ok"], "validate infra");
|
|
23
57
|
if (!data.ok && data.reason !== "invalid") {
|
|
24
58
|
need(data, ["reason", "message"], "validate infra");
|
|
@@ -35,6 +69,7 @@ export function register(server: McpServer, config: ServerConfig) {
|
|
|
35
69
|
counts: data.counts ?? null,
|
|
36
70
|
flows: Array.isArray(data.flows) ? data.flows.length : null,
|
|
37
71
|
problems: problems(data.problems, "validate infra"),
|
|
72
|
+
...migrations(data, plan),
|
|
38
73
|
});
|
|
39
74
|
} catch (e: any) {
|
|
40
75
|
return failure(e);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
290ce9df migration plans test: an edge whose two ends go at different steps goes at the first of them
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"validate ok": "{\n \"ok\": true,\n \"exit\": 0,\n \"readable\": true,\n \"dir\": \"docs/infra\",\n \"files\": [\n \"external.json\",\n \"main.json\"\n ],\n \"environments\": [\n \"production\",\n \"staging\"\n ],\n \"counts\": {\n \"groups\": 8,\n \"nodes\": 12,\n \"edges\": 10\n },\n \"flows\": 0,\n \"problems\": {\n \"count\": 0,\n \"errors\": 0,\n \"warnings\": 0,\n \"byCode\": {},\n \"first\": []\n }\n}",
|
|
3
|
+
"validate errors": "{\n \"ok\": false,\n \"exit\": 1,\n \"readable\": true,\n \"dir\": \"docs/infra\",\n \"files\": [\n \"external.json\",\n \"main.json\"\n ],\n \"environments\": [\n \"production\",\n \"staging\"\n ],\n \"counts\": {\n \"groups\": 8,\n \"nodes\": 12,\n \"edges\": 10\n },\n \"flows\": 0,\n \"problems\": {\n \"count\": 1,\n \"errors\": 1,\n \"warnings\": 0,\n \"byCode\": {\n \"schema\": 1\n },\n \"first\": [\n {\n \"severity\": \"error\",\n \"code\": \"schema\",\n \"file\": \"bad.json\",\n \"path\": \"/nodes/0/category\",\n \"message\": \"must be one of client, compute.vm, compute.container, compute.function, compute.k8s, compute.paas, edge.cdn, edge.dns, edge.waf, load_balancer, gateway.api, gateway.nat, network.ip, database.relational, database.document, database.kv, database.search, cache, storage.object, storage.file, storage.block, queue, stream, scheduler, mail, auth, secrets, monitoring, ci, external.saas, security.iam, security.policy, monitoring.alarm\"\n }\n ]\n }\n}",
|
|
4
|
+
"validate unreadable": "{\n \"ok\": false,\n \"exit\": 2,\n \"readable\": false,\n \"reason\": \"no_infra\",\n \"message\": \"no model files in docs/infra (*.json)\"\n}",
|
|
5
|
+
"cost month": "{\n \"ok\": true,\n \"exit\": 0,\n \"dir\": \"docs/infra/load\",\n \"scenario\": \"ordinary\",\n \"scenarios\": [\n {\n \"id\": \"ordinary\",\n \"label\": \"ふだんの月\",\n \"file\": \"ordinary.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n },\n {\n \"id\": \"sale\",\n \"label\": \"セールの月\",\n \"file\": \"sale.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n }\n ],\n \"loadProblems\": {\n \"count\": 0,\n \"errors\": 0,\n \"first\": []\n },\n \"priceWarnings\": [],\n \"priceWarningCount\": 0,\n \"month\": {\n \"scenario\": \"ordinary\",\n \"month\": \"2026-11\",\n \"usd\": 1018.5974779999989,\n \"by\": \"provider\",\n \"totals\": [\n {\n \"key\": \"aws\",\n \"label\": \"aws\",\n \"usd\": 1018.5974779999997\n }\n ],\n \"days\": 30,\n \"dearestDay\": \"2026-11-30\",\n \"noPrice\": [\n {\n \"node\": \"mailer\",\n \"meter\": \"emails\",\n \"sku\": \"emails\",\n \"unit\": \"email\"\n },\n {\n \"node\": \"api-5xx\",\n \"meter\": \"alarm\",\n \"sku\": \"alarm\",\n \"unit\": \"month\"\n }\n ],\n \"noRule\": [\n \"api-role\"\n ],\n \"loadUndefined\": [\n \"stripe\"\n ],\n \"overflowHours\": {},\n \"notes\": [\n \"no price for (no service) emails (email) in ap-northeast-1\",\n \"no price for cloudwatch.alarm alarm (month) in no region\"\n ],\n \"noteCount\": 2\n },\n \"topNodes\": [\n {\n \"node\": \"cdn\",\n \"usd\": 455.4774000000002\n },\n {\n \"node\": \"db-prod\",\n \"usd\": 422.3999999999987\n },\n {\n \"node\": \"api-prod\",\n \"usd\": 64.80000000000005\n },\n {\n \"node\": \"assets\",\n \"usd\": 44.55927800000017\n },\n {\n \"node\": \"alb-prod\",\n \"usd\": 29.015999999999977\n },\n {\n \"node\": \"api-prod>db-prod\",\n \"usd\": 2.3447999999999984\n }\n ]\n}",
|
|
6
|
+
"cost month by node": "{\n \"ok\": true,\n \"exit\": 0,\n \"dir\": \"docs/infra/load\",\n \"scenario\": \"sale\",\n \"scenarios\": [\n {\n \"id\": \"ordinary\",\n \"label\": \"ふだんの月\",\n \"file\": \"ordinary.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n },\n {\n \"id\": \"sale\",\n \"label\": \"セールの月\",\n \"file\": \"sale.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n }\n ],\n \"loadProblems\": {\n \"count\": 0,\n \"errors\": 0,\n \"first\": []\n },\n \"priceWarnings\": [],\n \"priceWarningCount\": 0,\n \"month\": {\n \"scenario\": \"sale\",\n \"month\": \"2026-11\",\n \"usd\": 2679.3521999999975,\n \"by\": \"node\",\n \"totals\": [\n {\n \"key\": \"cdn\",\n \"label\": \"CloudFront\",\n \"usd\": 1975.4412000000011\n },\n {\n \"key\": \"db-prod\",\n \"label\": \"db-prod\",\n \"usd\": 598.2749999999992\n },\n {\n \"key\": \"api-prod\",\n \"label\": \"api-prod\",\n \"usd\": 88.13999999999997\n },\n {\n \"key\": \"alb-prod\",\n \"label\": \"alb-prod\",\n \"usd\": 17.496\n }\n ],\n \"days\": 30,\n \"dearestDay\": \"2026-11-27\",\n \"noPrice\": [\n {\n \"node\": \"api-5xx\",\n \"meter\": \"alarm\",\n \"sku\": \"alarm\",\n \"unit\": \"month\"\n }\n ],\n \"noRule\": [\n \"api-role\"\n ],\n \"loadUndefined\": [\n \"stripe\",\n \"mailer\",\n \"assets\"\n ],\n \"overflowHours\": {\n \"api-prod\": 17,\n \"db-prod\": 3\n },\n \"notes\": [\n \"no price for cloudwatch.alarm alarm (month) in no region\"\n ],\n \"noteCount\": 1\n },\n \"topNodes\": [\n {\n \"node\": \"cdn\",\n \"usd\": 1975.4412000000011\n },\n {\n \"node\": \"db-prod\",\n \"usd\": 598.2749999999991\n },\n {\n \"node\": \"api-prod\",\n \"usd\": 88.13999999999996\n },\n {\n \"node\": \"alb-prod\",\n \"usd\": 17.496\n }\n ]\n}",
|
|
7
|
+
"cost day": "{\n \"ok\": true,\n \"exit\": 0,\n \"dir\": \"docs/infra/load\",\n \"scenario\": \"ordinary\",\n \"scenarios\": [\n {\n \"id\": \"ordinary\",\n \"label\": \"ふだんの月\",\n \"file\": \"ordinary.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n },\n {\n \"id\": \"sale\",\n \"label\": \"セールの月\",\n \"file\": \"sale.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n }\n ],\n \"loadProblems\": {\n \"count\": 0,\n \"errors\": 0,\n \"first\": []\n },\n \"priceWarnings\": [],\n \"priceWarningCount\": 0,\n \"day\": {\n \"scenario\": \"ordinary\",\n \"date\": \"2026-11-03\",\n \"usd\": 34.254097666666674,\n \"nodes\": [\n {\n \"node\": \"cdn\",\n \"label\": \"CloudFront\",\n \"provider\": \"aws\",\n \"rule\": \"aws/cloudfront\",\n \"usd\": 15.4623,\n \"peakHour\": 19,\n \"meters\": {\n \"egress-asia\": {\n \"usd\": 1.1940000000000008,\n \"amount\": 9.95,\n \"unit\": \"GB\",\n \"sku\": \"egress-asia\",\n \"layer\": \"bundled\"\n },\n \"egress-japan\": {\n \"usd\": 5.6715,\n \"amount\": 49.75,\n \"unit\": \"GB\",\n \"sku\": \"egress-japan\",\n \"layer\": \"bundled\"\n },\n \"requests-asia\": {\n \"usd\": 1.4328,\n \"amount\": 1194000,\n \"unit\": \"request\",\n \"sku\": \"requests-asia\",\n \"layer\": \"bundled\"\n },\n \"requests-japan\": {\n \"usd\": 7.164,\n \"amount\": 5970000,\n \"unit\": \"request\",\n \"sku\": \"requests-japan\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"db-prod\",\n \"label\": \"db-prod\",\n \"provider\": \"aws\",\n \"rule\": \"aws/rds\",\n \"usd\": 14.080000000000005,\n \"peakHour\": 0,\n \"meters\": {\n \"instance\": {\n \"usd\": 12.239999999999997,\n \"amount\": 24,\n \"unit\": \"instance-hour\",\n \"sku\": \"db.r6g.large:multi-az\",\n \"layer\": \"bundled\"\n },\n \"storage\": {\n \"usd\": 1.8400000000000054,\n \"amount\": 6.666666666666665,\n \"unit\": \"GB-month\",\n \"sku\": \"storage:multi-az\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"api-prod\",\n \"label\": \"api-prod\",\n \"provider\": \"aws\",\n \"rule\": \"aws/fargate\",\n \"usd\": 2.16,\n \"peakHour\": 0,\n \"meters\": {\n \"vcpu\": {\n \"usd\": 1.7999999999999994,\n \"amount\": 36,\n \"unit\": \"vCPU-hour\",\n \"sku\": \"vcpu\",\n \"layer\": \"user\"\n },\n \"memory\": {\n \"usd\": 0.3600000000000002,\n \"amount\": 72,\n \"unit\": \"GB-hour\",\n \"sku\": \"memory\",\n \"layer\": \"user\"\n }\n }\n },\n {\n \"node\": \"assets\",\n \"label\": \"アセットのバケツ\",\n \"provider\": \"aws\",\n \"rule\": \"aws/s3\",\n \"usd\": 1.5049976666666658,\n \"peakHour\": 19,\n \"meters\": {\n \"storage\": {\n \"usd\": 0.4166666666666656,\n \"amount\": 16.666666666666668,\n \"unit\": \"GB-month\",\n \"sku\": \"storage\",\n \"layer\": \"bundled\"\n },\n \"get\": {\n \"usd\": 1.060272,\n \"amount\": 2865600,\n \"unit\": \"request\",\n \"sku\": \"get\",\n \"layer\": \"user\"\n },\n \"put\": {\n \"usd\": 0.028059,\n \"amount\": 5970,\n \"unit\": \"request\",\n \"sku\": \"put\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"alb-prod\",\n \"label\": \"alb-prod\",\n \"provider\": \"aws\",\n \"rule\": \"aws/alb\",\n \"usd\": 0.9672000000000001,\n \"peakHour\": 0,\n \"meters\": {\n \"hour\": {\n \"usd\": 0.5831999999999998,\n \"amount\": 24,\n \"unit\": \"instance-hour\",\n \"sku\": \"hour\",\n \"layer\": \"bundled\"\n },\n \"lcu\": {\n \"usd\": 0.38400000000000023,\n \"amount\": 48,\n \"unit\": \"LCU-hour\",\n \"sku\": \"lcu\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"api-prod>db-prod\",\n \"label\": \"api-prod>db-prod\",\n \"provider\": \"aws\",\n \"rule\": \"transfer\",\n \"usd\": 0.07959999999999993,\n \"peakHour\": 19,\n \"meters\": {\n \"transfer-regional\": {\n \"usd\": 0.07959999999999996,\n \"amount\": 3.9800000000000004,\n \"unit\": \"GB\",\n \"sku\": \"transfer-regional\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"stripe\",\n \"label\": \"Stripe\",\n \"provider\": \"stripe\",\n \"rule\": \"generic/external.saas\",\n \"note\": \"load undefined\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {}\n },\n {\n \"node\": \"mailer\",\n \"label\": \"メール配信\",\n \"provider\": \"sendgrid\",\n \"rule\": \"generic/mail\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {\n \"emails\": {\n \"usd\": 0,\n \"amount\": 1658.3333333333335,\n \"unit\": \"email\",\n \"sku\": \"emails\",\n \"layer\": null\n }\n }\n },\n {\n \"node\": \"api-role\",\n \"label\": \"API の実行ロール\",\n \"provider\": \"aws\",\n \"rule\": null,\n \"note\": \"no rule\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {}\n },\n {\n \"node\": \"api-5xx\",\n \"label\": \"API の 5xx\",\n \"provider\": \"aws\",\n \"rule\": \"aws/cloudwatch-alarm\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {\n \"alarm\": {\n \"usd\": 0,\n \"amount\": 0.03333333333333332,\n \"unit\": \"month\",\n \"sku\": \"alarm\",\n \"layer\": null\n }\n }\n }\n ],\n \"others\": {\n \"count\": 0,\n \"usd\": 0\n }\n }\n}",
|
|
8
|
+
"cost refused": "{\n \"ok\": false,\n \"exit\": 2,\n \"reason\": \"no_scenario\",\n \"message\": \"no scenario 'nope'; there are: ordinary, sale\"\n}"
|
|
9
|
+
}
|
|
@@ -11,16 +11,17 @@
|
|
|
11
11
|
"mismatch": 0,
|
|
12
12
|
"missing_in_impl": 0,
|
|
13
13
|
"missing_in_doc": 0,
|
|
14
|
-
"skipped":
|
|
15
|
-
"warning":
|
|
14
|
+
"skipped": 4,
|
|
15
|
+
"warning": 4,
|
|
16
16
|
"unit": "node"
|
|
17
17
|
},
|
|
18
18
|
"results": [
|
|
19
19
|
{
|
|
20
20
|
"target": "load/ordinary/stripe",
|
|
21
|
-
"status": "
|
|
21
|
+
"status": "warning",
|
|
22
22
|
"confidence": "metadata",
|
|
23
|
-
"
|
|
23
|
+
"expected": "requests, input_tokens, output_tokens",
|
|
24
|
+
"message": "billed by use, but this load definition gives it no load (undefined, not 0)"
|
|
24
25
|
},
|
|
25
26
|
{
|
|
26
27
|
"target": "load/ordinary/api-role",
|
|
@@ -36,9 +37,10 @@
|
|
|
36
37
|
},
|
|
37
38
|
{
|
|
38
39
|
"target": "load/sale/stripe",
|
|
39
|
-
"status": "
|
|
40
|
+
"status": "warning",
|
|
40
41
|
"confidence": "metadata",
|
|
41
|
-
"
|
|
42
|
+
"expected": "requests, input_tokens, output_tokens",
|
|
43
|
+
"message": "billed by use, but this load definition gives it no load (undefined, not 0)"
|
|
42
44
|
},
|
|
43
45
|
{
|
|
44
46
|
"target": "load/sale/mailer",
|
|
@@ -441,10 +441,10 @@
|
|
|
441
441
|
}
|
|
442
442
|
],
|
|
443
443
|
"noRule": [
|
|
444
|
-
"stripe",
|
|
445
444
|
"api-role"
|
|
446
445
|
],
|
|
447
446
|
"loadUndefined": [
|
|
447
|
+
"stripe",
|
|
448
448
|
"mailer",
|
|
449
449
|
"assets"
|
|
450
450
|
],
|
|
@@ -456,9 +456,17 @@
|
|
|
456
456
|
{
|
|
457
457
|
"fn": "price_meter",
|
|
458
458
|
"message": "no price for cloudwatch.alarm alarm (month) in no region",
|
|
459
|
+
"detail": {
|
|
460
|
+
"keyword": "cost.no_price",
|
|
461
|
+
"service": "cloudwatch.alarm",
|
|
462
|
+
"sku": "alarm",
|
|
463
|
+
"unit": "month",
|
|
464
|
+
"region": ""
|
|
465
|
+
},
|
|
459
466
|
"node": "api-5xx",
|
|
460
467
|
"hour": "2026-11-01T00",
|
|
461
|
-
"meter": "alarm"
|
|
468
|
+
"meter": "alarm",
|
|
469
|
+
"short": "cloudwatch.alarm alarm(month)の価格が、(region 無し) にありません"
|
|
462
470
|
}
|
|
463
471
|
],
|
|
464
472
|
"warningCount": 1
|