second first commit

This commit is contained in:
Zakaria
2026-09-22 08:57:00 -04:00
parent e88d7e2ff3
commit 0aef7fb2d8
4766 changed files with 1236986 additions and 0 deletions
@@ -0,0 +1,241 @@
"""Opt-in AWS oracle capture. Only synthetic, read-only TestMetricFilter requests."""
import argparse
import datetime
import json
from pathlib import Path
import re
import subprocess
import time
def cases():
result = []
def add(name, pattern, messages):
result.append({"id": name, "request": {
"filterPattern": pattern, "logEventMessages": messages}})
terms = ["ERROR", "ERRORS", "xERROR", "error", "[ERROR]", "ERROR_code",
"ERROR timeout", "INFO timeout", "WARN", "two words", "two words"]
for name, pattern in [
("substring", "ERROR"), ("case", "error"), ("and", "ERROR timeout"),
("negative", "ERROR -timeout"), ("negative-only", "-ERROR"),
("optional", "?ERROR ?WARN"), ("optional-ignored", "?ERROR timeout"),
("optional-negative", "?ERROR -timeout"), ("phrase", '"two words"'),
("negative-phrase", '-"two words"'), ("optional-phrase", '?"two words" ?WARN'),
("wildcard", "ERR*"), ("quoted-wildcard", '"ERR*"'),
("bare-symbol", "ERROR!"), ("bare-colon", "ERROR:"),
("empty", ""), ("blank", " "), ("quoted-empty", '""'),
("missing-quote", '"ERROR'), ("adjacent-quote", '"ERROR"WARN'),
]:
add("terms-" + name, pattern, terms)
regex_messages = ["ERROR", "WARN", "aaaa", "aa", "cat", "a cat", "hat",
"color", "colour", "a-b", "a b", "a\nb", "123", "_", "café",
"key:value", "ab", "back\\slash", "!"]
for name, pattern in [
("alternation", "%ERROR|WARN%"), ("anchors", "%^[hc]at$%"),
("quantifier", "%^a{3,5}$%"), ("optional", "%colou?r%"),
("digits", "%\\d+%"), ("word", "%\\w+%"), ("space", "%a\\sb%"),
("hex", "%\\x3A%"), ("dot-newline", "%a.b%"),
("escaped-symbol", "%a\\-b%"), ("group-rejected", "%(ab)+%"),
("unicode-rejected", "%café%"), ("symbol-rejected", "%!%"),
("escape-rejected", "%\\p%"), ("escaped-group", "%\\(ab\\)%"),
("empty", "%%"), ("unclosed", "%abc"), ("bad-range", "%[z-a]%"),
("lazy", "%a+?%"), ("possessive", "%a++%"),
("class-intersection", "%[a&&b]%"), ("two", "%ERROR% %WARN%"),
("three", "%ERROR% %WARN% %INFO%"),
]:
add("regex-" + name, pattern, regex_messages)
json_messages = [
'{"a":1}', '{"a":"1"}', '{"a":1.0}', '{"a":"01"}', '{"a":true}',
'{"a":false}', '{"a":"true"}', '{"a":null}', '{}', '{"a":{}}',
'{"a":[]}', '{"a":"foo"}', '{"a":"foobar"}', '{"a":"FOO"}',
'{"a":1e3}', '{"a":"1e3"}', '{"a":1e309}', '{"a":"1e309"}',
'{"a":1e-999}', '{"a":-0.0}', '{"a":9007199254740993}',
]
for name, pattern in [
("numeric", "{ $.a = 1 }"), ("quoted-number", '{ $.a = "1" }'),
("numeric-ne", "{ $.a != 1 }"), ("string-ne", '{ $.a != "foo" }'),
("ordering", "{ $.a > 0 }"), ("quoted-order", '{ $.a > "0" }'),
("bare-word", "{ $.a = foo }"), ("bare-boolean", "{ $.a = true }"),
("quoted-boolean", '{ $.a = "true" }'), ("is-true", "{ $.a IS TRUE }"),
("is-false", "{ $.a IS FALSE }"), ("null", "{ $.a IS NULL }"),
("missing", "{ $.a NOT EXISTS }"), ("star", "{ $.a = * }"),
("quoted-star", '{ $.a = "*" }'), ("glob", "{ $.a = foo* }"),
("exponent", "{ $.a = 1e3 }"), ("plus", "{ $.a = +1 }"),
("overflow", "{ $.a = 1e309 }"), ("underflow", "{ $.a = 1e-999 }"),
("precision", "{ $.a = 9007199254740992 }"),
("regex-number", "{ $.a = %1% }"), ("keyword-and", "{ $.a = 1 AND $.b = 2 }"),
("double-equal", "{ $.a == 1 }"), ("trailing", "{ $.a = 1 } x"),
]:
add("json-" + name, pattern, json_messages)
selectors = [
'{"a":[1,2]}', '{"a":[2,1]}', '{"a":[null,2]}',
'{"a":{"x":1,"y":2}}', '{"a":{"x":2,"y":1}}',
'{"a":[{"b":1},{"b":2}]}', '{"a.b":2}', '{"a-b":2}',
'{"a":null}', '{}', '[1,2]', '{"a":2} trailing',
]
for name, pattern in [
("index", "{ $.a[1] = 2 }"), ("wildcard", "{ $.a[*] = 2 }"),
("object-wildcard", "{ $.a.* = 2 }"), ("wildcard-child", "{ $.a[*].b = 2 }"),
("quoted-property", "{ $.['a.b'] = 2 }"), ("bracket-property", "{ $['a.b'] = 2 }"),
("double-property", '{ $.["a.b"] = 2 }'), ("hyphen", "{ $.a-b = 2 }"),
("root-index", "{ $[1] = 2 }"), ("negative-index", "{ $.a[-1] = 2 }"),
("null-extraction", "{ $.a IS NULL }"), ("or-extraction", "{ $.a = 9 || $.missing NOT EXISTS }"),
("array-ne", "{ $.a[*] != 2 }"), ("nested-wildcard", "{ $.a[*].* = 2 }"),
]:
add("selector-" + name, pattern, selectors)
spaced = ["ERROR", "ERROR one", "ERROR one two", "WARN one", "INFO one",
'a "two words" c', "a [two words] c", 'a "two words"',
"a [two words]", "a b c ", "a\tb\tc", 'a "unclosed', "a [unclosed",
'a "b"c d', "one", "1 2", "1.0 2", "a b c d"]
for name, pattern in [
("fields", "[a, b, c]"), ("last-rest", "[a, rest]"), ("single", "[a]"),
("empty", "[]"), ("leading-ellipsis", "[..., last]"),
("middle-ellipsis", "[first, ..., last]"), ("trailing-ellipsis", "[first, ...]"),
("unnamed", "[, b,]"), ("trailing-comma", "[a,]"),
("constraint", "[level=ERROR, rest]"),
("or", "[level=ERROR || level=WARN, rest]"),
("numeric", "[a=1, rest]"), ("quoted-numeric", '[a="1", rest]'),
("cross-field", "[a=ERROR || b=one, b]"),
("duplicate", "[a, a]"), ("invalid-name", "[1a]"),
("double-ellipsis", "[a, ..., b, ...]"),
("bad-operator", "[a == 1]"), ("regex", "[a=%ERR%, rest]"),
]:
add("space-" + name, pattern, spaced)
return result
def edge_cases():
numbers = ['{"a":' + value + '}' for value in [
'1', '1.0', '1e0', '"1"', '"1.0"', '"01"', '0.0', '-0.0', '-1',
'9007199254740992', '9007199254740993', '1e308', '1e309', '"1e309"',
'1e-999', '"1e-999"', '0', 'true', '"true"', 'null', '[1,2]', '[null,2]',
]]
structures = ['{}', '{"a":null}', '{"a":2}', '{"a":1}', '{"a":"2"}',
'{"a":[]}', '{"a":[1,2]}', '{"a":[2,1]}', '{"a":[1]}',
'{"a":[null,2]}', '{"a":{}}', '{"a":{"b":2}}',
'{"a":[{"b":1},{"b":2}]}', '{"a":[{"b":null}]}',
'{"a":[[1,2]]}', '{"a":[true,false]}']
result = []
for name, pattern, messages in [
("decimal", "{ $.a = 1.0 }", numbers),
("zero", "{ $.a = 0 }", numbers),
("negative-zero", "{ $.a = -0.0 }", numbers),
("precise-long", "{ $.a = 9007199254740993 }", numbers),
("large-finite", "{ $.a = 1e308 }", numbers),
("large-ordered", "{ $.a > 1e307 }", numbers),
("underflow-ordered", "{ $.a > 1e-999 }", numbers),
("string-exponent", '{ $.a = "1e0" }', numbers),
("is-null", "{ $.a IS NULL }", structures),
("equals", "{ $.a = 2 }", structures),
("not-equals", "{ $.a != 2 }", structures),
("wildcard-not-equals", "{ $.a[*] != 2 }", structures),
("wildcard-null", "{ $.a[*] IS NULL }", structures),
("wildcard-missing", "{ $.a[*] NOT EXISTS }", structures),
("missing", "{ $.a NOT EXISTS }", structures),
("child", "{ $.a.b = 2 }", structures),
("child-missing", "{ $.a.b NOT EXISTS }", structures),
("star", "{ $.a = * }", structures),
("quoted-number", '{ $.a = "2" }', structures),
("bare-null", "{ $.a = null }", structures),
("is-not-null", "{ $.a IS NOT NULL }", structures),
("selector-root", "{ $ = 2 }", structures),
("json-negative-regex", "{ $.a != %2% }", structures),
("regex-text", "ERROR %WARN%", ["ERROR WARN", "ERROR", "WARN"]),
("regex-optional", "?%WARN%", ["WARN", "WARN INFO"]),
("term-punctuation", "a-b", ["a-b", "a b"]),
("term-underscore", "a_b", ["a_b", "a b"]),
("term-dot", "a.b", ["a.b", "a b"]),
("term-question", "a?b", ["a?b", "a b"]),
("regex-structured-two", "{ $.a = %1% || $.a = %2% }", structures),
("regex-structured-three", "{ $.a = %1% || $.a = %2% || $.a = %3% }", structures),
("quoted-order-space", '[a>"1", b]', ["2 x", "0 x"]),
("json-keyword-case", "{ $.a is null }", structures),
("json-unquoted-symbol", "{ $.a = a:b }", ['{"a":"a:b"}']),
("space-quote-trailing", "[a, b]", ['x "y" ', "x [y] ", "x y ", "x ", 'x ""', "x []"]),
("space-incomplete", "[a, b, c]", ['x "y z', "x [y z", 'x "y z q', "x [y z q"]),
]:
result.append({"id": "edge-" + name, "request": {
"filterPattern": pattern, "logEventMessages": messages}})
return result
def boundary_cases():
messages = [
'{"a":true}', '{"a":false}', '{"a":[true,false]}', '{"a":[null]}',
'{"a":[[null]]}', '{"a":[{"b":2},{"b":1}]}', '{"a":[{}]}', '{}',
]
result = []
for name, pattern, inputs in [
("array-true", "{ $.a IS TRUE }", messages),
("array-false", "{ $.a IS FALSE }", messages),
("array-null", "{ $.a IS NULL }", messages),
("nested-negative", "{ $.a[*].b != 2 }", messages),
("wildcard-null", "{ $.a[*] IS NULL }", messages),
("term-slash", "a/b", ["a/b", "a b"]),
("term-at", "a@b", ["a@b", "a b"]),
("term-unicode", "café", ["café", "cafe"]),
("quoted-space", '" "', ["x", "two words", " "]),
("huge-exponent", "{ $.a = 1e2147483648 }", ['{"a":1}', '{"a":"1e2147483648"}']),
("bare-negative", "a-b", ["a", "b", "a-b", "ab", "a b"]),
]:
result.append({"id": "boundary-" + name, "request": {
"filterPattern": pattern, "logEventMessages": inputs}})
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", required=True)
parser.add_argument("--region", required=True)
parser.add_argument("--endpoint-url", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--suite", choices=("base", "edges", "boundaries"), default="base")
args = parser.parse_args()
if args.endpoint_url != f"https://logs.{args.region}.amazonaws.com":
parser.error("capture requires the explicit regional AWS Logs endpoint")
corpus = {"base": cases, "edges": edge_cases, "boundaries": boundary_cases}[args.suite]()
assert len(cases()) + len(edge_cases()) + len(boundary_cases()) <= 149
for case in corpus:
for attempt in range(4):
command = ["aws", "--profile", args.profile, "--region", args.region,
"--endpoint-url", args.endpoint_url, "--no-cli-pager",
"logs", "test-metric-filter", "--output", "json",
"--cli-input-json", json.dumps(case["request"])]
completed = subprocess.run(command, capture_output=True, text=True)
if "Throttling" not in completed.stderr:
break
time.sleep(2 ** attempt)
if completed.returncode == 0:
case["response"] = json.loads(completed.stdout)
else:
error = re.search(r"An error occurred \((\w+)\).*?: (.*)", completed.stderr)
if not error or error.group(1) != "InvalidParameterException":
raise RuntimeError("AWS capture blocked: " + completed.stderr)
case["error"] = {"code": error.group(1), "message": error.group(2)}
print(case["id"], "error" if "error" in case else
str(len(case["response"]["matches"])) + " matches", flush=True)
time.sleep(0.15)
fixture = {
"provenance": {
"source": "AWS CloudWatch Logs TestMetricFilter",
"capturedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"region": args.region,
"endpoint": args.endpoint_url,
"inputs": "Synthetic only. No resource writes or user logs.",
"eventNumberBase": 1,
"reference": "https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_TestMetricFilter.html",
},
"cases": corpus,
}
args.output.write_text(json.dumps(fixture, indent=2) + "\n")
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
# Presentation for the generated "Supported Resource Types" table in
# docs/services/cloudformation.md. The set of types is NOT configured here: it comes from
# src/test/resources/cloudformation/supported-resource-types.tsv, which CfnResourceInventoryTest
# pins to the CDI-resolved provisioner registry and the legacy switch. This file only decides how
# those types are labelled, ordered and annotated.
#
# Adding a resource type: add the provisioner, update the TSV, run `make docs-sync`. Touch this
# file only for a new namespace (which needs a label) or to add a note.
# Row order. Namespaces omitted here are appended alphabetically after these, so a forgotten
# entry degrades to a sensible position rather than disappearing.
order:
- AWS::S3
- AWS::SQS
- AWS::SNS
- AWS::DynamoDB
- AWS::Lambda
- AWS::IAM
- AWS::Organizations
- AWS::SSM
- AWS::KMS
- AWS::SecretsManager
- AWS::ECR
- AWS::ECS
- AWS::EKS
- AWS::RDS
- AWS::Redshift
- AWS::EC2
- AWS::ElasticLoadBalancingV2
- AWS::AutoScaling
- AWS::Route53
- AWS::ApiGateway
- AWS::ApiGatewayV2
- AWS::AppSync
- AWS::StepFunctions
- AWS::CodePipeline
- AWS::CodeBuild
- AWS::Batch
- AWS::Cognito
- AWS::CertificateManager
- AWS::Events
- AWS::Scheduler
- AWS::Backup
- AWS::Pipes
- AWS::Kinesis
- AWS::KinesisFirehose
- AWS::IoT
- AWS::CloudFront
- AWS::CloudWatch
- AWS::Logs
- AWS::WAFv2
- AWS::Config
- AWS::CloudFormation
- AWS::CloudTrail
- Custom
- AWS::CDK
# Namespaces folded into another row. Custom::* types are CloudFormation's own extension
# mechanism, not a service of their own, so they belong in the CloudFormation row.
merge_namespaces:
Custom: AWS::CloudFormation
# Overrides the name shown for a type. Only needed where the bare leaf would be ambiguous.
display_names:
AWS::CDK::Metadata: CDK::Metadata
# Order of type names within a row. Listed names come first in this order; anything else is
# appended alphabetically, so a newly provisioned type appears without needing an entry here.
# Curated where the reading order carries meaning (primary resources before their accessories).
type_order:
AWS::S3: [Bucket, BucketPolicy]
AWS::SQS: [Queue, QueuePolicy]
AWS::SNS: [Topic, Subscription]
AWS::DynamoDB: [Table, GlobalTable]
AWS::Lambda: [Function, LayerVersion, EventSourceMapping, Version, Alias, Permission]
AWS::IAM: [Role, User, AccessKey, Policy, ManagedPolicy, InstanceProfile]
AWS::Organizations: [Organization, OrganizationalUnit, Account, Policy, ResourcePolicy]
AWS::KMS: [Key, Alias]
AWS::SecretsManager: [Secret, SecretTargetAttachment]
AWS::ECS: [Cluster, TaskDefinition, Service]
AWS::EKS: [Cluster, Nodegroup]
AWS::RDS:
[DBInstance, DBCluster, DBSubnetGroup, DBParameterGroup, DBClusterParameterGroup]
AWS::Redshift:
[Cluster, ClusterParameterGroup, ClusterSubnetGroup, ClusterSecurityGroup]
AWS::EC2:
[VPC, Subnet, SecurityGroup, SecurityGroupIngress, SecurityGroupEgress, InternetGateway,
RouteTable, SubnetRouteTableAssociation, Route, NatGateway, EIP, Instance, LaunchTemplate,
VPCGatewayAttachment, VPCEndpoint, NetworkAcl, NetworkAclEntry, SubnetNetworkAclAssociation,
FlowLog]
AWS::ElasticLoadBalancingV2: [LoadBalancer, TargetGroup, Listener, ListenerRule]
AWS::AutoScaling: [LaunchConfiguration, AutoScalingGroup, LifecycleHook]
AWS::Route53: [HostedZone, RecordSet]
AWS::ApiGateway: [RestApi, Resource, Authorizer, Method, Deployment, Stage, Account, DomainName, BasePathMapping, GatewayResponse]
AWS::ApiGatewayV2: [Api, Authorizer, Route, Integration, Stage, Deployment]
AWS::AppSync: [GraphQLApi, GraphQLSchema, DataSource, FunctionConfiguration, Resolver, ApiKey]
AWS::CodePipeline: [Pipeline, CustomActionType, Webhook]
AWS::Batch: [ComputeEnvironment, JobQueue, JobDefinition]
AWS::Cognito: [UserPool, UserPoolClient]
AWS::Events: [Rule, EventBus, EventBusPolicy]
AWS::CloudFormation: [CustomResource]
# Namespace -> the service name shown in the Service column.
service_labels:
AWS::ApiGateway: API Gateway (v1)
AWS::ApiGatewayV2: API Gateway v2
AWS::AppSync: AppSync
AWS::AutoScaling: Auto Scaling
AWS::Batch: Batch
AWS::Backup: Backup
AWS::CDK: CDK
AWS::CertificateManager: ACM
AWS::CloudFormation: CloudFormation
AWS::CloudFront: CloudFront
AWS::CloudTrail: CloudTrail
AWS::CloudWatch: CloudWatch
AWS::CodeBuild: CodeBuild
AWS::CodePipeline: CodePipeline
AWS::Cognito: Cognito
AWS::Config: Config
AWS::DynamoDB: DynamoDB
AWS::EC2: EC2
AWS::ECR: ECR
AWS::ECS: ECS
AWS::EKS: EKS
AWS::ElasticLoadBalancingV2: Elastic Load Balancing v2
AWS::Events: EventBridge
AWS::IAM: IAM
AWS::IoT: IoT Core
AWS::KMS: KMS
AWS::Kinesis: Kinesis
AWS::KinesisFirehose: Kinesis Data Firehose
AWS::Lambda: Lambda
AWS::Logs: CloudWatch Logs
AWS::Organizations: Organizations
AWS::Pipes: Pipes
AWS::RDS: RDS
AWS::Redshift: Redshift
AWS::Route53: Route 53
AWS::S3: S3
AWS::SNS: SNS
AWS::SQS: SQS
AWS::SSM: SSM
AWS::Scheduler: EventBridge Scheduler
AWS::SecretsManager: Secrets Manager
AWS::StepFunctions: Step Functions
AWS::WAFv2: WAFv2
Custom: CloudFormation
# Parenthetical shown after a type name. Keyed by the full type.
notes:
AWS::S3::BucketPolicy: document stored on the bucket; S3 does not evaluate it
AWS::SQS::QueuePolicy: accepted; policy not enforced
AWS::Lambda::Function: Zip via S3/inline `ZipFile`, and Image
AWS::Lambda::EventSourceMapping: SQS, Kinesis, DynamoDB Streams
AWS::Lambda::Alias: also what SAM's `AutoPublishAlias` expands into
AWS::EC2::SecurityGroup: inline `SecurityGroupIngress`/`SecurityGroupEgress` supported
AWS::RDS::DBInstance: starts a real container
AWS::RDS::DBCluster: starts a real container
AWS::Redshift::ClusterSecurityGroup: accepted; no EC2-Classic security group model
AWS::Redshift::Cluster: single-node container; Port and non-dev DBName ignored; ManageMasterPassword unsupported
AWS::CDK::Metadata: accepted; no-op
AWS::IAM::User: 'template `LoginProfile` and `PermissionsBoundary` are ignored; an API-created login profile is removed on delete'
AWS::IoT::DomainConfiguration: '`ServerCertificates` resolves to a JSON string'
AWS::IoT::Policy: 'deleted after detaching it from its principals; on AWS the delete fails with `DeleteConflictException` while the policy is attached'
Custom::DynamoDBReplica: applied natively against DynamoDB, not via a provider Lambda
AWS::Cognito::UserPool: '`ProviderURL` is the local issuer of the tokens Floci mints, `<base-url>/<pool id>`'
# Trailing prose appended to a row after its type list, for behaviour that belongs to the service
# rather than to one type.
row_notes:
AWS::Lambda: >-
Inline `ZipFile` packages include the `cfn-response` (Node.js) / `cfnresponse` (Python) module
AWS injects for that code path, so Solutions-style custom-resource handlers work.
# Capabilities that belong in this table but are not provisioner types, so they cannot come from
# the inventory. Each needs a reason, because an entry here is exempt from the drift check.
extra_types:
AWS::CloudFormation:
- name: Stack
note: nested stacks
reason: >-
Handled by CloudFormationService as a child-stack lifecycle, not by a resource provisioner.
- name: Custom::*
note: Lambda-backed
reason: >-
A wildcard prefix, not an exact type. The registry is keyed by exact type, so this is
dispatched by a prefix branch and can never appear in the inventory.
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Fail CI when a registered service has no row in the docs/services/index.md matrix.
`ResolvedServiceCatalog.java` is the source of truth for which AWS services Floci
emulates: every `descriptor("externalKey", "configKey", ...)` call registers one.
`docs/services/index.md` is the human-facing matrix users browse to find out what's
supported. Nothing previously connected the two, so a service could be registered,
enabled and routable while remaining absent from the matrix with every other check
green (see floci-io/floci#2454 - four services, and Bedrock AgentCore for five days,
shipped without a row).
This script closes that gap the same way regen_action_docs.py's unregistered-handler
sentinel closes the equivalent gap for action tables: extract descriptor keys from
Java source, extract documented filenames from the matrix, and fail on anything that
resolves to neither - unless tools/docs/service_matrix.yaml says otherwise (an alias
for a key whose AWS signing name differs from its doc filename, or a time-boxed
deferred entry for a service that is genuinely not documented yet).
The reverse direction is checked too: every docs/services/*.md page must have a
matrix row, unless listed under `facet_pages:` in the registry, so a page can no
longer exist in the nav with no row (elb-classic.md did, hidden by the `elb` key
exact-matching the v2 page).
Run from anywhere in the repo:
python3 tools/docs/check_service_matrix.py # print warnings only
python3 tools/docs/check_service_matrix.py --strict # exit non-zero on warnings
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import yaml
CATALOG_SOURCE = "src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java"
MATRIX_DOC = "docs/services/index.md"
REGISTRY = "tools/docs/service_matrix.yaml"
MATRIX_HEADING = "## Service Matrix"
MATRIX_END_HEADING = "## Common Setup"
# The externalKey is the first string literal argument to descriptor(...). It's always
# a plain double-quoted literal (no concatenation, no constants) in current usage.
DESCRIPTOR_KEY_RE = re.compile(r'\bdescriptor\(\s*"([^"]+)"')
# Every descriptor(...) call site, literal-keyed or not - used only to catch a call this
# parser's literal-only DESCRIPTOR_KEY_RE would otherwise miss in total silence (a future
# externalKey built from a variable or constant instead of a plain string literal). The
# negative lookbehind excludes the `private static ServiceDescriptor descriptor(...)`
# definition itself, the one place "descriptor(" appears that isn't a call.
ALL_DESCRIPTOR_CALLS_RE = re.compile(r"(?<!ServiceDescriptor )\bdescriptor\(")
# A matrix row's first cell is a markdown link to its doc page: `[Label](slug.md...)`.
# The slug is everything up to `.md`; a trailing `#anchor` (e.g. `cloudwatch.md#metrics`)
# is not part of the filename and is dropped by stopping the match at `.md` itself.
MATRIX_LINK_RE = re.compile(r"\]\(([a-z0-9][a-z0-9\-]*)\.md")
SERVICES_DIR = "docs/services"
@dataclass(frozen=True)
class DeferredEntry:
key: str
reason: str
by: date
def extract_descriptor_keys(java_source: str) -> list[str]:
"""externalKey of every descriptor(...) call, in source order.
May contain dupes only if the source itself registers the same externalKey twice
(a copy-paste bug); the caller dedups via set() before comparing against the matrix.
"""
return DESCRIPTOR_KEY_RE.findall(java_source)
def count_descriptor_calls(java_source: str) -> int:
"""Total descriptor(...) call sites, regardless of how the first argument is built."""
return len(ALL_DESCRIPTOR_CALLS_RE.findall(java_source))
def extract_matrix_slugs(md_source: str) -> set[str]:
"""Doc filenames (no extension, no anchor) linked from the Service Matrix table.
Scoped to the table between MATRIX_HEADING and MATRIX_END_HEADING so an unrelated
markdown link elsewhere on the page (prose, footer) can never be mistaken for a
documented row. Raises ValueError if either heading is missing, rather than letting
a renamed heading silently empty the table (and every service look undocumented).
"""
try:
start = md_source.index(MATRIX_HEADING)
end = md_source.index(MATRIX_END_HEADING, start)
except ValueError:
raise ValueError(
f"{MATRIX_DOC}: could not find '{MATRIX_HEADING}' ... '{MATRIX_END_HEADING}' "
"(has a heading been renamed or reordered?)"
) from None
table = md_source[start:end]
return set(MATRIX_LINK_RE.findall(table))
def find_unlinked_pages(services_dir: Path, slugs: set[str], facet_pages: set[str]) -> list[str]:
"""docs/services pages (by slug) that no matrix row links and no facet entry excuses.
index.md is the matrix page itself and is never expected to link to itself.
"""
pages = sorted(p.stem for p in services_dir.glob("*.md") if p.name != "index.md")
return [page for page in pages if page not in slugs and page not in facet_pages]
def _load_registry(repo_root: Path) -> tuple[dict[str, str], list[DeferredEntry], set[str]]:
"""Load service_matrix.yaml. Returns (aliases, deferred entries, facet pages).
Schema: `aliases:` maps an externalKey to the doc slug that documents it;
`deferred:` lists externalKeys with no row yet, each requiring `reason` and a
`by` expiry date (see service_matrix.yaml's header comment for why expiry is
mandatory rather than a permanent bypass); `facet_pages:` lists docs/services
slugs that deliberately have no matrix row of their own.
"""
raw = yaml.safe_load((repo_root / REGISTRY).read_text(encoding="utf-8")) or {}
aliases = dict(raw.get("aliases") or {})
facet_pages = set(raw.get("facet_pages") or [])
deferred: list[DeferredEntry] = []
for item in raw.get("deferred") or []:
missing = [f for f in ("key", "reason", "by") if f not in item]
if missing:
raise ValueError(
f"{REGISTRY}: deferred entry {item!r} is missing required field(s): {missing}"
)
deferred.append(
DeferredEntry(
key=item["key"],
reason=item["reason"],
by=date.fromisoformat(str(item["by"])),
)
)
return aliases, deferred, facet_pages
def find_undocumented(
keys: list[str],
slugs: set[str],
aliases: dict[str, str],
deferred: list[DeferredEntry],
today: date,
) -> tuple[list[str], list[DeferredEntry]]:
"""Classify descriptor keys against the matrix.
Returns (undocumented, expired_deferred):
- undocumented: keys that resolve to no matrix row by exact match, alias, or an
unexpired deferred entry. Includes keys whose only deferred entry has expired.
- expired_deferred: the deferred entries (still present in the registry) whose
`by` date has passed - reported separately so the warning names the date and
reason instead of reading like a brand-new, unexplained gap.
"""
active_deferred = {d.key: d for d in deferred if d.by >= today}
expired = [d for d in deferred if d.by < today]
undocumented: list[str] = []
for key in sorted(set(keys)):
target = aliases.get(key, key)
if target in slugs:
continue
if key in active_deferred:
continue
undocumented.append(key)
expired_deferred = [d for d in expired if d.key in set(keys) and aliases.get(d.key, d.key) not in slugs]
return undocumented, expired_deferred
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent.parent
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--strict",
action="store_true",
help="treat warnings (undocumented services, expired deferrals) as errors",
)
args = parser.parse_args(argv)
repo_root = _repo_root()
try:
aliases, deferred, facet_pages = _load_registry(repo_root)
except (ValueError, yaml.YAMLError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
java_source = (repo_root / CATALOG_SOURCE).read_text(encoding="utf-8")
keys = extract_descriptor_keys(java_source)
if not keys:
print(f"error: no descriptor(...) calls found in {CATALOG_SOURCE}", file=sys.stderr)
return 1
total_calls = count_descriptor_calls(java_source)
if total_calls != len(keys):
# DESCRIPTOR_KEY_RE only matches a plain double-quoted externalKey. A call built
# from a variable or constant would be silently invisible to every check below -
# this is the one place that gap gets caught instead of just under-counting.
print(
f"error: {CATALOG_SOURCE} has {total_calls} descriptor(...) call(s) but only "
f"{len(keys)} have a plain string-literal externalKey as the first argument; "
"the rest are invisible to this script and need a parser update",
file=sys.stderr,
)
return 1
md_source = (repo_root / MATRIX_DOC).read_text(encoding="utf-8")
try:
slugs = extract_matrix_slugs(md_source)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
undocumented, expired_deferred = find_undocumented(
keys, slugs, aliases, deferred, date.today()
)
warnings: list[str] = []
for d in expired_deferred:
warnings.append(
f"deferred entry '{d.key}' expired on {d.by.isoformat()} ({d.reason}); "
f"add its docs/services/index.md row or renew the expiry in {REGISTRY}"
)
still_undocumented = set(undocumented) - {d.key for d in expired_deferred}
for key in sorted(still_undocumented):
warnings.append(
f"service '{key}' is registered in {CATALOG_SOURCE} but has no row in "
f"{MATRIX_DOC} (add a row, an alias, or a time-boxed entry in {REGISTRY})"
)
for page in find_unlinked_pages(repo_root / SERVICES_DIR, slugs, facet_pages):
warnings.append(
f"{SERVICES_DIR}/{page}.md has no row in {MATRIX_DOC} (add a row, or list it "
f"under facet_pages in {REGISTRY} if it is a facet of another service's page)"
)
for w in warnings:
print(f"warning: {w}", file=sys.stderr)
if args.strict and warnings:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""Regenerate the action tables in docs/services/*.md from handler source.
The action LIST is derived from handler source (never hand-edited, never drifts).
The Description column is hand-written prose: regen PRESERVES existing descriptions
keyed by action name and leaves a "-" placeholder for new/unknown actions. The
generator only ever rewrites the bytes between the marker pair; everything else in
the file is byte-identical pre and post.
Assumption: a switch `case` label counts as supported purely by existing, so an arm
that is present but throws `UnsupportedOperation` in its body would still be listed.
Handlers here reject unsupported actions from the `default` arm, so this holds; a
per-action stub that throws would be a false positive.
Run from anywhere in the repo:
python3 tools/docs/regen_action_docs.py # rewrite docs in place
python3 tools/docs/regen_action_docs.py --strict # exit non-zero on warnings
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
import yaml
MARKER_START = "<!-- floci:actions:start -->"
MARKER_END = "<!-- floci:actions:end -->"
PLACEHOLDER = "-"
# `case "ActionName" ->` switch arms (Query / JSON 1.1 protocols). A single arm may
# carry several labels: `case "Foo", "Bar", "Baz" ->`; all are extracted, in order.
# The label list may also wrap across lines (valid Java 14+ switch syntax), so the
# capture group excludes only the `{ } ;` statement/body boundaries rather than
# newlines: it spans line breaks between labels but stops at the arm's `->` and can't
# run into an arm body or a colon-style `case` that has no `->`. Without this a
# wrapped arm would be silently dropped, and `docs-check` would stay green because the
# doc and the tool's output would agree on the omission.
# The label head requires `[A-Z][a-z]` (PascalCase), which deliberately excludes
# SCREAMING_CASE enum labels in non-action switches a handler may also contain
# (e.g. `switch (state) { case "DISABLED" -> ... }`). AWS action names are always
# PascalCase, so no real action is lost.
SWITCH_CASE_RE = re.compile(r"^\s*case\s+([^{};]+?)\s*->", re.MULTILINE)
SWITCH_LABEL_RE = re.compile(r'"([A-Z][a-z][A-Za-z0-9]*)"')
# REST controllers: a JAX-RS verb annotation followed by the public method it decorates,
# inside a class that carries a class-level @Path. The action is ucfirst(methodName),
# corrected per-service via rename_actions / exclude_actions (method names are not
# always the canonical AWS action, e.g. updateV2Integration -> UpdateIntegration).
CLASS_PATH_RE = re.compile(r"^\s*@Path\b", re.MULTILINE)
HTTP_ANNO_RE = re.compile(r"^\s*@(?:GET|POST|PUT|DELETE|PATCH)\b")
ANNOTATION_LINE_RE = re.compile(r"^\s*@\w")
PUBLIC_METHOD_RE = re.compile(r"^\s*public\s+\S+(?:<[^>]*>)?\s+([a-z][A-Za-z0-9]*)\s*\(")
# Line/block-comment lines (Javadoc) that may sit between the verb annotation and the
# method signature; skipped so they don't reset the `pending` flag mid-lookahead.
COMMENT_LINE_RE = re.compile(r"^\s*(?://|/\*|\*)")
@dataclass(frozen=True)
class Source:
path: Path
mode: str # 'switch' | 'rest'
@dataclass(frozen=True)
class ServiceEntry:
service: str
doc: Path
sources: list[Source]
rename_actions: dict[str, str] = field(default_factory=dict)
exclude_actions: frozenset[str] = field(default_factory=frozenset)
handler: Path | None = None # optional explicit handler path for excluded services
# --------------------------------------------------------------------------- #
# Extraction (pure functions over Java source text)
# --------------------------------------------------------------------------- #
def extract_switch_actions(java_source: str) -> list[str]:
"""Action names from `case "X" ->` arms, in source order.
Handles multi-label arms (`case "A", "B" ->`) by extracting every quoted
PascalCase label up to the arm's `->`.
"""
out: list[str] = []
for arm in SWITCH_CASE_RE.finditer(java_source):
out.extend(m.group(1) for m in SWITCH_LABEL_RE.finditer(arm.group(1)))
return out
CLASS_EXTENDS_RE = re.compile(r"public\s+(?:abstract\s+)?class\s+\w+\s+extends\s+([A-Za-z0-9_]+)")
def _extract_raw_rest_methods(java_source: str) -> list[str]:
actions: list[str] = []
pending = False
for line in java_source.splitlines():
if not line.strip():
continue
if HTTP_ANNO_RE.match(line):
pending = True
continue
if not pending:
continue
if ANNOTATION_LINE_RE.match(line):
continue
if COMMENT_LINE_RE.match(line):
continue
m = PUBLIC_METHOD_RE.match(line)
if m:
name = m.group(1)
actions.append(name[0].upper() + name[1:])
pending = False
return actions
def extract_rest_actions(java_source: str, source_path: Path | None = None) -> list[str]:
"""ucfirst(method name) of @GET/@POST/@PUT/@DELETE/@PATCH methods, source order.
Only applies to classes containing a class-level @Path. Returns [] otherwise.
Inherited methods from a superclass in the same directory are included first.
"""
if not CLASS_PATH_RE.search(java_source):
return []
actions: list[str] = []
if source_path is not None:
extends_match = CLASS_EXTENDS_RE.search(java_source)
if extends_match:
super_name = extends_match.group(1)
super_path = source_path.parent / f"{super_name}.java"
if super_path.exists():
actions.extend(_extract_raw_rest_methods(super_path.read_text(encoding="utf-8")))
actions.extend(_extract_raw_rest_methods(java_source))
return actions
def extract_actions(entry: ServiceEntry) -> list[str]:
"""Merged, deduped, override-corrected action list for a service.
Order: first appearance across the source list in registry order. rename_actions
is applied before dedup (so a renamed collision dedupes correctly); excluded
names are dropped after rename.
"""
seen: set[str] = set()
out: list[str] = []
for source in entry.sources:
text = source.path.read_text(encoding="utf-8")
if source.mode == "switch":
raw = extract_switch_actions(text)
elif source.mode == "rest":
raw = extract_rest_actions(text, source.path)
else:
raise ValueError(f"unknown mode: {source.mode!r}")
for name in raw:
name = entry.rename_actions.get(name, name)
if name in entry.exclude_actions:
continue
if name not in seen:
seen.add(name)
out.append(name)
return out
# --------------------------------------------------------------------------- #
# Markdown marker block: parse existing descriptions, render new table
# --------------------------------------------------------------------------- #
_SEPARATOR_ROW_RE = re.compile(r"^\|[\s:|-]+\|$")
def _split_row(row: str) -> list[str]:
"""Split a markdown table row into trimmed cells, honoring escaped pipes (\\|).
A literal pipe inside a cell must be written as \\| in markdown; an unescaped |
is always a column separator (including pipes inside inline code, which is why
descriptions should escape them). The split is on unescaped pipes only.
"""
cells: list[str] = []
buf: list[str] = []
i = 0
n = len(row)
while i < n:
ch = row[i]
if ch == "\\" and i + 1 < n and row[i + 1] == "|":
buf.append("|")
i += 2
continue
if ch == "|":
cells.append("".join(buf).strip())
buf = []
i += 1
continue
buf.append(ch)
i += 1
cells.append("".join(buf).strip())
# A well-formed row starts and ends with |, producing empty edge cells; drop them.
if cells and cells[0] == "":
cells = cells[1:]
if cells and cells[-1] == "":
cells = cells[:-1]
return cells
def _strip_action_cell(cell: str) -> str:
"""Normalize an action cell to the bare action name (handles `Backticked` form)."""
return cell.strip().strip("`").strip()
def parse_marker_block(md: str) -> tuple[str, dict[str, str], str]:
"""Split a doc around the marker block.
Returns (prefix_through_start_marker_line, {action: description}, suffix_from_end_marker_line).
Raises ValueError if markers are missing, unbalanced, or the enclosed table is malformed.
"""
if md.count(MARKER_START) != 1 or md.count(MARKER_END) != 1:
raise ValueError(
f"document must contain exactly one '{MARKER_START}' and one '{MARKER_END}'"
)
start_idx = md.index(MARKER_START)
end_idx = md.index(MARKER_END)
if end_idx < start_idx:
raise ValueError("end marker appears before start marker")
try:
after_start = md.index("\n", start_idx) + 1
before_end = md.rindex("\n", 0, end_idx) + 1
except ValueError:
raise ValueError(
f"'{MARKER_START}' and '{MARKER_END}' must each sit on their own line"
) from None
prefix = md[:after_start]
suffix = md[before_end:]
body = md[after_start:before_end]
return prefix, _parse_table(body), suffix
def _parse_table(body: str) -> dict[str, str]:
"""Parse {action: description} from the table inside the marker block.
Tolerant of a missing/extra Description column, padded alignment rows, and an
empty block (first migration). Rejects rows that don't look like a table row
once a table has started, loudly, rather than silently dropping content.
"""
descriptions: dict[str, str] = {}
seen_header = False
for line in body.splitlines():
stripped = line.strip()
if not stripped:
continue
if not stripped.startswith("|"):
raise ValueError(f"unexpected non-table line inside marker block: {stripped!r}")
if _SEPARATOR_ROW_RE.match(stripped):
continue
cells = _split_row(stripped)
if not cells:
continue
if len(cells) > 2:
raise ValueError(
f"malformed table row, expected at most 2 columns "
f"(escape literal pipes as '\\|'): {stripped!r}"
)
action = _strip_action_cell(cells[0])
# Skip the header row (its first cell is the literal "Action").
if not seen_header and action.lower() == "action":
seen_header = True
continue
seen_header = True
if not action:
continue
desc = cells[1].strip() if len(cells) > 1 else ""
if desc == PLACEHOLDER:
desc = ""
descriptions[action] = desc
return descriptions
def render_marker_block(actions: list[str], descriptions: dict[str, str]) -> str:
"""Render the table that goes between the marker lines (two columns).
Preserves a known description; emits the "-" placeholder for an unknown action.
"""
lines = ["| Action | Description |", "| --- | --- |"]
for action in actions:
desc = descriptions.get(action) or PLACEHOLDER
# Descriptions are held unescaped in memory; re-escape literal pipes so the
# cell round-trips through _split_row on the next regen instead of splitting.
desc = desc.replace("|", r"\|")
lines.append(f"| `{action}` | {desc} |")
return "\n".join(lines) + "\n"
def regenerate_doc_content(
actions: list[str], doc_content: str
) -> tuple[str, list[str]]:
"""Pure: given the new action list and the current doc, return (new_doc, orphans).
Orphans are actions present in the doc's old block but no longer in source; their
descriptions are dropped from the new block (the row goes away).
"""
prefix, prior, suffix = parse_marker_block(doc_content)
keep = set(actions)
orphans = [a for a in prior if a not in keep]
body = render_marker_block(actions, prior)
return prefix + body + suffix, orphans
# --------------------------------------------------------------------------- #
# Registry + driver
# --------------------------------------------------------------------------- #
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent.parent
def _load_registry(repo_root: Path) -> tuple[list[ServiceEntry], set[Path]]:
"""Load services.yaml. Returns (service entries, acknowledged deferred handler paths).
Schema: a top-level mapping with `services:` (the registered entries) and
`deferred_handlers:` (switch handlers known to exist but intentionally not yet
covered, so the unregistered-handler sentinel doesn't flag them every run).
"""
registry_path = repo_root / "tools" / "docs" / "services.yaml"
raw = yaml.safe_load(registry_path.read_text(encoding="utf-8")) or {}
entries: list[ServiceEntry] = []
for item in raw.get("services") or []:
sources = [
Source(path=repo_root / s["path"], mode=s["mode"])
for s in item.get("sources") or []
]
handler = item.get("handler")
entries.append(
ServiceEntry(
service=item["service"],
doc=repo_root / item["doc"],
sources=sources,
rename_actions=dict(item.get("rename_actions") or {}),
exclude_actions=frozenset(item.get("exclude_actions") or []),
handler=repo_root / handler if handler else None,
)
)
deferred = {
(repo_root / d["path"]).resolve()
for d in raw.get("deferred_handlers") or []
}
return entries, deferred
def _process_entry(entry: ServiceEntry) -> tuple[bool, list[str]]:
"""Regenerate one service's doc. Returns (changed, orphans). Skip-mode is a no-op."""
if not entry.sources:
return False, []
actions = extract_actions(entry)
doc_content = entry.doc.read_text(encoding="utf-8")
new_content, orphans = regenerate_doc_content(actions, doc_content)
if new_content != doc_content:
entry.doc.write_text(new_content, encoding="utf-8")
return True, orphans
return False, orphans
def _find_unregistered_handlers(
repo_root: Path, entries: list[ServiceEntry], deferred: set[Path]
) -> list[str]:
"""Top-level *Handler.java files that produce switch actions but aren't registered
and aren't on the deferred allowlist.
New-service drift sentinel: a brand-new switch handler must be either registered
or added to deferred_handlers (with a reason), or this fails --strict. REST
controllers are opt-in (per-judgment) and not flagged here.
"""
services_root = repo_root / "src/main/java/io/github/hectorvent/floci/services"
registered = {s.path.resolve() for entry in entries for s in entry.sources}
registered |= {entry.handler.resolve() for entry in entries if entry.handler}
registered |= deferred
unregistered: list[str] = []
# Recursive: handlers live at varying depths (e.g. cloudwatch/logs/, cloudwatch/metrics/).
for path in sorted(services_root.glob("**/[A-Z]*Handler.java")):
if path.resolve() in registered:
continue
if not extract_switch_actions(path.read_text(encoding="utf-8")):
continue
unregistered.append(str(path.relative_to(repo_root)))
return unregistered
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--strict",
action="store_true",
help="treat warnings (orphan actions, unregistered handlers) as errors",
)
args = parser.parse_args(argv)
repo_root = _repo_root()
entries, deferred = _load_registry(repo_root)
warnings: list[str] = []
for entry in entries:
try:
changed, orphans = _process_entry(entry)
except (ValueError, FileNotFoundError) as exc:
print(f"error: {entry.service}: {exc}", file=sys.stderr)
return 1
if changed:
print(f"updated {entry.doc.relative_to(repo_root)}")
for orphan in orphans:
warnings.append(
f"{entry.service}: action '{orphan}' was in the doc but not in source (row dropped)"
)
for handler in _find_unregistered_handlers(repo_root, entries, deferred):
warnings.append(
f"unregistered handler '{handler}' produces actions but is not in "
f"tools/docs/services.yaml (register it or add to deferred_handlers)"
)
for w in warnings:
print(f"warning: {w}", file=sys.stderr)
if args.strict and warnings:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Regenerate the "Supported Resource Types" table in docs/services/cloudformation.md.
The set of types comes from src/test/resources/cloudformation/supported-resource-types.tsv,
which CfnResourceInventoryTest pins to the CDI-resolved provisioner registry plus the legacy
switch. Presentation (row order, service labels, notes) comes from cfn_resource_types.yaml.
Deliberately reads the TSV rather than parsing the Java: several provisioners return
`Set.of(CONSTANT, CONSTANT)` from resourceTypes(), so a source regex sees only a subset. The
TSV is the one representation that is both machine-checked against the running registry and
readable without a JVM.
python3 tools/docs/regen_cfn_resource_types.py # rewrite in place
python3 tools/docs/regen_cfn_resource_types.py --check # exit 1 if stale
python3 tools/docs/regen_cfn_resource_types.py --strict # also fail on warnings
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
INVENTORY = REPO_ROOT / "src/test/resources/cloudformation/supported-resource-types.tsv"
CONFIG = Path(__file__).resolve().parent / "cfn_resource_types.yaml"
DOC = REPO_ROOT / "docs/services/cloudformation.md"
PROVISIONER_DIR = REPO_ROOT / "src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners"
MARKER_START = "<!-- floci:cfn-types:start -->"
MARKER_END = "<!-- floci:cfn-types:end -->"
def load_inventory(path: Path = INVENTORY) -> list[tuple[str, str]]:
"""[(resource_type, owner)] in file order."""
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) != 2:
raise ValueError(f"{path.name}: expected 'type<TAB>owner', got: {line!r}")
rows.append((parts[0], parts[1]))
return rows
def namespace_of(resource_type: str) -> str:
"""AWS::SQS::Queue -> AWS::SQS; Custom::DynamoDBReplica -> Custom."""
parts = resource_type.split("::")
return "::".join(parts[:2]) if len(parts) == 3 else parts[0]
def leaf_of(resource_type: str) -> str:
"""AWS::SQS::Queue -> Queue; Custom::DynamoDBReplica -> Custom::DynamoDBReplica.
A two-segment type has no namespace to strip, so it is shown whole; abbreviating
Custom::DynamoDBReplica to DynamoDBReplica would read as an AWS type that does not exist.
"""
parts = resource_type.split("::")
return parts[2] if len(parts) == 3 else resource_type
def render_rows(inventory, config) -> tuple[list[str], list[str]]:
"""Returns (table_lines, warnings)."""
warnings: list[str] = []
labels = config.get("service_labels", {}) or {}
notes = config.get("notes", {}) or {}
row_notes = config.get("row_notes", {}) or {}
extras = config.get("extra_types", {}) or {}
order = config.get("order", []) or []
merges = config.get("merge_namespaces", {}) or {}
displays = config.get("display_names", {}) or {}
type_order = config.get("type_order", {}) or {}
by_namespace: dict[str, list[str]] = {}
for resource_type, _owner in inventory:
ns = namespace_of(resource_type)
by_namespace.setdefault(merges.get(ns, ns), []).append(resource_type)
# Curated order first (it reads by importance, not alphabet), then anything new
# alphabetically so a type added without touching this config still lands somewhere sane.
for ns, types in by_namespace.items():
wanted = type_order.get(ns, [])
rank = {name: i for i, name in enumerate(wanted)}
types.sort(key=lambda t: (rank.get(leaf_of(t), len(rank)), leaf_of(t)))
for leaf in wanted:
if leaf not in {leaf_of(t) for t in types}:
warnings.append(
f"type_order for {ns} lists {leaf}, which nothing provisions; "
f"remove it or fix the spelling"
)
namespaces = list(by_namespace) + [ns for ns in extras if ns not in by_namespace]
ranked = [ns for ns in order if ns in namespaces]
ranked += sorted(ns for ns in namespaces if ns not in order)
for ns in namespaces:
if ns not in labels:
warnings.append(
f"no service_labels entry for {ns}; add one to cfn_resource_types.yaml "
f"so the row gets a readable service name"
)
if ns not in order:
warnings.append(f"{ns} is not in the order list; appended alphabetically")
lines = ["| Service | Resource types |", "|---|---|"]
for ns in ranked:
cells = []
for resource_type in by_namespace.get(ns, []):
note = notes.get(resource_type)
shown = displays.get(resource_type, leaf_of(resource_type))
cells.append(f"`{shown}`" + (f" ({note})" if note else ""))
for extra in extras.get(ns, []):
if not extra.get("reason"):
warnings.append(
f"extra_types entry {ns} / {extra.get('name')} has no reason; "
f"an entry exempt from the inventory check must say why"
)
note = extra.get("note")
cells.append(f"`{extra['name']}`" + (f" ({note})" if note else ""))
if not cells:
continue
types = ", ".join(cells)
trailing = (row_notes.get(ns) or "").strip()
if trailing:
types = f"{types}. {trailing}"
lines.append(f"| {labels.get(ns, ns)} | {types} |")
return lines, warnings
def check_provisioner_annotations() -> list[str]:
"""A CfnResourceProvisioner without @ApplicationScoped is never discovered by CDI.
CfnResourceInventoryTest catches this at runtime; flagging it here too means the cheap
check runs in docs-check, before anyone waits on a Quarkus test boot.
"""
warnings = []
if not PROVISIONER_DIR.is_dir():
return warnings
for path in sorted(PROVISIONER_DIR.glob("*CfnProvisioner.java")):
src = path.read_text(encoding="utf-8")
if "implements CfnResourceProvisioner" not in src:
continue
if "@ApplicationScoped" not in src:
warnings.append(
f"{path.name} implements CfnResourceProvisioner without @ApplicationScoped, "
f"so CDI never registers it and its types are silently stubbed"
)
return warnings
def check_no_double_ownership(inventory) -> list[str]:
"""A type served by a provisioner must not also sit in the legacy switch."""
owners: dict[str, set[str]] = {}
for resource_type, owner in inventory:
owners.setdefault(resource_type, set()).add(owner)
return [
f"{resource_type} is claimed by both the legacy switch and {sorted(o - {'LEGACY_SWITCH'})[0]}; "
f"the registry wins, leaving the switch arm as dead code"
for resource_type, o in sorted(owners.items())
if len(o) > 1 and "LEGACY_SWITCH" in o
]
def splice(md: str, table: list[str]) -> str:
if md.count(MARKER_START) != 1 or md.count(MARKER_END) != 1:
raise ValueError(
f"{DOC.name} must contain exactly one {MARKER_START} and one {MARKER_END}"
)
start, end = md.index(MARKER_START), md.index(MARKER_END)
if end < start:
raise ValueError("end marker appears before start marker")
after_start = md.index("\n", start) + 1
before_end = md.rindex("\n", 0, end) + 1
return md[:after_start] + "\n".join(table) + "\n" + md[before_end:]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="exit 1 if the doc is stale")
parser.add_argument("--strict", action="store_true", help="also exit 1 on warnings")
args = parser.parse_args(argv)
inventory = load_inventory()
config = yaml.safe_load(CONFIG.read_text(encoding="utf-8")) or {}
table, warnings = render_rows(inventory, config)
warnings += check_no_double_ownership(inventory)
warnings += check_provisioner_annotations()
for warning in warnings:
print(f"warning: {warning}", file=sys.stderr)
current = DOC.read_text(encoding="utf-8")
updated = splice(current, table)
stale = updated != current
if args.check or args.strict:
if stale:
print(
f"error: {DOC.relative_to(REPO_ROOT)} resource-type table is stale. "
f"Run 'make docs-sync' and commit the result.",
file=sys.stderr,
)
return 1 if (stale or (args.strict and warnings)) else 0
if stale:
DOC.write_text(updated, encoding="utf-8")
print(f"updated {DOC.relative_to(REPO_ROOT)} ({len(table) - 2} rows)")
else:
print(f"{DOC.relative_to(REPO_ROOT)} already up to date ({len(table) - 2} rows)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -0,0 +1,2 @@
PyYAML>=6.0
pytest>=7.0
+577
View File
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""Reconcile every source that answers "how many AWS services does Floci support?".
There is no single number, and that is not a bug: twelve sources in this repo each
count something slightly different, and each is right on its own terms. A descriptor
is a signing endpoint, a matrix row is an API surface, a doc page is a service a user
browses to, a README category entry is a marketing name. Asked "how many services",
people reach for whichever is nearest and get a different answer every time.
This script prints all twelve, groups them into the five keyspaces that can actually
be compared, and for each comparison prints the delta *as a number and as the named
entries that explain it*. An unexplained delta is a finding; an explained one is a
design decision that should read as deliberate.
It is a human-facing report, not a CI gate: it is read-only, and it exits non-zero
only when a source cannot be parsed. It deliberately does not duplicate the two real
gates that already exist (check_service_matrix.py for catalog-to-matrix,
ServiceConfigYamlCoverageTest for config-to-YAML); both are named in the output so an
advisory comparison can never be mistaken for an enforced one.
python3 tools/docs/service_inventory.py # the reconciliation report
python3 tools/docs/service_inventory.py --json # the same data, machine-readable
**Never copy a number from this output into README.md or docs/.** The repo's rule is
that no service count is published anywhere, derived or not; user-facing pages link to
the Service Matrix instead. This tool exists so the question can be answered in
conversation, not so the answer can be pasted into a page that then goes stale.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import date, timedelta
from pathlib import Path
import yaml
import check_service_matrix as csm
CONFIG_SOURCE = "src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java"
APPLICATION_YML = "src/main/resources/application.yml"
README = "README.md"
SERVICES_YAML = "tools/docs/services.yaml"
PACKAGE_DIR = "src/main/java/io/github/hectorvent/floci/services"
# Warn this far ahead of a deferred entry's expiry, so a red main is a decision rather
# than a surprise on the morning the date passes.
DEFERRAL_WARN_DAYS = 30
# Entries under floci.services.* that emulate nothing from AWS. The config and YAML
# collectors count them correctly (they are services Floci runs), so they have to be
# subtracted before those counts can be compared with anything AWS-shaped.
NON_AWS_CONFIG_KEYS = {"duck", "ui"}
# Packages under services/ that back no descriptor of their own, each with the reason.
# The point of listing them is that the package-count delta reads as explained; an
# entry appearing in the UNREGISTERED list instead is the signal that a package was
# added without being registered in the catalog.
PACKAGE_NOTES = {
"cloudwatch": "one package, two descriptors: cloudwatchlogs and cloudwatchmetrics",
"floci": "Floci's own control surface, not an AWS service",
"lambdamicrovms": "controllers attached to the lambda descriptor",
"resourcegroupstagging": "registered under the AWS signing name, tagging",
"securityadmin": "shared admin-accounts endpoint over GuardDuty and Macie",
"signin": "controller attached to the signin descriptor, which is keyed iam",
"ssoportal": "controller attached to the sso descriptor, which is keyed ssoadmin",
}
# README category names whose wording differs from the matrix row label they refer to.
# Matching is by normalised name, so only genuine wording differences need an entry,
# and one README name may cover several matrix rows. This assists a human-facing
# report and gates nothing, which is why it lives here rather than in
# service_matrix.yaml next to the aliases the CI gate reads.
README_NAME_ALIASES: dict[str, str | list[str]] = {
"API Gateway REST": "API Gateway v1",
"Bedrock": "Amazon Bedrock",
"ELB Classic": "ELB Classic (v1)",
"Firehose": "Data Firehose",
"Resource Explorer 2": "Resource Explorer",
"Service Catalog": "AWS Service Catalog",
"IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM)": [
"IAM Identity Center (SSO Admin)",
"IAM Identity Center OIDC",
"IAM Identity Center Access Portal",
],
}
CONFIG_ACCESSOR_RE = re.compile(r"^\s{8}[A-Za-z0-9]+ +([a-zA-Z0-9]+)\(\);", re.M)
ENABLED_CALL_RE = re.compile(r"config\.services\(\)\.([a-zA-Z0-9]+)\(\)\.enabled\(\)")
MATRIX_ROW_RE = re.compile(r"^\| \[([^\]]+)\]\(([a-z0-9][a-z0-9\-]*)\.md([^)]*)\)", re.M)
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent.parent
def key(name: str) -> str:
"""Canonical form of a service key, for comparing across naming conventions.
The same service is `bcmDataExports` as a config accessor, `bcm-data-exports` in
application.yml and `bcmdataexports` as a package directory. Comparing the raw
spellings reports eight bogus mismatches in a repo where nothing is actually wrong,
so every cross-source comparison goes through here.
"""
return re.sub(r"[^a-z0-9]", "", name.lower())
# --------------------------------------------------------------------------- #
# Java catalog
# --------------------------------------------------------------------------- #
def split_top_level_args(text: str, start: int) -> list[str]:
"""Split the argument list of a call whose opening paren is at or after `start`.
Splits on commas at paren depth 1 only, so a nested `config.services().x().enabled()`
or `Set.of("a", "b")` argument stays in one piece. Raises ValueError if the parens
never balance.
"""
depth = 0
args: list[str] = []
current: list[str] = []
for i in range(start, len(text)):
ch = text[i]
if ch == "(":
depth += 1
if depth == 1:
continue
elif ch == ")":
depth -= 1
if depth == 0:
args.append("".join(current).strip())
return args
elif ch == "," and depth == 1:
args.append("".join(current).strip())
current = []
continue
current.append(ch)
raise ValueError("unbalanced parentheses in descriptor(...) call")
def parse_descriptors(java_source: str) -> list[dict]:
"""externalKey, configKey, enable toggles and includeInStatus of every call.
check_service_matrix.py needs only the externalKey, so one regex serves it. This
needs the first four arguments, and the fourth can sit on a continuation line:
rds-data and redshift-data wrap because their enabled expression is a conjunction
of two toggles. Matching off the opening line would read `true` from the wrong
argument for exactly those two, so the arguments are split properly instead.
"""
out: list[dict] = []
for m in csm.ALL_DESCRIPTOR_CALLS_RE.finditer(java_source):
open_paren = java_source.index("(", m.start())
args = split_top_level_args(java_source, open_paren)
if len(args) < 4:
raise ValueError(f"descriptor(...) call with only {len(args)} arguments")
external, config_key, enabled, include = args[0], args[1], args[2], args[3]
if not (external.startswith('"') and external.endswith('"')):
raise ValueError(
f"descriptor(...) externalKey is not a plain string literal: {external!r}"
)
out.append(
{
"external_key": external.strip('"'),
"config_key": config_key.strip('"') if config_key.startswith('"') else None,
"toggles": sorted(set(ENABLED_CALL_RE.findall(enabled))),
"in_status": include.strip() == "true",
}
)
return out
# --------------------------------------------------------------------------- #
# Config plane
# --------------------------------------------------------------------------- #
def parse_config_accessors(java_source: str) -> list[str]:
"""Accessors on EmulatorConfig.ServicesConfig that return a per-service config.
Scoped to that interface body, because the same accessor shape appears on other
nested interfaces. `dockerNetwork()` is excluded by the return-type pattern: it
returns `Optional<String>`, and the `<` keeps it out.
"""
try:
start = java_source.index(" interface ServicesConfig {")
except ValueError:
raise ValueError(f"{CONFIG_SOURCE}: interface ServicesConfig not found") from None
end = java_source.index("\n }", start)
return sorted(CONFIG_ACCESSOR_RE.findall(java_source[start:end]))
def parse_yaml_service_keys(path: Path) -> tuple[list[str], list[str]]:
"""(every floci.services.* key, the subset declaring `enabled:`)."""
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
services = (data.get("floci") or {}).get("services") or {}
keys = [k for k, v in services.items() if isinstance(v, dict)]
return sorted(keys), sorted(k for k in keys if "enabled" in services[k])
# --------------------------------------------------------------------------- #
# Docs and README
# --------------------------------------------------------------------------- #
def parse_matrix_rows(md_source: str) -> list[dict]:
"""One entry per Service Matrix row: label, page slug, anchor.
A row is a distinct API surface; a page is a documented service. They differ
because eight pages carry two rows each (DynamoDB and DynamoDB Streams, IAM and
AWS Sign-In, and so on). The facet test is **two rows sharing a slug**, not "the
link carries an anchor": IoT Core and IoT Data both link plain `iot.md` with no
anchor at all, so an anchor-based rule misses that pair and over-counts by one.
"""
start = md_source.index(csm.MATRIX_HEADING)
end = md_source.index(csm.MATRIX_END_HEADING, start)
return [
{"label": label, "slug": slug, "anchor": anchor.lstrip("#") or None}
for label, slug, anchor in MATRIX_ROW_RE.findall(md_source[start:end])
]
def parse_readme_category_names(md_source: str) -> list[str]:
"""Service names from the README's Category/Services table.
Split on commas at paren depth 0 only. One entry reads `IAM Identity Center (SSO
Admin, OIDC, Access Portal, SCIM)`; splitting on every comma turns that single
name into four, three of which ("OIDC", "SCIM)") are not service names at all and
inflate the count by three.
"""
start = md_source.index("| Category | Services |")
end = md_source.index("For operation-level compatibility", start)
names: list[str] = []
for line in md_source[start:end].splitlines():
if not line.startswith("| ") or line.startswith("|---") or line.startswith("| Category"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2:
continue
depth = 0
current: list[str] = []
for ch in cells[1]:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif ch == "," and depth == 0:
names.append("".join(current).strip())
current = []
continue
current.append(ch)
if "".join(current).strip():
names.append("".join(current).strip())
return names
def parse_readme_detail_rows(md_source: str) -> list[str]:
"""Service names from the README's collapsed "Detailed service notes" table."""
header = "| Service | How it works | Notable features |"
start = md_source.index(header)
end = md_source.index("</details>", start)
rows = []
for line in md_source[start:end].splitlines():
if not line.startswith("| ") or line.startswith("|---") or line == header:
continue
rows.append(line.strip("|").split("|")[0].strip())
return rows
def parse_action_table_services(path: Path) -> list[str]:
"""Service keys in services.yaml, the opt-in list for generated action tables."""
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return sorted(entry["service"] for entry in (data.get("services") or []))
# --------------------------------------------------------------------------- #
# Reconciliation
# --------------------------------------------------------------------------- #
def collect(repo_root: Path) -> dict:
"""Every source, parsed. Raises ValueError if any source cannot be read."""
descriptors = parse_descriptors((repo_root / csm.CATALOG_SOURCE).read_text(encoding="utf-8"))
if not descriptors:
raise ValueError(f"no descriptor(...) calls found in {csm.CATALOG_SOURCE}")
yaml_keys, yaml_enabled = parse_yaml_service_keys(repo_root / APPLICATION_YML)
readme_src = (repo_root / README).read_text(encoding="utf-8")
aliases, deferred, facet_pages = csm._load_registry(repo_root)
return {
"descriptors": descriptors,
"config_accessors": parse_config_accessors(
(repo_root / CONFIG_SOURCE).read_text(encoding="utf-8")
),
"yaml_keys": yaml_keys,
"yaml_enabled": yaml_enabled,
"packages": sorted(p.name for p in (repo_root / PACKAGE_DIR).iterdir() if p.is_dir()),
"matrix_rows": parse_matrix_rows(
(repo_root / csm.MATRIX_DOC).read_text(encoding="utf-8")
),
"doc_pages": sorted(
p.stem for p in (repo_root / csm.SERVICES_DIR).glob("*.md") if p.name != "index.md"
),
"readme_categories": parse_readme_category_names(readme_src),
"readme_details": parse_readme_detail_rows(readme_src),
"action_tables": parse_action_table_services(repo_root / SERVICES_YAML),
"aliases": aliases,
"facet_pages": sorted(facet_pages),
"deferred": [{"key": d.key, "reason": d.reason, "by": d.by.isoformat()} for d in deferred],
}
def reconcile(data: dict, today: date | None = None) -> dict:
"""Counts, deltas, and the named entries that explain each delta."""
today = today or date.today()
descriptors = data["descriptors"]
aliases = data["aliases"]
# --- catalog ---------------------------------------------------------- #
keys = [d["external_key"] for d in descriptors]
page_of = {k: aliases.get(k, k) for k in keys}
owners: dict[str, list[str]] = {}
for k in keys:
owners.setdefault(page_of[k], []).append(k)
facet_descriptors = sorted(k for k in keys if owners[page_of[k]][0] != k)
status_hidden = sorted(d["external_key"] for d in descriptors if not d["in_status"])
toggle_users: dict[str, list[str]] = {}
for d in descriptors:
for t in d["toggles"]:
toggle_users.setdefault(key(t), []).append(d["external_key"])
shared = {t: v for t, v in sorted(toggle_users.items()) if len(v) > 1}
# --- config plane ----------------------------------------------------- #
accessors = {key(a): a for a in data["config_accessors"]}
yaml_keys = {key(k): k for k in data["yaml_keys"]}
aws_keys = {k: v for k, v in yaml_keys.items() if v not in NON_AWS_CONFIG_KEYS}
untoggled = sorted(v for k, v in aws_keys.items() if k not in toggle_users)
# --- packages --------------------------------------------------------- #
descriptor_keys = {key(d["config_key"]) for d in descriptors if d["config_key"]}
packages = data["packages"]
noted = sorted(p for p in packages if p in PACKAGE_NOTES)
unregistered = sorted(
p for p in packages if key(p) not in descriptor_keys and p not in PACKAGE_NOTES
)
# --- docs ------------------------------------------------------------- #
rows = data["matrix_rows"]
slug_rows: dict[str, list[str]] = {}
for r in rows:
slug_rows.setdefault(r["slug"], []).append(r["label"])
facet_rows = sorted(r["label"] for r in rows if slug_rows[r["slug"]][0] != r["label"])
doc_pages = set(data["doc_pages"])
expiring = [
d for d in data["deferred"]
if date.fromisoformat(d["by"]) <= today + timedelta(days=DEFERRAL_WARN_DAYS)
]
# --- README ----------------------------------------------------------- #
row_labels = {key(r["label"]) for r in rows}
facet_norms = {key(label) for label in facet_rows}
def cover(names: list[str]) -> tuple[set[str], list[str]]:
"""(matrix labels these README names account for, names matching no row).
A name resolves through README_NAME_ALIASES when its wording differs from the
matrix label, and one alias may cover several rows: the README names IAM
Identity Center once where the matrix carries three rows for it.
"""
covered: set[str] = set()
unmatched: list[str] = []
for name in names:
target = README_NAME_ALIASES.get(name, name)
norms = [key(t) for t in (target if isinstance(target, list) else [target])]
if all(n in row_labels for n in norms):
covered |= set(norms)
else:
unmatched.append(name)
return covered, unmatched
category_covered, unmatched_names = cover(data["readme_categories"])
detail_covered, _ = cover(data["readme_details"])
def uncovered(covered: set[str], facets: bool) -> list[str]:
return [
r["label"]
for r in rows
if key(r["label"]) not in covered and (key(r["label"]) in facet_norms) is facets
]
return {
"counts": {
"catalog_descriptors": len(keys),
"catalog_distinct_products": len(keys) - len(facet_descriptors),
"catalog_status_visible": len(keys) - len(status_hidden),
"catalog_toggles": len(toggle_users),
"service_packages": len(packages),
"config_accessors": len(accessors),
"yaml_keys": len(yaml_keys),
"yaml_enabled": len(data["yaml_enabled"]),
"yaml_aws_keys": len(aws_keys),
"matrix_rows": len(rows),
"matrix_pages": len(slug_rows),
"doc_pages": len(doc_pages),
"readme_category_names": len(data["readme_categories"]),
"readme_detail_rows": len(data["readme_details"]),
"action_tables": len(data["action_tables"]),
},
"catalog": {
"facet_descriptors": facet_descriptors,
"status_hidden": status_hidden,
"shared_toggles": {t: v for t, v in shared.items()},
},
"config": {
"non_aws": sorted(v for k, v in yaml_keys.items() if v in NON_AWS_CONFIG_KEYS),
"accessor_only": sorted(accessors[k] for k in accessors.keys() - yaml_keys.keys()),
"yaml_only": sorted(yaml_keys[k] for k in yaml_keys.keys() - accessors.keys()),
"no_enabled_key": sorted(set(data["yaml_keys"]) - set(data["yaml_enabled"])),
"untoggled": untoggled,
"toggle_without_key": sorted(
t for t in toggle_users if t not in yaml_keys
),
},
"packages": {"noted": noted, "unregistered": unregistered},
"docs": {
"facet_rows": facet_rows,
"pages_without_row": sorted(doc_pages - set(slug_rows) - set(data["facet_pages"])),
"rows_without_page": sorted(set(slug_rows) - doc_pages),
"facet_pages": data["facet_pages"],
"deferred": data["deferred"],
"deferred_expiring": expiring,
},
"readme": {
"unmatched_category_names": unmatched_names,
"missing_from_categories": uncovered(category_covered, facets=False),
"missing_facet_rows": uncovered(category_covered, facets=True),
"missing_from_details": uncovered(detail_covered, facets=False),
},
}
# --------------------------------------------------------------------------- #
# Report
# --------------------------------------------------------------------------- #
def _names(items: list[str], limit: int = 10) -> str:
if not items:
return ""
shown = ", ".join(items[:limit])
return shown if len(items) <= limit else f"{shown}, +{len(items) - limit} more"
def render(result: dict) -> str:
c = result["counts"]
cat, cfg, pkg, docs, rd = (
result["catalog"], result["config"], result["packages"],
result["docs"], result["readme"],
)
out: list[str] = []
w = out.append
def line(label: str, count: int | str, note: str = "") -> None:
w(f" {label:<32}{str(count):>5} {note}".rstrip())
def delta(label: str, items: list[str], limit: int = 10) -> None:
w(f" {'- ' + label:<32}{len(items):>5} {_names(items, limit)}".rstrip())
def note(text: str) -> None:
w(f" {text}")
w("")
w("THE ANSWER")
w(f" Floci documents {c['matrix_pages']} AWS services, exposing {c['matrix_rows']} distinct")
w(f" API surfaces across {c['catalog_descriptors']} signing endpoints.")
w(" Quote the first for \"how many services\", the second when the question is really")
w(" about API coverage. Never write any of them into README.md or docs/: link to the")
w(" Service Matrix instead.")
w("")
w("1. CATALOG (keyspace: descriptor externalKey)")
line("catalog descriptors", c["catalog_descriptors"], csm.CATALOG_SOURCE)
delta("facet endpoints", cat["facet_descriptors"])
line("= distinct products", c["catalog_distinct_products"])
line("status-visible at runtime", c["catalog_status_visible"],
f"GET /health; hidden: {_names(cat['status_hidden'])}")
line("distinct enable toggles", c["catalog_toggles"])
for toggle, users in cat["shared_toggles"].items():
note(f"{toggle} gates {len(users)}: {_names(users)}")
w("")
w("2. CONFIG PLANE (keyspace: floci.services.* key, naming normalised)")
line("EmulatorConfig accessors", c["config_accessors"], CONFIG_SOURCE)
line("application.yml keys", c["yaml_keys"], APPLICATION_YML)
if cfg["accessor_only"] or cfg["yaml_only"]:
delta("accessor with no YAML key", cfg["accessor_only"])
delta("YAML key with no accessor", cfg["yaml_only"])
else:
line("= identical sets", "", "GATED by ServiceConfigYamlCoverageTest")
delta("no `enabled:` in YAML", cfg["no_enabled_key"])
delta("non-AWS entries", cfg["non_aws"])
line("= AWS service keys", c["yaml_aws_keys"])
delta("no descriptor reads its toggle", cfg["untoggled"])
delta("toggle with no YAML key", cfg["toggle_without_key"])
w("")
w("3. SOURCE (keyspace: Java package, naming normalised)")
line("services/ packages", c["service_packages"], PACKAGE_DIR)
delta("no descriptor, explained", pkg["noted"])
for p in pkg["noted"]:
note(f"{p}: {PACKAGE_NOTES[p]}")
if pkg["unregistered"]:
delta("UNREGISTERED", pkg["unregistered"])
note(f"A package under {PACKAGE_DIR} that no descriptor(...) names.")
note("Register it, or add it to PACKAGE_NOTES with the reason it has none.")
else:
line("= every other package registered", "", "ADVISORY, no gate")
w("")
w("4. DOCS (keyspace: docs/services slug)")
line("matrix rows", c["matrix_rows"], csm.MATRIX_DOC)
delta("facet rows (share a page)", docs["facet_rows"])
line("= matrix pages", c["matrix_pages"])
line("docs/services/*.md", c["doc_pages"])
if docs["pages_without_row"] or docs["rows_without_page"]:
delta("page with no row", docs["pages_without_row"])
delta("row with no page", docs["rows_without_page"])
else:
line("= identical sets", "", "GATED by check_service_matrix.py --strict")
if docs["facet_pages"]:
note(f"facet_pages, row-exempt by registry: {_names(docs['facet_pages'])}")
for d in docs["deferred"]:
flag = " <-- EXPIRING" if d in docs["deferred_expiring"] else ""
note(f"deferred: {d['key']} until {d['by']}{flag}")
note(f" {d['reason']}")
w("")
w("5. README (keyspace: service name, matched to matrix row labels)")
line("category-table names", c["readme_category_names"], f"{README}, Supported Services")
line("matrix rows they cover",
c["matrix_rows"] - len(rd["missing_from_categories"]) - len(rd["missing_facet_rows"]))
delta("row missing from README", rd["missing_from_categories"])
delta("facet row, absent by design", rd["missing_facet_rows"])
if rd["unmatched_category_names"]:
delta("README name matching no row", rd["unmatched_category_names"], limit=4)
note("Wording drift, a service not in the matrix, or a missing")
note("README_NAME_ALIASES entry. Read it, do not assume.")
note("ADVISORY, no gate: docs/contributing.md asks contributors for a README")
note("row as well as a matrix row, but only the matrix row is machine-checked.")
w("")
line("detail-table rows", c["readme_detail_rows"], "editorial \"notable features\"")
delta("documented service with no note", rd["missing_from_details"], limit=6)
note("Deliberately ungated: the detail table is editorial, and a row per")
note(f"service would buy {len(rd['missing_from_details'])} lines of filler prose.")
w("")
w("6. ACTION TABLES (keyspace: services.yaml entry)")
line("generated action tables", c["action_tables"], SERVICES_YAML)
note(f"Opt-in by design, not a gap: {c['doc_pages'] - c['action_tables']} pages carry no")
note("generated operation table. regen_action_docs.py owns this list.")
w("")
return "\n".join(out)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--json", action="store_true", help="emit the raw data instead of the report"
)
args = parser.parse_args(argv)
try:
result = reconcile(collect(_repo_root()))
except (ValueError, KeyError, yaml.YAMLError, OSError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, indent=2, sort_keys=True) if args.json else render(result))
return 0
if __name__ == "__main__":
sys.exit(main())
+69
View File
@@ -0,0 +1,69 @@
# Registry for the service-matrix presence check (tools/docs/check_service_matrix.py).
#
# Every descriptor("externalKey", "configKey", ...) registered in
# core/common/ResolvedServiceCatalog.java must resolve to a row in the Service Matrix
# table in docs/services/index.md, keyed by the .md filename its row links to (no
# extension, no #anchor). A key resolves one of three ways:
#
# 1. exact match - externalKey literally equals a documented filename. No entry
# needed in this file; most services work this way.
# 2. aliases - externalKey differs from the filename that documents it, because
# it's an AWS SigV4 signing-name quirk (`kafka` -> msk.md) or a
# facet folded into another service's page (`ec2messages` -> ssm.md).
# 3. deferred - genuinely undocumented today, tracked with a reason. A NEW
# descriptor key that is neither an exact match, an alias target,
# nor listed here fails `check_service_matrix.py --strict`.
#
# The reverse direction is checked too: every docs/services/*.md page must be linked
# from a matrix row unless its slug is listed under `facet_pages:` (a page that
# documents a facet of another service and deliberately has no row of its own).
aliases:
apigateway: api-gateway # "API Gateway v1" row title
apigatewayv2: api-gateway # v2 facet, documented at api-gateway.md#v2
aps: managed-prometheus # AMP's SigV4 signing name is "aps"
kafka: msk # MSK's SigV4 signing name is "kafka"
mq: amazonmq # Amazon MQ's SigV4 signing name is "mq"
signin: iam # AWS Sign-In facet, at iam.md#aws-sign-in-login-credentials
sso: ssoadmin # descriptor("sso") is the Identity Center admin API; ssoadmin.md is its page
sso-oidc: ssooidc # IAM Identity Center OIDC REST API
events: eventbridge
servicediscovery: cloudmap # Cloud Map's SigV4 signing name
elasticmapreduce: emr # EMR's SigV4 signing name
logs: cloudwatch # CloudWatch Logs facet, at cloudwatch.md
monitoring: cloudwatch # CloudWatch Metrics facet, at cloudwatch.md#metrics
secretsmanager: secrets-manager
codeguru-reviewer: codegurureviewer # SigV4 signing name is hyphenated; the doc slug is not
cognito-identity: cognitoidentity # SigV4 signing name is hyphenated; the doc slug is not
app-integrations: appintegrations # SigV4 signing name is hyphenated; the doc slug is not
servicecatalog: service-catalog
cognito-idp: cognito # Cognito's SigV4 signing name
states: step-functions # Step Functions' SigV4 signing name
email: ses # SES's SigV4 signing name
es: opensearch # OpenSearch's SigV4 signing name (legacy Elasticsearch)
appconfigdata: appconfig # data-plane facet, at appconfig.md#data-plane
tagging: resource-groups-tagging # Resource Groups Tagging API's SigV4 signing name
elasticloadbalancing: elb # ELB v2's SigV4 signing name
elb: elb-classic # Classic (2012-06-01) key; the v2 page is elb.md
application-autoscaling: applicationautoscaling
elasticbeanstalk: elastic-beanstalk
ec2messages: ssm # SSM Messages facet, documented alongside ssm.md
iotdata: iot # IoT Data row shares iot.md with IoT Core
resource-explorer-2: resource-explorer # API version suffix; the page is resource-explorer.md
bedrock-agentcore-control: bedrock-agentcore # control plane owns the page; the data-plane row is the facet
# A NEW switch/REST descriptor not listed here and not resolvable via an alias fails
# `--strict`. Every entry MUST carry a `by:` expiry date (ISO, YYYY-MM-DD): once that
# date passes, `--strict` fails for it exactly as if it were never listed, even though
# the entry is still physically present in this file. This is deliberate - an
# unbounded deferred list is a silent, permanent bypass of the whole check, which is
# how Bedrock AgentCore (#2316) shipped a working service that stayed off the matrix
# for five days with nothing red anywhere. An expiring entry forces a human to either
# add the row or come back and renew the date with a fresh justification; it can never
# just be forgotten. Remove an entry outright once the referenced PR merges and the
# row lands - don't wait for it to expire.
deferred: []
# docs/services pages that intentionally have no Service Matrix row of their own.
facet_pages:
- cloudwatch-metric-filters-verification
+437
View File
@@ -0,0 +1,437 @@
# Registry of services whose Supported Actions tables are auto-generated.
#
# The action LIST comes from handler source (mode: switch|rest); the Description
# column is hand-written and PRESERVED across regen, keyed by action name.
# Per-service overrides: rename_actions (method->canonical), exclude_actions.
#
# Phase 1 covers single-table switch-mode services. REST controllers, multi-table
# docs, mixed-mode (ses), S3 query-string dispatch, and non-tabular docs are tracked
# as follow-ups; their switch handlers are acknowledged in deferred_handlers below so
# the new-service sentinel only fires on genuinely unregistered handlers.
services:
- service: account
doc: docs/services/account.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/account/AccountController.java, mode: rest }
- service: accessanalyzer
doc: docs/services/access-analyzer.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/accessanalyzer/AccessAnalyzerController.java, mode: rest }
- service: acm
doc: docs/services/acm.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/acm/AcmJsonHandler.java, mode: switch }
- service: applicationautoscaling
doc: docs/services/applicationautoscaling.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/applicationautoscaling/ApplicationAutoScalingJsonHandler.java, mode: switch }
- service: athena
doc: docs/services/athena.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/athena/AthenaJsonHandler.java, mode: switch }
- service: bedrock
doc: docs/services/bedrock.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/bedrock/BedrockController.java, mode: rest }
- service: bedrock-agentcore-control
doc: docs/services/bedrock-agentcore.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreControlController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreIdentityController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreGatewayController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreMemoryController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreToolsController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreGatewayRuleController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/bedrockagentcorecontrol/BedrockAgentCoreResourcePolicyController.java, mode: rest }
rename_actions:
CreateEndpoint: CreateAgentRuntimeEndpoint
GetEndpoint: GetAgentRuntimeEndpoint
UpdateEndpoint: UpdateAgentRuntimeEndpoint
DeleteEndpoint: DeleteAgentRuntimeEndpoint
ListEndpoints: ListAgentRuntimeEndpoints
Create: CreateWorkloadIdentity
Get: GetWorkloadIdentity
Update: UpdateWorkloadIdentity
Delete: DeleteWorkloadIdentity
List: ListWorkloadIdentities
CreateTarget: CreateGatewayTarget
GetTarget: GetGatewayTarget
UpdateTarget: UpdateGatewayTarget
DeleteTarget: DeleteGatewayTarget
ListTargets: ListGatewayTargets
- service: cloudformation
doc: docs/services/cloudformation.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationQueryHandler.java, mode: switch }
- service: cloudhsmv2
doc: docs/services/cloudhsmv2.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/cloudhsmv2/CloudHsmV2JsonHandler.java, mode: switch }
- service: cloudtrail
doc: docs/services/cloudtrail.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/cloudtrail/CloudTrailJsonHandler.java, mode: switch }
- service: codeartifact
doc: docs/services/codeartifact.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/codeartifact/CodeArtifactController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/V1TagsController.java, mode: rest }
rename_actions:
ListTagsForResourceByQuery: ListTagsForResource
exclude_actions:
- ListTags
- TagResourcePost
- TagResourcePut
- service: codegurureviewer
doc: docs/services/codegurureviewer.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/codegurureviewer/CodeGuruReviewerController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/SharedTagsController.java, mode: rest }
rename_actions:
ListTags: ListTagsForResource
TagResourcePost: TagResource
exclude_actions:
- ListTagsByQuery
- TagResourceByBody
- TagResourcePut
- UntagResourceByQuery
- service: marketplace
doc: docs/services/marketplace.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceCatalogController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementService.java, mode: switch }
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceEntitlementController.java, mode: switch }
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDeploymentController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceReportingController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceMeteringService.java, mode: switch }
- { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryController.java, mode: rest }
exclude_actions:
- AgreementType
- Status
- AcceptorAccountId
- OfferId
- OfferSetId
- ResourceIdentifier
- ResourceType
- PartyType
- BeforeStartTime
- AfterStartTime
- BeforeEndTime
- AfterEndTime
- BeforeLastUpdateTime
- AfterLastUpdateTime
- EndTimeBehaviorType
- EndTimeBehaviorReasonCode
- InitialAgreementId
- LicenseArn
- StartTime
- LastUpdateTime
- EndTime
- ASCENDING
- DESCENDING
- service: comprehend
doc: docs/services/comprehend.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/comprehend/ComprehendJsonHandler.java, mode: switch }
- service: controlcatalog
doc: docs/services/controlcatalog.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/controlcatalog/ControlCatalogController.java, mode: rest }
rename_actions:
GetControl: GetControl
ListControls: ListControls
- service: appintegrations
doc: docs/services/appintegrations.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/appintegrations/AppIntegrationsController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/SharedTagsController.java, mode: rest }
rename_actions:
ListTags: ListTagsForResource
TagResourcePost: TagResource
exclude_actions:
- ListTagsByQuery
- TagResourceByBody
- TagResourcePut
- UntagResourceByQuery
- service: connect
doc: docs/services/connect.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/connect/ConnectController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/SharedTagsController.java, mode: rest }
rename_actions:
ListTags: ListTagsForResource
TagResourcePost: TagResource
exclude_actions:
- ListTagsByQuery
- TagResourceByBody
- TagResourcePut
- UntagResourceByQuery
- service: rekognition
doc: docs/services/rekognition.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/rekognition/RekognitionJsonHandler.java, mode: switch }
- service: translate
doc: docs/services/translate.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/translate/TranslateJsonHandler.java, mode: switch }
- service: detective
doc: docs/services/detective.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/detective/DetectiveController.java, mode: rest }
- service: dms
doc: docs/services/dms.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/dms/DmsJsonHandler.java, mode: switch }
- service: docdb
doc: docs/services/docdb.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/docdb/DocDbQueryHandler.java, mode: switch }
- service: efs
doc: docs/services/efs.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/efs/EfsController.java, mode: rest }
- service: organizations
doc: docs/services/organizations.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsJsonHandler.java, mode: switch }
- service: elasticache
doc: docs/services/elasticache.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/elasticache/ElastiCacheQueryHandler.java, mode: switch }
- service: emr
doc: docs/services/emr.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/emr/EmrHandler.java, mode: switch }
- service: eventbridge
doc: docs/services/eventbridge.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeHandler.java, mode: switch }
- service: firehose
doc: docs/services/firehose.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/firehose/FirehoseJsonHandler.java, mode: switch }
- service: resource-explorer-2
doc: docs/services/resource-explorer.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/resourceexplorer2/ResourceExplorer2Controller.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/SharedTagsController.java, mode: rest }
rename_actions:
ListTags: ListTagsForResource
TagResourcePost: TagResource
exclude_actions:
- ListTagsByQuery
- TagResourceByBody
- TagResourcePut
- UntagResourceByQuery
- service: fis
doc: docs/services/fis.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/fis/FisController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/SharedTagsController.java, mode: rest }
rename_actions:
ListTags: ListTagsForResource
TagResourcePost: TagResource
exclude_actions:
- ListTagsByQuery
- TagResourceByBody
- TagResourcePut
- UntagResourceByQuery
- service: kinesis
doc: docs/services/kinesis.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/kinesis/KinesisJsonHandler.java, mode: switch }
- service: kinesisanalytics
doc: docs/services/kinesisanalytics.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/kinesisanalytics/KinesisAnalyticsV2JsonHandler.java, mode: switch }
- service: kms
doc: docs/services/kms.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/kms/KmsJsonHandler.java, mode: switch }
- service: memorydb
doc: docs/services/memorydb.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/memorydb/MemoryDbHandler.java, mode: switch }
- service: macie2
doc: docs/services/macie2.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/macie2/MacieController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/services/securityadmin/SecurityAdminController.java, mode: rest }
rename_actions:
ListAdmin: ListOrganizationAdminAccounts
- service: inspector2
doc: docs/services/inspector2.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/inspector2/Inspector2Controller.java, mode: rest }
- service: neptune
doc: docs/services/neptune.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/neptune/NeptuneQueryHandler.java, mode: switch }
- service: networkfirewall
doc: docs/services/network-firewall.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/networkfirewall/NetworkFirewallJsonHandler.java, mode: switch }
- service: ram
doc: docs/services/ram.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/ram/RamController.java, mode: rest }
- service: rds
doc: docs/services/rds.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/rds/RdsQueryHandler.java, mode: switch }
exclude_actions: [SubnetIds, VpcSecurityGroupIds, OptionsToRemove, ValuesToAdd, ValuesToRemove]
- service: redshift
doc: docs/services/redshift.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/redshift/RedshiftQueryHandler.java, mode: switch }
# SubnetIds / VpcSecurityGroupIds / IamRoles / AddIamRoles / RemoveIamRoles / TagKeys / DbGroups
# are labels of an inner member-name switch (memberKeyRegex), not actions, same shape as the
# rds exclusions above.
exclude_actions: [SubnetIds, VpcSecurityGroupIds, IamRoles, AddIamRoles, RemoveIamRoles, TagKeys, DbGroups]
- service: redshiftdata
doc: docs/services/redshift-data.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/redshiftdata/RedshiftDataJsonHandler.java, mode: switch }
# ExecuteSql / BatchExecuteSql are the deprecated pre-2020 operations: the handler
# recognizes them only to return a ValidationException, they are not supported actions.
exclude_actions: [ExecuteSql, BatchExecuteSql]
- service: cognitoidentity
doc: docs/services/cognitoidentity.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/cognitoidentity/CognitoIdentityJsonHandler.java, mode: switch }
- service: globalaccelerator
doc: docs/services/globalaccelerator.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/globalaccelerator/GlobalAcceleratorJsonHandler.java, mode: switch }
- service: datasync
doc: docs/services/datasync.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/datasync/DataSyncJsonHandler.java, mode: switch }
- service: route53
doc: docs/services/route53.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/route53/Route53Controller.java, mode: rest }
# JAX-RS method names use Java casing; map them onto the canonical Route 53 action names.
rename_actions:
AssociateVpcWithHostedZone: AssociateVPCWithHostedZone
DisassociateVpcFromHostedZone: DisassociateVPCFromHostedZone
CreateVpcAssociationAuthorization: CreateVPCAssociationAuthorization
DeleteVpcAssociationAuthorization: DeleteVPCAssociationAuthorization
ListVpcAssociationAuthorizations: ListVPCAssociationAuthorizations
ListHostedZonesByVpc: ListHostedZonesByVPC
GetDnssec: GetDNSSEC
- service: route53resolver
doc: docs/services/route53resolver.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/route53resolver/Route53ResolverJsonHandler.java, mode: switch }
- service: sagemaker
doc: docs/services/sagemaker.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/sagemaker/SageMakerJsonHandler.java, mode: switch }
- service: securityhub
doc: docs/services/securityhub.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/securityhub/SecurityHubController.java, mode: rest }
- { path: src/main/java/io/github/hectorvent/floci/core/common/SharedTagsController.java, mode: rest }
rename_actions:
ListTags: ListTagsForResource
TagResourcePost: TagResource
exclude_actions:
- ListTagsByQuery
- TagResourceByBody
- TagResourcePut
- UntagResourceByQuery
- service: secretsmanager
doc: docs/services/secrets-manager.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/secretsmanager/SecretsManagerJsonHandler.java, mode: switch }
- service: servicecatalog
doc: docs/services/service-catalog.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/servicecatalog/ServiceCatalogJsonHandler.java, mode: switch }
- service: servicequotas
doc: docs/services/servicequotas.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/servicequotas/ServiceQuotasJsonHandler.java, mode: switch }
- service: ssoadmin
doc: docs/services/ssoadmin.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java, mode: switch }
- service: identitystore
doc: docs/services/identitystore.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreJsonHandler.java, mode: switch }
- service: budgets
doc: docs/services/budgets.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/budgets/BudgetsJsonHandler.java, mode: switch }
- service: verifiedpermissions
doc: docs/services/verifiedpermissions.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/verifiedpermissions/VerifiedPermissionsJsonHandler.java, mode: switch }
- service: timestream-influxdb
doc: docs/services/timestream-influxdb.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/timestreaminfluxdb/TimestreamInfluxDbJsonHandler.java, mode: switch }
- service: sqs
doc: docs/services/sqs.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/sqs/SqsJsonHandler.java, mode: switch }
- { path: src/main/java/io/github/hectorvent/floci/services/sqs/SqsQueryHandler.java, mode: switch }
- service: stepfunctions
doc: docs/services/step-functions.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/stepfunctions/StepFunctionsJsonHandler.java, mode: switch }
- service: swf
doc: docs/services/swf.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/swf/SwfJsonHandler.java, mode: switch }
- service: wafv2
doc: docs/services/wafv2.md
sources:
- { path: src/main/java/io/github/hectorvent/floci/services/wafv2/WafV2Handler.java, mode: switch }
# Switch handlers that exist but are intentionally not yet covered (see reasons).
# A NEW switch handler not listed here and not registered above fails `--strict`.
deferred_handlers:
- { path: src/main/java/io/github/hectorvent/floci/services/apigatewayv2/ApiGatewayV2JsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/applicationautoscaling/ScalingPolicyAlarmActionHandler.java } # not a JSON/Query action dispatcher — its `case "X" ->` arms are the StepScaling AdjustmentType switch, not AWS actions
- { path: src/main/java/io/github/hectorvent/floci/services/autoscaling/AutoScalingQueryHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/bcmpricingcalculator/BcmPricingCalculatorJsonHandler.java } # focused workload-estimate surface
- { path: src/main/java/io/github/hectorvent/floci/services/bcmdataexports/BcmDataExportsJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/ce/CostExplorerJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/cloudcontrol/CloudControlJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/cloudmap/CloudMapHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/cloudwatch/logs/CloudWatchLogsHandler.java } # multi-table doc (nested handler)
- { path: src/main/java/io/github/hectorvent/floci/services/cloudwatch/logs/CloudWatchLogsMetricFilterHandler.java } # multi-table doc (nested handler, reached through CloudWatchLogsHandler)
- { path: src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java } # multi-table doc (nested handler)
- { path: src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java } # multi-table doc (nested handler)
- { path: src/main/java/io/github/hectorvent/floci/services/codebuild/CodeBuildJsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/codedeploy/CodeDeployJsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/cognito/CognitoJsonHandler.java } # non-tabular doc (grouped/inline); separate docs effort
- { path: src/main/java/io/github/hectorvent/floci/services/configservice/ConfigServiceJsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/cur/CurJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbJsonHandler.java } # multi-table doc
- { path: src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbStreamsJsonHandler.java } # multi-table doc
- { path: src/main/java/io/github/hectorvent/floci/services/ec2/Ec2QueryHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/ecr/EcrJsonHandler.java } # doc packs multiple actions per row (`A` / `B`) + REST endpoints; needs de-grouping
- { path: src/main/java/io/github/hectorvent/floci/services/ecs/EcsJsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/elasticbeanstalk/ElasticBeanstalkQueryHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/elb/ElbClassicQueryHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2QueryHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/glue/GlueJsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/iam/IamQueryHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/iam/StsQueryHandler.java } # sts (lives in iam package); single-table, registerable next
- { path: src/main/java/io/github/hectorvent/floci/services/lakeformation/LakeFormationJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/lightsail/LightsailJsonHandler.java } # non-tabular doc (grouped/inline); standalone action table to be added
- { path: src/main/java/io/github/hectorvent/floci/services/pricing/PricingJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/resourcegroupstagging/ResourceGroupsTaggingJsonHandler.java } # non-tabular doc (grouped/inline)
- { path: src/main/java/io/github/hectorvent/floci/services/ses/SesQueryHandler.java } # mixed-mode (Query + REST surface)
- { path: src/main/java/io/github/hectorvent/floci/services/sns/SnsJsonHandler.java } # multi-table doc
- { path: src/main/java/io/github/hectorvent/floci/services/sns/SnsQueryHandler.java } # multi-table doc
- { path: src/main/java/io/github/hectorvent/floci/services/ssm/Ec2MessagesJsonHandler.java } # multi-table doc
- { path: src/main/java/io/github/hectorvent/floci/services/ssm/SsmJsonHandler.java } # multi-table doc
- { path: src/main/java/io/github/hectorvent/floci/services/textract/TextractJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/transcribe/TranscribeJsonHandler.java } # no standalone action table yet
- { path: src/main/java/io/github/hectorvent/floci/services/transfer/TransferHandler.java } # multi-table doc
+201
View File
@@ -0,0 +1,201 @@
"""Tests for check_service_matrix.
Run with: pytest tools/docs -q (or: make docs-test)
"""
from __future__ import annotations
from datetime import date
import pytest
import check_service_matrix as c
# --------------------------------------------------------------------------- #
# Extraction
# --------------------------------------------------------------------------- #
def test_extract_descriptor_keys_basic():
src = '''
descriptor("ssm", "ssm", config.services().ssm().enabled(), true,
"ssm", storageMode(...), 5000L, null, ServiceProtocol.JSON,
protocols(ServiceProtocol.JSON),
Set.of("AmazonSSM."), Set.of("ssm"), Set.of(), Set.of()),
descriptor("sqs", "sqs", config.services().sqs().enabled(), true, ...),
'''
assert c.extract_descriptor_keys(src) == ["ssm", "sqs"]
def test_extract_descriptor_keys_ignores_other_string_literals():
# Only the first argument of descriptor(...) counts; targetPrefixes, credential
# scopes and the like must not be picked up even though they're also quoted.
src = 'descriptor("kafka", "msk", true, true, "msk", null, 5000L, null, X, Y, Set.of("kafka-signing-prefix."), Set.of("kafka"), Set.of(), Set.of())'
assert c.extract_descriptor_keys(src) == ["kafka"]
def test_count_descriptor_calls_matches_literal_keys_when_all_literal():
src = '''
descriptor("ssm", "ssm", true, true, ...),
descriptor("sqs", "sqs", true, true, ...),
'''
assert c.count_descriptor_calls(src) == len(c.extract_descriptor_keys(src)) == 2
def test_count_descriptor_calls_excludes_the_definition():
src = '''
private static ServiceDescriptor descriptor(
String externalKey,
String configKey) {
return new ServiceDescriptor(externalKey, configKey);
}
'''
assert c.count_descriptor_calls(src) == 0
assert c.extract_descriptor_keys(src) == []
def test_count_descriptor_calls_catches_a_non_literal_key():
# A future externalKey built from a variable/constant instead of a plain string
# literal must not be silently invisible: the call-site count and the literal-key
# count have to disagree so main() can fail loudly instead of under-counting.
src = '''
descriptor("ssm", "ssm", true, true, ...),
descriptor(SOME_CONSTANT, "other", true, true, ...),
'''
assert c.count_descriptor_calls(src) == 2
assert len(c.extract_descriptor_keys(src)) == 1
def test_extract_matrix_slugs_scoped_to_table():
md = """
# Services Overview
Some prose with an unrelated [link](not-a-service.md) that must not be picked up.
## Service Matrix
| Service | Endpoint | Protocol | Supported operations |
|---|---|---|---|
| [SSM](ssm.md) | ... | JSON 1.1 | 22 |
| [CloudWatch Metrics](cloudwatch.md#metrics) | ... | Query | 11 |
## Common Setup
Another [link](also-not-a-service.md) below the table.
"""
assert c.extract_matrix_slugs(md) == {"ssm", "cloudwatch"}
def test_extract_matrix_slugs_strips_anchor():
md = "## Service Matrix\n\n| [API Gateway v2](api-gateway.md#v2) | | | 48 |\n\n## Common Setup\n"
assert c.extract_matrix_slugs(md) == {"api-gateway"}
def test_extract_matrix_slugs_raises_on_missing_heading():
# A renamed/reordered heading must fail loudly rather than silently returning an
# empty slug set, which would make every registered service look undocumented.
md = "# Services Overview\n\nNo matrix heading here.\n"
with pytest.raises(ValueError, match="Service Matrix"):
c.extract_matrix_slugs(md)
# --------------------------------------------------------------------------- #
# Classification
# --------------------------------------------------------------------------- #
def test_find_undocumented_exact_match_needs_no_alias():
undocumented, expired = c.find_undocumented(
keys=["ssm", "sqs"],
slugs={"ssm", "sqs"},
aliases={},
deferred=[],
today=date(2026, 1, 1),
)
assert undocumented == []
assert expired == []
def test_find_undocumented_alias_resolves():
undocumented, expired = c.find_undocumented(
keys=["kafka"],
slugs={"msk"},
aliases={"kafka": "msk"},
deferred=[],
today=date(2026, 1, 1),
)
assert undocumented == []
def test_find_undocumented_flags_genuine_gap():
undocumented, expired = c.find_undocumented(
keys=["swf"],
slugs={"ssm"},
aliases={},
deferred=[],
today=date(2026, 1, 1),
)
assert undocumented == ["swf"]
assert expired == []
def test_find_undocumented_unexpired_deferred_is_silenced():
d = c.DeferredEntry(key="bedrock-agentcore", reason="tracked in #2436", by=date(2026, 9, 15))
undocumented, expired = c.find_undocumented(
keys=["bedrock-agentcore"],
slugs=set(),
aliases={},
deferred=[d],
today=date(2026, 8, 22),
)
assert undocumented == []
assert expired == []
def test_find_undocumented_expired_deferred_reports_and_still_flags():
# This is the case the mandatory `by:` date exists to catch: a deferred entry that
# has run past its expiry is reported distinctly (so the message names the date
# and reason) AND still counts as undocumented - it cannot silently keep passing.
d = c.DeferredEntry(key="bedrock-agentcore", reason="tracked in #2436", by=date(2026, 9, 15))
undocumented, expired = c.find_undocumented(
keys=["bedrock-agentcore"],
slugs=set(),
aliases={},
deferred=[d],
today=date(2026, 10, 1),
)
assert undocumented == ["bedrock-agentcore"]
assert expired == [d]
def test_find_undocumented_deferred_entry_already_resolved_is_not_flagged():
# If the row was added but the registry entry wasn't cleaned up, the key resolves
# via the matrix itself regardless of expiry - no warning either way.
d = c.DeferredEntry(key="bedrock-agentcore", reason="tracked in #2436", by=date(2020, 1, 1))
undocumented, expired = c.find_undocumented(
keys=["bedrock-agentcore"],
slugs={"bedrock-agentcore"},
aliases={},
deferred=[d],
today=date(2026, 8, 22),
)
assert undocumented == []
assert expired == []
# --------------------------------------------------------------------------- #
# Unlinked pages
# --------------------------------------------------------------------------- #
def test_find_unlinked_pages_flags_a_page_with_no_row(tmp_path):
for name in ("index.md", "ssm.md", "elb.md", "elb-classic.md"):
(tmp_path / name).write_text("# page\n")
# index.md is the matrix page itself; elb.md is linked; elb-classic.md is not.
assert c.find_unlinked_pages(tmp_path, slugs={"ssm", "elb"}, facet_pages=set()) == ["elb-classic"]
def test_find_unlinked_pages_honours_facet_exemption(tmp_path):
for name in ("index.md", "lambda.md", "lambda-microvms.md"):
(tmp_path / name).write_text("# page\n")
assert c.find_unlinked_pages(tmp_path, slugs={"lambda"}, facet_pages={"lambda-microvms"}) == []
def test_find_unlinked_pages_ignores_non_markdown(tmp_path):
(tmp_path / "ssm.md").write_text("# page\n")
(tmp_path / "diagram.svg").write_text("<svg/>")
assert c.find_unlinked_pages(tmp_path, slugs={"ssm"}, facet_pages=set()) == []
+277
View File
@@ -0,0 +1,277 @@
"""Tests for regen_action_docs.
Run with: pytest tools/docs -q (or: make docs-test)
"""
from __future__ import annotations
from pathlib import Path
import pytest
import regen_action_docs as r
# --------------------------------------------------------------------------- #
# Extraction: switch
# --------------------------------------------------------------------------- #
def test_extract_switch_single_label():
src = """
return switch (action) {
case "CreateSecret" -> handleCreate(req);
case "GetSecretValue" -> handleGet(req);
default -> error();
};
"""
assert r.extract_switch_actions(src) == ["CreateSecret", "GetSecretValue"]
def test_extract_switch_multi_label_arm():
# A single arm carrying several labels (real case: CloudFormation StackSets).
src = 'case "ListStackSets", "DescribeStackSet", "CreateStackSet" -> handleStackSets(req);'
assert r.extract_switch_actions(src) == [
"ListStackSets",
"DescribeStackSet",
"CreateStackSet",
]
def test_extract_switch_multiline_arm():
# Labels wrapped across lines (valid Java 14+ switch syntax) must all be captured,
# and the capture must stop at the arm's `->` rather than running into the next
# arm's `{ ... }` body. A miss here would be invisible: the doc and the regenerated
# output would agree on the omission and docs-check would stay green.
src = '''
return switch (action) {
case "CreateStackSet",
"DescribeStackSet",
"ListStackSets" -> handleStackSets(req);
case "GetTemplate" -> { return handleGet(req); }
};
'''
assert r.extract_switch_actions(src) == [
"CreateStackSet",
"DescribeStackSet",
"ListStackSets",
"GetTemplate",
]
def test_extract_switch_ignores_non_pascal_and_default():
src = '''
case "lowercase" -> a();
case null, default -> b();
case "Good" -> c();
'''
assert r.extract_switch_actions(src) == ["Good"]
def test_extract_switch_excludes_screaming_case_enum_labels():
# A handler may contain non-action switches (e.g. state enums). SCREAMING_CASE
# labels must not be picked up as actions; PascalCase actions still are.
src = '''
return switch (action) {
case "EnableRule" -> enable();
case "DisableRule" -> disable();
};
return switch (state.toUpperCase()) {
case "DISABLED" -> RuleState.DISABLED;
case "ENABLED" -> RuleState.ENABLED;
};
'''
assert r.extract_switch_actions(src) == ["EnableRule", "DisableRule"]
# --------------------------------------------------------------------------- #
# Extraction: rest + overrides
# --------------------------------------------------------------------------- #
REST_CONTROLLER = """
@Path("/")
public class ThingController {
@POST
public Response createThing(Req r) { return ok(); }
@GET
public Response listThingsPost(Req r) { return ok(); }
@PUT
public Response updateV2Thing(Req r) { return ok(); }
}
"""
def test_extract_rest_ucfirsts_method_names():
assert r.extract_rest_actions(REST_CONTROLLER) == [
"CreateThing",
"ListThingsPost",
"UpdateV2Thing",
]
def test_extract_rest_requires_class_path():
no_path = REST_CONTROLLER.replace('@Path("/")\n', "")
assert r.extract_rest_actions(no_path) == []
def test_extract_rest_skips_comments_between_annotation_and_method():
# A Javadoc or inline comment sitting between the verb annotation and the method
# signature must not reset the lookahead and drop the action.
src = '''
@Path("/")
public class C {
@POST
/** Creates a thing. */
public Response createThing(Req r) { return ok(); }
@GET
// list them
public Response listThings(Req r) { return ok(); }
}
'''
assert r.extract_rest_actions(src) == ["CreateThing", "ListThings"]
def test_extract_rest_inherits_methods_from_superclass(tmp_path: Path):
base_src = """
public abstract class BaseController {
@GET
public Response getBaseItem() { return ok(); }
@DELETE
public Response deleteBaseItem() { return ok(); }
}
"""
sub_src = """
@Path("/items")
public class ChildController extends BaseController {
@POST
public Response createChildItem() { return ok(); }
}
"""
base_file = tmp_path / "BaseController.java"
sub_file = tmp_path / "ChildController.java"
base_file.write_text(base_src, encoding="utf-8")
sub_file.write_text(sub_src, encoding="utf-8")
actions = r.extract_rest_actions(sub_src, sub_file)
assert actions == ["GetBaseItem", "DeleteBaseItem", "CreateChildItem"]
def test_extract_actions_applies_rename_and_exclude(tmp_path: Path):
src = tmp_path / "ThingController.java"
src.write_text(REST_CONTROLLER)
entry = r.ServiceEntry(
service="thing",
doc=tmp_path / "thing.md",
sources=[r.Source(path=src, mode="rest")],
rename_actions={"UpdateV2Thing": "UpdateThing"},
exclude_actions=frozenset({"ListThingsPost"}),
)
assert r.extract_actions(entry) == ["CreateThing", "UpdateThing"]
def test_extract_actions_dedupes_across_sources(tmp_path: Path):
a = tmp_path / "A.java"
b = tmp_path / "B.java"
a.write_text('case "Foo" -> x();\ncase "Bar" -> y();')
b.write_text('case "Bar" -> y();\ncase "Baz" -> z();')
entry = r.ServiceEntry(
service="s",
doc=tmp_path / "s.md",
sources=[r.Source(path=a, mode="switch"), r.Source(path=b, mode="switch")],
)
assert r.extract_actions(entry) == ["Foo", "Bar", "Baz"]
# --------------------------------------------------------------------------- #
# Table parsing: robustness
# --------------------------------------------------------------------------- #
def test_split_row_honors_escaped_pipe():
assert r._split_row(r"| `Foo` | a \| b |") == ["`Foo`", "a | b"]
def test_parse_marker_block_preserves_descriptions():
md = (
"pre\n"
f"{r.MARKER_START}\n"
"| Action | Description |\n"
"| --- | --- |\n"
"| `CreateSecret` | Create a new secret |\n"
"| `GetSecretValue` | - |\n"
f"{r.MARKER_END}\n"
"post\n"
)
_, descs, _ = r.parse_marker_block(md)
assert descs == {"CreateSecret": "Create a new secret", "GetSecretValue": ""}
def test_parse_marker_block_rejects_missing_markers():
with pytest.raises(ValueError):
r.parse_marker_block("no markers here")
def test_parse_table_rejects_non_table_line():
body = "| Action | Description |\n| --- | --- |\nstray prose line\n"
with pytest.raises(ValueError):
r._parse_table(body)
def test_parse_table_rejects_extra_columns():
# An unescaped pipe in a hand-edited description yields a 3-cell row; reject it
# loudly rather than silently dropping the trailing text.
body = "| Action | Description |\n| --- | --- |\n| `Foo` | a | b |\n"
with pytest.raises(ValueError):
r._parse_table(body)
def test_pipe_in_description_round_trips():
# A description containing a literal pipe survives render -> parse -> render
# unchanged (render escapes, the parser unescapes), and stays idempotent.
block = r.render_marker_block(["Foo"], {"Foo": "does a | b"})
assert r"\|" in block # escaped on render
md = f"x\n{r.MARKER_START}\n{block}{r.MARKER_END}\n"
_, descs, _ = r.parse_marker_block(md)
assert descs["Foo"] == "does a | b" # unescaped on parse
assert r.render_marker_block(["Foo"], descs) == block # idempotent
# --------------------------------------------------------------------------- #
# Regeneration: preservation, placeholders, orphans, idempotency
# --------------------------------------------------------------------------- #
DOC = (
"# Svc\n\n## Supported Actions\n\n"
f"{r.MARKER_START}\n"
"| Action | Description |\n"
"| --- | --- |\n"
"| `CreateSecret` | Create a new secret |\n"
"| `OldAction` | gone from code |\n"
f"{r.MARKER_END}\n\n## Configuration\nprose\n"
)
def test_regenerate_preserves_known_and_placeholders_new():
actions = ["CreateSecret", "GetSecretValue"]
new, orphans = r.regenerate_doc_content(actions, DOC)
_, descs, _ = r.parse_marker_block(new)
assert descs["CreateSecret"] == "Create a new secret" # preserved
assert descs["GetSecretValue"] == "" # new -> placeholder
assert orphans == ["OldAction"] # dropped + reported
assert "OldAction" not in new
def test_regenerate_is_idempotent():
actions = ["CreateSecret", "GetSecretValue"]
once, _ = r.regenerate_doc_content(actions, DOC)
twice, _ = r.regenerate_doc_content(actions, once)
assert once == twice
def test_regenerate_touches_only_marker_block():
actions = ["CreateSecret", "GetSecretValue"]
new, _ = r.regenerate_doc_content(actions, DOC)
assert new.split(r.MARKER_START)[0] == DOC.split(r.MARKER_START)[0]
assert new.split(r.MARKER_END)[1] == DOC.split(r.MARKER_END)[1]
def test_render_marker_block_shape():
out = r.render_marker_block(["Foo"], {"Foo": "does foo"})
assert out == "| Action | Description |\n| --- | --- |\n| `Foo` | does foo |\n"
+167
View File
@@ -0,0 +1,167 @@
"""Tests for regen_cfn_resource_types.
Run with: pytest tools/docs -q (or: make docs-test)
"""
from __future__ import annotations
import pytest
import regen_cfn_resource_types as r
BASE_CONFIG = {
"order": ["AWS::S3", "AWS::CloudFormation"],
"service_labels": {
"AWS::S3": "S3",
"AWS::CloudFormation": "CloudFormation",
"AWS::SQS": "SQS",
},
}
def render(inventory, **overrides):
config = {**BASE_CONFIG, **overrides}
return r.render_rows(inventory, config)
# --------------------------------------------------------------------------- #
# Type naming
# --------------------------------------------------------------------------- #
def test_namespace_and_leaf_of_a_three_segment_type():
assert r.namespace_of("AWS::SQS::Queue") == "AWS::SQS"
assert r.leaf_of("AWS::SQS::Queue") == "Queue"
def test_two_segment_type_keeps_its_prefix():
# Abbreviating Custom::DynamoDBReplica to DynamoDBReplica would read as an AWS
# resource type that does not exist.
assert r.namespace_of("Custom::DynamoDBReplica") == "Custom"
assert r.leaf_of("Custom::DynamoDBReplica") == "Custom::DynamoDBReplica"
# --------------------------------------------------------------------------- #
# Rendering
# --------------------------------------------------------------------------- #
def test_renders_one_row_per_namespace_in_configured_order():
lines, warnings = render(
[("AWS::SQS::Queue", "SqsCfnProvisioner"), ("AWS::S3::Bucket", "LEGACY_SWITCH")]
)
assert lines[0] == "| Service | Resource types |"
# S3 is in the order list, SQS is not, so SQS is appended after it.
assert lines[2] == "| S3 | `Bucket` |"
assert lines[3] == "| SQS | `Queue` |"
assert any("not in the order list" in w for w in warnings)
def test_unlisted_types_sort_alphabetically_after_curated_ones():
inventory = [
("AWS::S3::AccessPoint", "LEGACY_SWITCH"),
("AWS::S3::Bucket", "LEGACY_SWITCH"),
("AWS::S3::BucketPolicy", "LEGACY_SWITCH"),
]
lines, _ = render(inventory, type_order={"AWS::S3": ["Bucket", "BucketPolicy"]})
assert lines[2] == "| S3 | `Bucket`, `BucketPolicy`, `AccessPoint` |"
def test_notes_and_row_notes_are_appended():
lines, _ = render(
[("AWS::S3::Bucket", "LEGACY_SWITCH")],
notes={"AWS::S3::Bucket": "accepted; policy not enforced"},
row_notes={"AWS::S3": "Trailing prose."},
)
assert lines[2] == "| S3 | `Bucket` (accepted; policy not enforced). Trailing prose. |"
def test_display_names_override_the_leaf():
lines, _ = render(
[("AWS::CDK::Metadata", "LEGACY_SWITCH")],
service_labels={**BASE_CONFIG["service_labels"], "AWS::CDK": "CDK"},
display_names={"AWS::CDK::Metadata": "CDK::Metadata"},
)
assert "`CDK::Metadata`" in lines[2]
def test_merged_namespace_folds_into_one_row():
lines, _ = render(
[
("AWS::CloudFormation::CustomResource", "LEGACY_SWITCH"),
("Custom::DynamoDBReplica", "LEGACY_SWITCH"),
],
merge_namespaces={"Custom": "AWS::CloudFormation"},
)
rows = [line for line in lines if line.startswith("| CloudFormation ")]
assert len(rows) == 1, "Custom::* belongs in the CloudFormation row, not a second one"
assert "`Custom::DynamoDBReplica`" in rows[0]
def test_extra_types_appear_after_inventory_types():
lines, _ = render(
[("AWS::CloudFormation::CustomResource", "LEGACY_SWITCH")],
extra_types={
"AWS::CloudFormation": [
{"name": "Stack", "note": "nested stacks", "reason": "not a provisioner type"}
]
},
)
assert lines[2] == "| CloudFormation | `CustomResource`, `Stack` (nested stacks) |"
# --------------------------------------------------------------------------- #
# Warnings: these are the drift the gate exists to catch
# --------------------------------------------------------------------------- #
def test_warns_when_a_namespace_has_no_label():
_, warnings = render([("AWS::Athena::WorkGroup", "AthenaCfnProvisioner")])
assert any("no service_labels entry for AWS::Athena" in w for w in warnings)
def test_warns_when_type_order_names_a_type_nothing_provisions():
_, warnings = render(
[("AWS::S3::Bucket", "LEGACY_SWITCH")],
type_order={"AWS::S3": ["Bucket", "Buckett"]},
)
assert any("lists Buckett" in w for w in warnings)
def test_warns_when_an_extra_type_has_no_reason():
_, warnings = render(
[("AWS::CloudFormation::CustomResource", "LEGACY_SWITCH")],
extra_types={"AWS::CloudFormation": [{"name": "Stack"}]},
)
assert any("has no reason" in w for w in warnings)
def test_warns_when_a_type_is_claimed_by_both_the_switch_and_a_provisioner():
# The registry wins, so the switch arm is dead code that still looks live.
warnings = r.check_no_double_ownership(
[("AWS::SQS::Queue", "LEGACY_SWITCH"), ("AWS::SQS::Queue", "SqsCfnProvisioner")]
)
assert len(warnings) == 1
assert "SqsCfnProvisioner" in warnings[0]
def test_no_double_ownership_warning_when_each_type_has_one_owner():
assert r.check_no_double_ownership(
[("AWS::SQS::Queue", "SqsCfnProvisioner"), ("AWS::S3::Bucket", "LEGACY_SWITCH")]
) == []
# --------------------------------------------------------------------------- #
# Inventory parsing and splicing
# --------------------------------------------------------------------------- #
def test_load_inventory_rejects_a_malformed_row(tmp_path):
path = tmp_path / "inv.tsv"
path.write_text("AWS::SQS::Queue\tSqsCfnProvisioner\nAWS::S3::Bucket\n", encoding="utf-8")
with pytest.raises(ValueError, match="expected 'type<TAB>owner'"):
r.load_inventory(path)
def test_splice_replaces_only_the_marked_block():
md = f"before\n{r.MARKER_START}\nold table\n{r.MARKER_END}\nafter\n"
assert r.splice(md, ["new table"]) == (
f"before\n{r.MARKER_START}\nnew table\n{r.MARKER_END}\nafter\n"
)
def test_splice_requires_exactly_one_marker_pair():
with pytest.raises(ValueError, match="exactly one"):
r.splice("no markers here\n", ["x"])
+320
View File
@@ -0,0 +1,320 @@
"""Tests for service_inventory.
Every test here pins a parser trap that was found by hand-checking the real sources,
and that would otherwise have made this tool produce a *tenth* wrong number in a repo
that already had nine. They are written against synthetic sources so they keep failing
for the right reason when the real files change.
Run with: pytest tools/docs -q (or: make docs-test)
"""
from __future__ import annotations
from datetime import date
import pytest
import service_inventory as si
# --------------------------------------------------------------------------- #
# Key normalisation
# --------------------------------------------------------------------------- #
def test_key_normalises_across_naming_conventions():
# The same service, spelled three ways by three sources.
assert si.key("bcmDataExports") == si.key("bcm-data-exports") == si.key("bcmdataexports")
def test_key_does_not_collide_across_distinct_services():
assert si.key("rds") != si.key("rds-data")
# --------------------------------------------------------------------------- #
# Descriptor parsing
# --------------------------------------------------------------------------- #
SIMPLE = '''
descriptor("ssm", "ssm", config.services().ssm().enabled(), true,
"ssm", storageMode(...), 5000L, null, ServiceProtocol.JSON,
protocols(ServiceProtocol.JSON),
Set.of("AmazonSSM."), Set.of("ssm"), Set.of(), Set.of()),
'''
def test_parses_key_config_key_and_status_flag():
[d] = si.parse_descriptors(SIMPLE)
assert d["external_key"] == "ssm"
assert d["config_key"] == "ssm"
assert d["toggles"] == ["ssm"]
assert d["in_status"] is True
def test_conjunction_toggle_wrapping_onto_a_continuation_line():
# rds-data and redshift-data wrap because their enabled expression is a conjunction.
# A line-oriented parser reads `true` off the *first* line, which for these two is
# the configKey's line, and gets both the toggles and includeInStatus wrong.
src = '''
descriptor("rds-data", "rds-data",
config.services().rds().enabled() && config.services().rdsData().enabled(), false,
null, null, 5000L, null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON),
Set.of(), Set.of("rds-data"), Set.of(), Set.of(RdsDataController.class)),
'''
[d] = si.parse_descriptors(src)
assert d["external_key"] == "rds-data"
assert d["toggles"] == ["rds", "rdsData"]
assert d["in_status"] is False
def test_status_flag_is_read_from_the_fourth_argument_not_the_first_true():
# `includeInStatus=false` on a descriptor whose second line contains other literals.
src = '''
descriptor("sts", "iam", config.services().iam().enabled(), false,
null, null, 5000L, AwsNamespaces.STS, ServiceProtocol.QUERY,
protocols(ServiceProtocol.QUERY),
Set.of(), Set.of("sts"), Set.of(), Set.of()),
'''
assert si.parse_descriptors(src)[0]["in_status"] is False
def test_nested_commas_do_not_split_an_argument():
args = si.split_top_level_args('f("a", Set.of("b", "c"), true)', 1)
assert args == ['"a"', 'Set.of("b", "c")', "true"]
def test_unbalanced_parens_raise_rather_than_returning_a_short_list():
with pytest.raises(ValueError, match="unbalanced"):
si.split_top_level_args('f("a", "b"', 1)
def test_non_literal_external_key_raises():
# check_service_matrix.py catches this case by counting call sites; here it must
# fail loudly rather than silently dropping a service from every count.
with pytest.raises(ValueError, match="not a plain string literal"):
si.parse_descriptors("descriptor(KEY, \"ssm\", true, true, ...)")
# --------------------------------------------------------------------------- #
# Config accessors
# --------------------------------------------------------------------------- #
def test_config_accessors_exclude_docker_network_and_include_non_service_configs():
# DuckConfig does not end in ServiceConfig, so a `*ServiceConfig` pattern misses it
# and under-counts by one. dockerNetwork() returns Optional<String> and must not
# be counted at all.
src = """
interface ServicesConfig {
Optional<String> dockerNetwork();
SsmServiceConfig ssm();
SqsServiceConfig sqs();
DuckConfig duck();
}
"""
assert si.parse_config_accessors(src) == ["duck", "sqs", "ssm"]
def test_missing_services_config_interface_raises():
with pytest.raises(ValueError, match="ServicesConfig not found"):
si.parse_config_accessors("interface Unrelated { }")
# --------------------------------------------------------------------------- #
# Matrix rows
# --------------------------------------------------------------------------- #
MATRIX = f"""{si.csm.MATRIX_HEADING}
| Service | Endpoint | Protocol | Supported operations |
|---|---|---|---|
| [DynamoDB](dynamodb.md) | `POST /` | JSON 1.1 | 28 |
| [DynamoDB Streams](dynamodb.md#streams) | `POST /` | JSON 1.1 | 4 |
| [IoT Core](iot.md) | `/things` | REST JSON | 40 |
| [IoT Data](iot.md) | `/topics` | REST JSON | 5 |
| [S3](s3.md) | `/{{bucket}}` | REST XML | 58 |
{si.csm.MATRIX_END_HEADING}
"""
def test_facet_rows_are_detected_by_duplicate_slug_not_by_anchor():
# IoT Core and IoT Data both link plain `iot.md` with no anchor at all. An
# anchor-based rule finds only the DynamoDB pair and over-counts pages by one.
rows = si.parse_matrix_rows(MATRIX)
assert len(rows) == 5
assert {r["slug"] for r in rows} == {"dynamodb", "iot", "s3"}
assert rows[3]["anchor"] is None
def test_rows_outside_the_matrix_headings_are_ignored():
src = f"| [Elsewhere](elsewhere.md) | x |\n\n{MATRIX}"
assert all(r["slug"] != "elsewhere" for r in si.parse_matrix_rows(src))
# --------------------------------------------------------------------------- #
# README
# --------------------------------------------------------------------------- #
def test_category_names_do_not_split_inside_parentheses():
# "IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM)" is one service name.
# Splitting on every comma turns it into four, three of which ("OIDC", "SCIM)")
# are not service names and inflate the count.
src = (
"| Category | Services |\n|---|---|\n"
"| Identity | IAM, IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM), KMS |\n"
"\nFor operation-level compatibility, see the Services Overview.\n"
)
assert si.parse_readme_category_names(src) == [
"IAM",
"IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM)",
"KMS",
]
def test_detail_rows_take_the_first_cell_only():
src = (
"| Service | How it works | Notable features |\n|---|---|---|\n"
"| SQS | In-process | Standard and FIFO queues, DLQ |\n"
"</details>\n"
)
assert si.parse_readme_detail_rows(src) == ["SQS"]
# --------------------------------------------------------------------------- #
# Reconciliation
# --------------------------------------------------------------------------- #
def _data(**overrides) -> dict:
base = {
"descriptors": [
{"external_key": "iam", "config_key": "iam", "toggles": ["iam"], "in_status": True},
{"external_key": "sts", "config_key": "iam", "toggles": ["iam"], "in_status": False},
{"external_key": "s3", "config_key": "s3", "toggles": ["s3"], "in_status": True},
],
"config_accessors": ["iam", "s3", "duck"],
"yaml_keys": ["iam", "s3", "duck"],
"yaml_enabled": ["iam", "s3"],
"packages": ["iam", "s3"],
"matrix_rows": [
{"label": "IAM", "slug": "iam", "anchor": None},
{"label": "AWS Sign-In", "slug": "iam", "anchor": "signin"},
{"label": "S3", "slug": "s3", "anchor": None},
],
"doc_pages": ["iam", "s3"],
"readme_categories": ["IAM", "S3"],
"readme_details": ["S3"],
"action_tables": ["s3"],
"aliases": {"sts": "iam"},
"facet_pages": [],
"deferred": [],
}
base.update(overrides)
return base
def test_shared_toggle_is_reported_with_every_descriptor_it_gates():
r = si.reconcile(_data())
assert r["catalog"]["shared_toggles"] == {"iam": ["iam", "sts"]}
assert r["counts"]["catalog_toggles"] == 2
def test_facet_descriptor_is_the_non_owner_of_a_shared_page():
r = si.reconcile(_data())
assert r["catalog"]["facet_descriptors"] == ["sts"]
assert r["counts"]["catalog_distinct_products"] == 2
def test_non_aws_entries_are_subtracted_from_the_aws_key_count():
r = si.reconcile(_data())
assert r["config"]["non_aws"] == ["duck"]
assert r["counts"]["yaml_aws_keys"] == 2
def test_config_and_yaml_agree_after_normalisation():
r = si.reconcile(_data(config_accessors=["rdsData"], yaml_keys=["rds-data"]))
assert r["config"]["accessor_only"] == []
assert r["config"]["yaml_only"] == []
def test_unregistered_package_is_reported_when_not_noted():
r = si.reconcile(_data(packages=["iam", "s3", "newthing"]))
assert r["packages"]["unregistered"] == ["newthing"]
def test_noted_package_is_not_reported_as_unregistered():
si.PACKAGE_NOTES["temporary"] = "test fixture"
try:
r = si.reconcile(_data(packages=["iam", "s3", "temporary"]))
assert r["packages"]["unregistered"] == []
assert "temporary" in r["packages"]["noted"]
finally:
del si.PACKAGE_NOTES["temporary"]
def test_facet_row_missing_from_readme_is_separated_from_a_real_gap():
# "AWS Sign-In" shares iam.md, so its absence from the README is by design.
# A non-facet row missing from the README is a genuine finding.
r = si.reconcile(_data(readme_categories=["IAM"]))
assert r["readme"]["missing_from_categories"] == ["S3"]
assert r["readme"]["missing_facet_rows"] == ["AWS Sign-In"]
def test_readme_alias_covers_several_matrix_rows_at_once():
si.README_NAME_ALIASES["Identity"] = ["IAM", "AWS Sign-In"]
try:
r = si.reconcile(_data(readme_categories=["Identity", "S3"]))
assert r["readme"]["unmatched_category_names"] == []
assert r["readme"]["missing_facet_rows"] == []
finally:
del si.README_NAME_ALIASES["Identity"]
def test_readme_name_matching_no_row_is_reported_rather_than_dropped():
r = si.reconcile(_data(readme_categories=["IAM", "S3", "Imaginary Service"]))
assert r["readme"]["unmatched_category_names"] == ["Imaginary Service"]
def test_deferral_within_the_warning_window_is_flagged():
deferred = [{"key": "x", "reason": "r", "by": "2026-09-15"}]
r = si.reconcile(_data(deferred=deferred), today=date(2026, 9, 14))
assert r["docs"]["deferred_expiring"] == deferred
def test_deferral_beyond_the_warning_window_is_not_flagged():
deferred = [{"key": "x", "reason": "r", "by": "2027-01-01"}]
r = si.reconcile(_data(deferred=deferred), today=date(2026, 9, 14))
assert r["docs"]["deferred_expiring"] == []
def test_row_exempt_facet_pages_are_counted_without_requiring_a_matrix_row():
r = si.reconcile(_data(doc_pages=["iam", "s3", "iam-verification"],
facet_pages=["iam-verification"]))
assert r["counts"]["matrix_pages"] == 2
assert r["counts"]["doc_pages"] == 3
assert r["docs"]["pages_without_row"] == []
assert r["docs"]["rows_without_page"] == []
def test_facet_exemption_does_not_hide_an_unregistered_page():
r = si.reconcile(_data(doc_pages=["iam", "s3", "iam-verification", "unregistered"],
facet_pages=["iam-verification"]))
assert r["docs"]["pages_without_row"] == ["unregistered"]
# --------------------------------------------------------------------------- #
# End to end, against the real repo
# --------------------------------------------------------------------------- #
def test_real_sources_parse_and_render():
data = si.collect(si._repo_root())
result = si.reconcile(data)
counts = result["counts"]
# Not pinned to exact values: they move every time a service lands. What must hold
# is that every source parsed to something plausible and the two gated equalities
# this tool reports as green really are green.
assert all(v > 50 for v in counts.values())
assert counts["matrix_pages"] == len(set(data["doc_pages"]) - set(data["facet_pages"]))
assert counts["config_accessors"] == counts["yaml_keys"]
assert result["docs"]["pages_without_row"] == []
assert result["docs"]["rows_without_page"] == []
assert result["packages"]["unregistered"] == []
assert "THE ANSWER" in si.render(result)
def test_report_never_suggests_publishing_a_count():
# The hard rule the tool exists to protect, asserted rather than trusted to prose.
text = si.render(si.reconcile(si.collect(si._repo_root())))
assert "Never write any of them into README.md or docs/" in text