Skip to content

feat: Phase 1 AWS depth & stabilization for v1.0 - #96

Merged
skyoo2003 merged 5 commits into
mainfrom
feat/phase1-stabilization
Jul 19, 2026
Merged

skyoo2003 merged 5 commits into
mainfrom
feat/phase1-stabilization

Conversation

@skyoo2003

Copy link
Copy Markdown
Owner

Advances the four open Phase 1 (AWS Depth & Stabilization) roadmap items toward a stable v1.0. Scoped as a stabilization release, not a complete-every-operation one.

Unimplemented-op behavior (fix)

  • Unknown operations now return an AWS InvalidAction error (HTTP 400) instead of a false 200 OK {} success, so SDKs no longer see a phantom success. Affects 11 services: glue, sagemaker, iot, backup, apigatewayv2, configservice, codebuild, codedeploy, codepipeline, cognito-idp, iotwireless. Tests that asserted the old behavior were updated.
  • Removed the dead, unregistered STS provider stub (internal/services/sts) — STS is served by the IAM package.

Compatibility (fix + tests)

  • Fixed X-Amz-Target routing for services with dotted target prefixes (CodeConnections, DMS) that previously misrouted to CloudTrail via an overbroad com prefix match; now routes by the last dotted prefix segment.
  • Enabled and added boto3 compatibility tests for CodeConnections, DMS, and Verified Permissions (registered but never enabled — hence never tested).
  • Added a regression test locking the unimplemented-op fix.
  • Compatibility suite: 717 → 729 tests, all green.

Plugin API (docs + test)

  • Documented the stable ServicePlugin contract in docs/plugin-api.md — method contracts, config keys, error convention, and the v1.x stability policy — enforced by a conformance test over every registered service. Added RegisteredServices() / Construct() registry accessors.

Docs

  • Corrected service/operation counts and the unverifiable ~96% claim in README, services-matrix, and roadmap; ticked roadmap items 1–3; removed stale edge-case prose.
  • Added unreleased Changie fragments.

Verification

go build ./..., go vet ./..., go test ./..., and make test-compat (729 passed) all green.

Not included

No release cut — tagging v1.0 remains a separate maintainer step per docs/release.md.

Advance the four open Phase 1 roadmap items toward a stable v1.0.

Unimplemented-op behavior (fix):
- Unknown operations now return an AWS `InvalidAction` error (HTTP 400)
  instead of a false `200 OK {}` success across 11 services (glue, sagemaker,
  iot, backup, apigatewayv2, configservice, codebuild, codedeploy,
  codepipeline, cognito-idp, iotwireless). Updated tests that asserted the
  old behavior.
- Remove the dead, unregistered STS provider stub (served by the IAM package).

Compatibility (fix + tests):
- Fix X-Amz-Target routing for services with dotted target prefixes
  (CodeConnections, DMS), which misrouted to CloudTrail via an overbroad
  `com` prefix match; route by the last dotted prefix segment instead.
- Enable and add boto3 compatibility tests for CodeConnections, DMS, and
  Verified Permissions (registered but never enabled). Add a regression test
  locking the unimplemented-op fix. Suite: 717 -> 729 tests, all green.

Plugin API (docs + test):
- Document the stable ServicePlugin contract in docs/plugin-api.md (method
  contracts, config keys, error convention, v1.x stability policy), enforced
  by a conformance test over every registered service. Add RegisteredServices
  and Construct registry accessors.

Docs:
- Correct service/operation counts and the unverifiable ~96% claim in README,
  services-matrix, and roadmap; tick roadmap items; drop stale edge-case prose.
- Add unreleased changelog fragments (Issue numbers are placeholders pending
  the PR number).
@sourcery-ai

sourcery-ai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Stabilization-focused v1.0 prep that standardizes unimplemented-op behavior to return AWS-style InvalidAction errors, fixes X-Amz-Target routing (including DMS/CodeConnections), documents and enforces the stable ServicePlugin API, enables three additional services in the boto3 compatibility suite, and refreshes service/coverage documentation and changelog fragments.

File-Level Changes

Change Details Files
Add registry helpers and a conformance test to lock down the stable ServicePlugin API surface for all services.
  • Introduce Registry.RegisteredServices() to list all registered service IDs in sorted order.
  • Introduce Registry.Construct() to build a plugin instance from its factory without Init, for introspection/testing.
  • Add TestServicePluginConformance in cmd/devcloud to validate all registered plugins’ ServiceID/ServiceName/Protocol invariants against a closed set of protocols.
  • Document the ServicePlugin contract, configuration, error conventions, and v1.x stability guarantees in docs/plugin-api.md and link it from architecture and contributing docs.
internal/plugin/registry.go
cmd/devcloud/conformance_test.go
docs/plugin-api.md
docs/architecture.md
docs/contributing.md
Standardize behavior of unimplemented operations to return AWS InvalidAction errors instead of false 200 successes, and lock this in with tests.
  • Change default cases in multiple service providers to return shared.JSONError("InvalidAction", ...) with HTTP 400 instead of 200 OK empty bodies.
  • Update Glue, SageMaker, IoT, IoT Wireless, CodeBuild, CodeDeploy, CodePipeline, Cognito IDP, Backup, ConfigService, and APIGatewayv2 tests to assert 400/InvalidAction for stub/unknown operations.
  • Add a compatibility regression test file ensuring representative unimplemented operations across services raise botocore ClientError.
  • Clarify docs to state that less-common operations now return a clean AWS error rather than a silent success.
internal/services/apigatewayv2/provider.go
internal/services/backup/provider.go
internal/services/codebuild/provider.go
internal/services/codedeploy/provider.go
internal/services/codepipeline/provider.go
internal/services/cognitoidentityprovider/provider.go
internal/services/configservice/provider.go
internal/services/glue/provider.go
internal/services/iot/provider.go
internal/services/iotwireless/provider.go
internal/services/sagemaker/provider.go
internal/services/glue/provider_test.go
internal/services/sagemaker/provider_test.go
internal/services/iot/provider_test.go
internal/services/iotwireless/provider_test.go
internal/services/codebuild/provider_test.go
internal/services/codedeploy/provider_test.go
tests/compatibility/test_unimplemented_ops.py
docs/services-matrix.md
README.md
docs/roadmap.md
changes/unreleased/Fixed-20260718-150000.yaml
Fix X-Amz-Target routing for dotted target prefixes and normalize DMS IDs so requests route to the correct service instead of CloudTrail.
  • Update serviceFromTarget to strip the operation name using the last '.', then take the last dotted segment of the prefix to derive the ServiceName_Date token, handling prefixes like com.amazonaws.codeconnections.CodeConnections_20231201.
  • Adjust normalizeServiceID to map AmazonDMS variants to dms and remove the generic com→cloudtrail mapping to avoid misrouting.
  • Add a changelog fragment documenting the X-Amz-Target routing fix.
internal/gateway/protocol.go
changes/unreleased/Fixed-20260718-150100.yaml
Enable CodeConnections, DMS, and Verified Permissions services in the default config and cover them with boto3 compatibility tests.
  • Enable the codeconnections, dms, and verifiedpermissions services in the default YAML with data_dir entries.
  • Add pytest fixtures that create boto3 clients for these services in the compatibility suite conftest.
  • Add focused compatibility test modules for CodeConnections (CRUD on connections), DMS (replication instances/endpoints/account attributes), and Verified Permissions (list_policies and is_authorized).
  • Record these additions in an unreleased Added changelog fragment.
internal/config/default.yaml
tests/compatibility/conftest.py
tests/compatibility/test_codeconnections.py
tests/compatibility/test_dms.py
tests/compatibility/test_verifiedpermissions.py
changes/unreleased/Added-20260718-150200.yaml
Clean up docs, counts, and dead code to reflect the stabilized v1.0 state and expanded service surface.
  • Update services-matrix and README to state 104 services, describe the 729-test compatibility suite, and clarify that unimplemented operations yield clean AWS errors and that stats count handlers not distinct operations.
  • Refresh the roadmap to mark Phase 1 items done (error semantics, coverage for newly tested services, stable ServicePlugin API) and clarify that v1.0 release is pending tagging.
  • Add a link to Plugin API docs in README and adjust contributing guidance to reference the documented contract.
  • Remove the unused STS provider implementation and its blank import, relying on IAM-backed STS only.
  • Add unreleased changie fragments for Added/Removed items.
docs/services-matrix.md
README.md
docs/roadmap.md
cmd/devcloud/imports.go
internal/services/sts/provider.go
changes/unreleased/Added-20260718-150300.yaml
changes/unreleased/Removed-20260718-150400.yaml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests Test code and test infrastructure services AWS service implementations labels Jul 18, 2026
Replace the placeholder-issue fragments with `changie new`-generated ones
carrying the real PR number (#96).

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 5 issues, and left some high level feedback:

  • In TestServicePluginConformance, the hardcoded len(ids) < 90 threshold is a bit brittle (e.g., if services are intentionally reduced or more added later); consider asserting > 0 or deriving the expected minimum from config/metadata instead of a magic number.
  • Now that RegisteredServices() returns a sorted slice under an RLock, you might consider copying the map keys under the lock and doing sort.Strings after releasing it to keep lock hold times minimal if the registry grows further.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `TestServicePluginConformance`, the hardcoded `len(ids) < 90` threshold is a bit brittle (e.g., if services are intentionally reduced or more added later); consider asserting `> 0` or deriving the expected minimum from config/metadata instead of a magic number.
- Now that `RegisteredServices()` returns a sorted slice under an `RLock`, you might consider copying the map keys under the lock and doing `sort.Strings` after releasing it to keep lock hold times minimal if the registry grows further.

## Individual Comments

### Comment 1
<location path="internal/plugin/registry.go" line_range="81-88" />
<code_context>
+// without calling Init. Returns nil,false if the service is not registered.
+// Only the metadata methods (ServiceID/ServiceName/Protocol) are guaranteed
+// usable on the returned value; anything requiring state needs Init first.
+func (r *Registry) Construct(serviceID string) (ServicePlugin, bool) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	f, ok := r.factories[serviceID]
+	if !ok {
+		return nil, false
+	}
+	return f(), true
+}
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid calling the factory while holding the registry read lock

Because the `RLock` is held across `f()`, any factory that (directly or indirectly) interacts with the registry (e.g., registers services or calls another method that takes this lock) can deadlock. It also increases lock contention by running arbitrary plugin code under the lock. Please narrow the lock scope so the factory is looked up under `RLock`, then release the lock before invoking `f()`, e.g.:

```go
func (r *Registry) Construct(serviceID string) (ServicePlugin, bool) {
    r.mu.RLock()
    f, ok := r.factories[serviceID]
    r.mu.RUnlock()
    if !ok {
        return nil, false
    }
    return f(), true
}
```

If some read protection must remain, still try to exclude the factory call from the locked region.
</issue_to_address>

### Comment 2
<location path="internal/services/glue/provider_test.go" line_range="56-53" />
<code_context>
 	}
 }

+func assertErr(t *testing.T, resp *plugin.Response) {
+	t.Helper()
+	if resp.StatusCode != 400 {
+		t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(resp.Body))
+	}
+}
+
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen `assertErr` to also validate the AWS-style error body, not just the 400 status

Since this now returns an AWS `InvalidAction` error, please also assert the response body matches the expected AWS error shape (e.g. `__type`/`code == "InvalidAction"`) and that the message is non-empty. This will make the tests verify the full behavior, not just the status code.

Suggested implementation:

```golang
func assertErr(t *testing.T, resp *plugin.Response) {
	t.Helper()

	if resp.StatusCode != 400 {
		t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(resp.Body))
	}

	if len(resp.Body) == 0 {
		t.Fatal("expected non-empty error body")
	}

	var body map[string]any
	if err := json.Unmarshal(resp.Body, &body); err != nil {
		t.Fatalf("expected JSON error body, got %q: %v", string(resp.Body), err)
	}

	code, _ := body["code"].(string)
	if code == "" {
		code, _ = body["__type"].(string)
	}
	if code == "" || !strings.Contains(code, "InvalidAction") {
		t.Fatalf(`expected AWS-style "InvalidAction" error code, got %q (body: %s)`, code, string(resp.Body))
	}

	msg, _ := body["message"].(string)
	if msg == "" {
		msg, _ = body["Message"].(string)
	}
	if strings.TrimSpace(msg) == "" {
		t.Fatalf("expected non-empty error message in AWS error body, got: %s", string(resp.Body))
	}
}

```

You will also need to ensure the following imports are present at the top of `internal/services/glue/provider_test.go`:

- `encoding/json`
- `strings`

For example:

```go
import (
    "encoding/json"
    "strings"
    // existing imports...
)
```

Adjust the import block to match your current formatting and existing imports.
</issue_to_address>

### Comment 3
<location path="internal/services/sagemaker/provider_test.go" line_range="56-53" />
<code_context>
 	}
 }

+func assertErr(t *testing.T, resp *plugin.Response) {
+	t.Helper()
+	if resp.StatusCode != 400 {
+		t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(resp.Body))
+	}
+}
+
</code_context>
<issue_to_address>
**suggestion (testing):** Mirror Glue tests by asserting the error payload shape for unimplemented SageMaker ops

Since this suite is a regression test for the “unimplemented op returns AWS error” contract, it would be useful for `assertErr` to also inspect `resp.Body` and assert that the error type/code is `InvalidAction` (and ideally that the message references the unknown operation), not just that the status is 400. This will better protect against regressions where the status remains 400 but the payload stops being a valid AWS-style error.

Suggested implementation:

```golang
func assertErr(t *testing.T, resp *plugin.Response) {
	t.Helper()
	if resp.StatusCode != 400 {
		t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(resp.Body))
	}

	var errResp struct {
		Type    string `json:"__type"`
		Message string `json:"message"`
	}

	if err := json.Unmarshal(resp.Body, &errResp); err != nil {
		t.Fatalf("expected JSON error body, got %q (unmarshal error: %v)", string(resp.Body), err)
	}

	// Mirror Glue tests: error type/code should be InvalidAction for unknown operations
	if !strings.Contains(errResp.Type, "InvalidAction") {
		t.Fatalf("expected error type to contain %q, got %q (body: %q)", "InvalidAction", errResp.Type, string(resp.Body))
	}

	// And the message should mention the unknown operation
	if !strings.Contains(strings.ToLower(errResp.Message), "unknown operation") {
		t.Fatalf("expected error message to mention unknown operation, got %q (body: %q)", errResp.Message, string(resp.Body))
	}
}

```

To compile successfully and match existing Glue tests, ensure the following in `internal/services/sagemaker/provider_test.go`:

1. The imports include:
   - `encoding/json`
   - `strings`

   For example, in the import block:
   ```go
   import (
       "encoding/json"
       "strings"
       // existing imports...
   )
   ```

2. If the Glue tests use a shared helper or a different error shape (e.g. different field names or a namespaced `__type` like `com.amazon.coral.service#InvalidAction`), you may want to:
   - Reuse the same error struct type as in the Glue tests (instead of the inline `errResp` struct).
   - Mirror any additional assertions they make on the error type prefix/suffix to keep the SageMaker tests consistent with Glue.
</issue_to_address>

### Comment 4
<location path="cmd/devcloud/conformance_test.go" line_range="25-34" />
<code_context>
+// package init, so DefaultRegistry holds the full plugin surface. See
+// docs/plugin-api.md for the contract this enforces.
+func TestServicePluginConformance(t *testing.T) {
+	ids := plugin.DefaultRegistry.RegisteredServices()
+	if len(ids) < 90 {
+		t.Fatalf("expected the full service surface to be registered, got only %d", len(ids))
+	}
+
+	for _, id := range ids {
+		p, ok := plugin.DefaultRegistry.Construct(id)
+		if !ok {
+			t.Errorf("%s: registered but Construct returned not-found", id)
+			continue
+		}
+		if got := p.ServiceID(); got != id {
+			t.Errorf("%s: ServiceID() = %q, want the registry key %q", id, got, id)
+		}
+		if p.ServiceName() == "" {
+			t.Errorf("%s: ServiceName() is empty", id)
+		}
+		if proto := p.Protocol(); !validProtocols[proto] {
+			t.Errorf("%s: Protocol() = %q is not a valid ProtocolType", id, proto)
+		}
</code_context>
<issue_to_address>
**suggestion (testing):** Extend the conformance test to assert `RegisteredServices` ordering and non-emptiness semantics

Since `RegisteredServices` is specified to return a sorted list, this test should also assert that `ids` is non-decreasing (e.g., compare each element to the previous). That will make regressions in registry ordering (and any code relying on deterministic iteration) visible here.

Suggested implementation:

```golang
func TestServicePluginConformance(t *testing.T) {
	ids := plugin.DefaultRegistry.RegisteredServices()
	if len(ids) == 0 {
		t.Fatalf("RegisteredServices() returned no services; expected a non-empty, sorted list")
	}
	if len(ids) < 90 {
		t.Fatalf("expected the full service surface to be registered, got only %d", len(ids))
	}

	for i := 1; i < len(ids); i++ {
		if prev, curr := ids[i-1], ids[i]; prev > curr {
			t.Fatalf("RegisteredServices() is not sorted: %q > %q at index %d (full list: %v)", prev, curr, i, ids)
		}
	}

	for _, id := range ids {
		if id == "" {
			t.Errorf("RegisteredServices() contains an empty service ID")
			continue
		}

```

```golang
		p, ok := plugin.DefaultRegistry.Construct(id)

```

```golang
		if proto := p.Protocol(); !validProtocols[proto] {

```
</issue_to_address>

### Comment 5
<location path="tests/compatibility/test_unimplemented_ops.py" line_range="1-10" />
<code_context>
+"""Regression tests: unimplemented operations must return an AWS error, not a
+silent 200 empty-body success. Guards the fix in the services that previously
+returned a false 200 for unknown ops (glue, sagemaker, codebuild, iot, ...)."""
+
+import pytest
+from botocore.exceptions import ClientError
+
+
+def test_glue_unimplemented_op_errors(glue_client):
+    with pytest.raises(ClientError):
+        glue_client.list_workflows()
+
+
+def test_sagemaker_unimplemented_op_errors(sagemaker_client):
+    with pytest.raises(ClientError):
+        sagemaker_client.list_auto_ml_jobs()
+
+
+def test_codebuild_unimplemented_op_errors(codebuild_client):
+    with pytest.raises(ClientError):
+        codebuild_client.list_build_batches()
</code_context>
<issue_to_address>
**suggestion (testing):** Broaden the regression coverage to all services that previously returned false 200s for unknown ops

The tests only cover Glue, SageMaker, and CodeBuild, while the docstring/PR mention additional affected services (IoT, IoTWireless, Backup, ApiGatewayV2, ConfigService, CodeDeploy, CodePipeline, Cognito-IDP). Please add similar `pytest.raises(ClientError)` checks for those clients as well—parameterizing over `(client_fixture, operation_name)` pairs would keep the tests compact and ensure consistent behavior across all listed services.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread internal/plugin/registry.go
Comment thread internal/services/glue/provider_test.go
Comment thread internal/services/sagemaker/provider_test.go
Comment thread cmd/devcloud/conformance_test.go
Comment thread tests/compatibility/test_unimplemented_ops.py Outdated
- registry: release RLock before invoking factory in Construct to avoid
  deadlock/contention from factory code that re-enters the registry
- glue/sagemaker: assertErr now validates the AWS error body
  (__type=InvalidAction, non-empty message), not just the 400 status
- conformance: derive the service-count floor from imports.go blank imports
  instead of a magic number; assert sorted order and non-empty service IDs
- compat: parameterize the unimplemented-op regression across all 11
  previously-affected services (verified each raises ClientError)
…, drop dead sts

- add TestUnimplementedOp (400/InvalidAction) to apigatewayv2, backup,
  codepipeline, cognitoidentityprovider, configservice so all services
  guard the new default-branch behavior under go test, not only the
  Python compat suite
- add table-driven TestServiceFromTarget covering dotted X-Amz-Target
  prefixes (codeconnections, cloudtrail) that were only exercised by
  the compat suite
- remove orphaned internal/generated/sts (no importer after services/sts
  deletion)
@skyoo2003

Copy link
Copy Markdown
Owner Author

@sourcery-ai resolve

@skyoo2003

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Sorry @skyoo2003, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

…review

Error contract: all service providers' default branches now return
NotImplemented (HTTP 501) instead of InvalidAction (400), so clients can
tell a not-yet-emulated real operation apart from a genuinely invalid
action. The route53 ChangeResourceRecordSets record-action validation path
keeps InvalidChangeBatch (a real input error, not an unimplemented op).

Also addresses code-review findings:
- conformance_test: derive the service floor from a constant instead of
  parsing imports.go at runtime (module-rename would silently no-op it)
- cognito: drop the byte-identical local cognitoError, use shared.JSONError
- gateway: remove the dead "amazondms" normalizeServiceID alias
- registry: extract a sortedKeys generic to dedupe
  RegisteredServices/ActiveServices

Verified: go build/vet/test ./... and 737 boto3 compatibility tests pass.
@skyoo2003
skyoo2003 merged commit 700acde into main Jul 19, 2026
9 checks passed
@skyoo2003
skyoo2003 deleted the feat/phase1-stabilization branch July 19, 2026 13:53
skyoo2003 added a commit that referenced this pull request Jul 30, 2026
…engineering audit (#120)

* refactor: cut dead plugin surface and de-duplicate provider helpers

A repo-wide over-engineering audit turned up surface that no code path can
reach and helpers copied per service.

Remove the GetMetrics/ServiceMetrics plugin API: 102 of 104 implementations
returned a zero-valued struct, and TotalRequests/ErrorCount were never
populated by anyone, so /devcloud/api/metrics reported zeros forever. The two
services that did fill ResourceCount (DynamoDB, Lambda) are unaffected in
practice — /devcloud/api/services already derives resource counts from
ListResources. The interface is still pre-1.0, so this is not a v1.x break.

Delete the generic shared.ResourceStore (114 lines plus 204 of tests): no
production caller ever used it, every service writes its own SQL. Its
shared.Scanner was a second declaration of the identical sqlite.Scanner, so
configservice now uses the latter.

Fold 23 copies of the same string-param accessor (strParam / strVal /
getString / str — three spellings, identical behaviour) into shared.StrParam,
point 10 per-service region constants at shared.DefaultRegion, and make
shared.DefaultAccountID mirror plugin.DefaultAccountID rather than repeat the
literal. Left alone deliberately: the per-service jsonError/jsonResponse
helpers, whose Content-Type differs by protocol (application/json vs
x-amz-json-1.0 vs 1.1), and the randHex/generateID variants, which produce
genuinely different id formats.

Merge the two mirror-image case-conversion walkers in shared into one
mapKeys(v, f) and move it to kafka, its only caller, with a test.

* refactor: simplify startup, config, and the admin log collector

The embedded default config was 325 lines in which all 103 service entries
were byte-identical boilerplate: enabled plus data_dir ./data/<name>, with no
exceptions. Derive that instead. The services block is now optional and
authoritative — omit it and every registered service starts under
./data/<service>; list any service and only the listed ones start, which is
the behaviour existing partial configs already relied on. Startup iterates the
plugin registry rather than a config map, so a newly registered service no
longer needs a YAML entry to run.

Delete the event bus and the admin WebSocket at /devcloud/api/ws: nothing in
the binary ever called Publish, and main.go dropped the bus reference at the
call site, so the socket accepted connections and could never send a message.
The REST /devcloud/api/logs endpoint already serves the request log it was
meant to stream. This drops the gorilla/websocket dependency.

Replace the custom buffering slog.Handler (92 lines) with config warnings
returned as a []string and logged after setupLogging — same guarantee that
config-time warnings honour logging.format/level, without a bespoke handler.
The 'config file not found, using embedded defaults' notice is gone: it fired
on the zero-config happy path, and keeping it was the only reason the handler
existed.

Fold the two init loops, whose bodies were identical, into one closure. The
fixed initOrder stays: it encodes core-service-failure-is-fatal, not just the
iam-before-sts ordering.

Keep the deprecated 'dashboard' key shim — the rename it protects is still
unreleased, so dropping it now would silently break v0.2.0 configs.

Drop two of the LogCollector's three redundant size clamps, keeping the
divide-by-zero guard.

* refactor: shrink protocol detection

normalizeServiceID went 248 lines to 133. Of its 117 cases, 79 arms were
identity mappings (case "s3": return "s3") — but they were not simply
redundant: they also lowercased their input, which `default: return svc` did
not. The default now lowercases, which makes the identity arms genuinely dead
and is strictly better for unmatched names, since every registry key is
lowercase. The rewrite was verified by asserting that 385 labels (every case
arm, every return value, their uppercase variants, plus unmatched samples) map
identically before and after. One dead arm went too: "simpleWorkflowService"
could never match a switch on strings.ToLower.

serviceFromQueryRequest drops the Action-name whitelist. It only ran for a
request carrying neither a SigV4 credential scope nor an iam/sts/sqs host
prefix — and every AWS SDK, the CLI, and Terraform sign their requests. With
the whitelist gone the QueueUrl probe is also redundant, since it returned the
same value as the fallback, so the body argument and the net/url import go
with it.

Delete gateway/auth.go: ExtractAccountID read the Authorization header into
_, returned a constant, and had no callers.

* fix: the weekly Smithy model sync could never detect an update

download-smithy-models.sh skipped any model already on disk, and every model
is committed, so the weekly job re-ran codegen over unchanged inputs and found
nothing every week. Its hand-maintained 40-line SERVICES list had also drifted
from upstream naming, so 14 entries (secretsmanager, logs, monitoring, events,
route53, apigateway, autoscaling, dms, ...) 404'd on every run. Verified
against upstream: the committed sqs and kms models are stale.

The list is now derived from the models present, so it cannot drift; name a
service explicitly to add a new one. The workflow passes --refresh to
re-download, and downloads land through a temp file, so a failed fetch can no
longer delete or truncate a committed model — the previous code did rm -f on
the destination. The change check moves to git status --porcelain because
git diff --quiet cannot see a newly generated (untracked) package.

The models stay committed on purpose, and the script now says why: BASE_URL
tracks aws-sdk-go-v2 main, so they are the pin that makes `make codegen`
reproducible and offline. Only the weekly job refreshes them, which is what
turns an upstream API change into a reviewable model diff.

codegen now gofmts what it writes, so a fresh `make codegen` is byte-identical
to the committed tree instead of showing a whole-tree reformat. That made one
thing visible: codegen was resurrecting internal/generated/sts, 563 lines
deleted in #91 and #96 because STS is hand-written in internal/services/iam,
as untracked files the sync's diff check could not see. STS is now skipped; it
is Query-protocol, so it contributes nothing to the JSON-only CRUD registry.

* docs: correct the generated-code surface and the admin API

The codegen diagram listed interface.go, serializer.go, and deserializer.go as
generated outputs. None of them exist, and README claimed codegen produces
"serializers". That is the source of a recurring misreading of
internal/generated as scaffolding waiting to be filled in: there is no
generated wire glue, providers parse the raw *http.Request themselves, so
types.go and base_provider.go have nothing to connect to and only router.go is
consumed today. Document the four files actually generated, and why the Smithy
models are committed.

Also drop the event bus and WebSocket sections, the GetMetrics contract row,
the /devcloud/api/metrics endpoint, and the auth.enabled key, all of which
describe code that no longer exists; and state that the services config block
is optional and authoritative.

* chore: run the full test suite in CI, add changelog fragments

CI ran go test ./internal/..., which skipped cmd/devcloud — so the
ServicePlugin conformance test over every registered service, the thing that
enforces the documented plugin contract, never actually ran. Use ./... .

`make stats` counted services by parsing the services block out of
default.yaml, which no longer lists them; count the service packages instead.

* fix: unsigned Query requests all routed to SQS, and other audit fallout

A code review of the refactor commits on this branch turned up one behaviour
regression and four silent failures.

serviceFromQueryRequest dropped its Action fallback on the reasoning that every
SDK, the CLI, and Terraform sign their requests. They do — but the fallback only
ever ran for a request with neither a SigV4 credential scope nor an iam/sts/sqs
host prefix, and for those the function now returned "sqs" unconditionally: an
unsigned Action=GetCallerIdentity POST to a bare endpoint reached the SQS
provider. The existing Query tests set Host to iam./sts., so they never covered
the path they were meant to. The fallback is back, ordered after SigV4 and the
host prefix and matching on IAM entity substrings rather than the old 35-name
whitelist, which makes it shorter and wider — DeleteRole and ListPolicies were
both missing from that list. sqs stays the final default.

download-smithy-models.sh counted failures and exited 0, so the weekly sync
would regenerate from stale models, see no diff, and report a successful sync of
nothing — the same silent no-op the previous commit set out to fix. It now exits
1 when any download failed. Its model count also moves from `ls *.json` to find:
under set -euo pipefail a glob matching nothing made ls exit 2 and killed the
script just before it printed the summary.

The removed auth block is parsed again, only to warn. yaml.Unmarshal ignores
unknown keys, so an operator who wrote auth.enabled: true to require signature
validation got no warning that nothing checks signatures — the one deprecation
that must not be silent. Follows the dashboard key's one-release shim.

A services block is authoritative and Enabled is a plain bool, so a block that
lists a service without enabled: true starts zero services with nothing in the
log. main now warns when the active set is empty, which also covers a typo'd
DEVCLOUD_SERVICES. docs/configuration.md claimed enabled defaults to true; it
defaults to false and is required per entry.

Also: make stats counted service directories and reported 103, because STS lives
in internal/services/iam — count registry registrations instead, which gives the
104 README states. Finish the shared.StrParam de-duplication with
StrParamDefault, removing five more copies (29 call sites). Drop the
now-unreachable "simpleWorkflowService" case label, since the switch lowercases
its input. Stop copying the request body into a string to test for "Action=".
Merge the split import groups left in twelve providers. Isolate DEVCLOUD_* from
the process environment in the config tests, which otherwise fail on a machine
that exports them.

Left alone deliberately: the eleven services with no committed Smithy model
(account, cloudcontrol, dms, ...). The script takes an arbitrary MODELS_DIR and
must not know about internal/services; the invariant that every service package
has a model belongs in a codegen test. intParam stays duplicated — two of its
six copies differ in signature and integer width, so folding them would change
behaviour at the call site.

* docs: condense the unreleased changelog fragments

The fragments had grown into commit messages: line counts, per-service tallies,
and the reasoning behind each change. A CHANGELOG reader wants what changed and
why it mattered; the rest is already in git. Trimmed to one or two sentences
each, keeping every name a reader would grep for — config keys, endpoints,
package paths, operation names.
skyoo2003 added a commit that referenced this pull request Aug 9, 2026
Regenerating in place only overwrites the filenames the generator still emits.
internal/codegen/generator.go has no deletion path — no os.Remove, no
RemoveAll — so an output it stops emitting stays on disk, tracked and
unchanged, and `git status --porcelain` reports nothing. The drift check then
passes on generated code that no longer matches its generator, which is the
case the check exists to catch. The repo has hit this before: cmd/codegen/
main.go:16-22 exists because stubs deleted in #91 and #96 kept coming back.

Both drift jobs now clear internal/generated before regenerating, so a retired
or renamed output shows up as a deletion. Every tracked file under that tree
carries a generated marker, so nothing hand-written is at risk, and
scripts/generate-imports.sh writes to cmd/devcloud/imports.go, outside it.

Verified by committing a file the generator does not emit: in-place
regeneration left porcelain empty, clean-tree regeneration reported
` D internal/generated/zz_obsolete_gen.go`. A clean-tree run against the
current tree reproduces it byte for byte, so the stricter check starts green.
skyoo2003 added a commit that referenced this pull request Aug 9, 2026
* ci: enforce the checks a v1.0 tag depends on

The release procedure was documented end to end but unenforced, and four
defects were live on main.

The fidelity manifest could drift silently. internal/generated is committed
but derived, and nothing verified the committed output still matched its
sources. The Go tests guard the manifest's shape — floors, registered
services, the CRUD registry — but none of them notice an operation a provider
gained and the manifest never did; absence from a 7,475-entry map is
invisible. A codegen-drift job now regenerates and diffs. Its first catch was
this repo: the manifest's own doc comment was a release behind its template.

Eight changelog fragments carried an empty Issue field, which changie renders
as ([#](https://github.com/skyoo2003/devcloud/issues/)) and batches without
complaint. They would have shipped as dead links in the v1.0 release notes.
The fragments are fixed and the release workflow now rejects a batched file
containing one.

One fragment sat in .changes/unreleased/ rather than changes/unreleased/, so
it was excluded from every release since it was written. Moved; the batch
goes from 25 entries to 26.

A tag push published without waiting for tests. CI does trigger on tags, but
the two workflows race, so a red commit could still ship binaries, container
images and a Homebrew formula. The release job now needs a test job.

Also ships the docs tree inside the release archive, so docs are versioned by
tag: the docs/ beside a binary describe that binary.

* docs(changelog): fragment for the release-hardening change

* fix(ci): close the holes the release gate left open

Six follow-ups from review, each verified against the tree.

The drift check did not gate publishing. release.yml waited only on the Go
tests, which are exactly the checks that cannot see a stale fidelity manifest
— the reason the drift job exists. A tag could publish generated code that
misstates what the release serves. The release job now needs a codegen-drift
job of its own rather than trusting that CI won the race.

The drift check missed untracked output. `git diff --exit-code` ignores a
newly generated package, so adding a service model and forgetting to commit
its generated directory passed. cmd/codegen/main.go:18-20 already documents
this trap and smithy-sync.yml:44-46 already avoids it; both drift checks now
use `git status --porcelain`, as that one does.

codegen exited 0 after skipping a model. An unreadable or malformed model
printed to stderr and continued, so generation could be incomplete while the
drift check saw no changed files and called it clean. It now exits non-zero,
and does so before writing the CRUD registry and fidelity manifest — those
describe the whole fleet, and building them from a partial set would state in
generated code that a service's operations do not exist.

The issue-number guard only caught the empty case. `Issue: "abc"`, `"0"` and
`"-1"` all render links that go nowhere and all batched cleanly. The check now
requires every entry to end in a positive integer rather than enumerating the
malformed spellings.

Manual dispatch tested the wrong commit. actions/checkout defaults to the ref
the run was launched from, not the tag input, so the gate vouched for a branch
while GoReleaser was asked for a tag. Both jobs now check out the resolved tag.

The gate ran on amd64 only while GoReleaser publishes arm64 artifacts. The
test job now mirrors ci.yml's architecture matrix.

* fix(ci): gate the release on the compatibility suite

compat.yml triggers on branch pushes and pull requests only, so on a tag it
does not race the release the way CI does — it never runs at all. The boto3
suite is the guardrail this project leans on hardest, and it had no bearing on
what a tag published; docs/release.md listed it as a manual pre-flight step,
which is the judgment call the release gate exists to remove.

The pre-flight checklist now says which boxes the workflow re-runs and which
only a human catches, and quotes the drift check the way CI actually runs it
(git status --porcelain, not git diff --exit-code).

* fix(ci): regenerate from a clean tree so retired outputs surface

Regenerating in place only overwrites the filenames the generator still emits.
internal/codegen/generator.go has no deletion path — no os.Remove, no
RemoveAll — so an output it stops emitting stays on disk, tracked and
unchanged, and `git status --porcelain` reports nothing. The drift check then
passes on generated code that no longer matches its generator, which is the
case the check exists to catch. The repo has hit this before: cmd/codegen/
main.go:16-22 exists because stubs deleted in #91 and #96 kept coming back.

Both drift jobs now clear internal/generated before regenerating, so a retired
or renamed output shows up as a deletion. Every tracked file under that tree
carries a generated marker, so nothing hand-written is at risk, and
scripts/generate-imports.sh writes to cmd/devcloud/imports.go, outside it.

Verified by committing a file the generator does not emit: in-place
regeneration left porcelain empty, clean-tree regeneration reported
` D internal/generated/zz_obsolete_gen.go`. A clean-tree run against the
current tree reproduces it byte for byte, so the stricter check starts green.

* fix(release): ship what the archived docs link to, and clear the tree locally

Adding docs/ to the archive exposed how little of it resolved: measured
against a real snapshot archive, README.md and the docs tree reach seven
top-level files by relative path that were not shipped. Adding them takes the
archive from 26 of 28 relative links broken — the state before docs/ was
included at all — to 17 of 134, and every one of those points into source or
CI config, which a binary archive has no business carrying.

Verified by extracting dist/devcloud_..._linux_amd64.tar.gz and resolving each
link against the extracted tree: 117 of 134 work. Archive size 3.2M.

The pre-flight checklist also still told developers to regenerate in place,
which is the exact check the previous commit taught CI not to trust. It now
mirrors the clean-tree sequence.

* fix(ci): fail release notes that contain no changie entries

The issue-link check filtered the '* ' entries and complained about the ones
that did not carry a valid link. With no '* ' entries at all the filter matched
nothing, `|| true` turned the empty result into success, and the gate passed
having checked nothing. changes/v0.1.0.md is exactly that shape — hand-written
prose with 25 '- ' bullets and no issue links — so a manual dispatch for that
tag would have replaced its release with notes this gate never inspected.

Now a notes file must contain at least one changie entry, and any other bullet
form is rejected outright: those are hand edits, which is what the link check
cannot vouch for and what docs/release.md already forbids.

Verified against the real files and synthetic cases: v0.1.0.md and a headers-
only file now fail on "no changie entries", a file mixing a changie entry with
a hand-written bullet fails on the bullet, a malformed issue link still fails,
and v0.2.0.md plus the current unreleased batch still pass.

* fix(ci): reject every line a release notes file did not get from changie

The hand-edit check enumerated bullet markers — '^[-+] ' — and its comment
claimed it rejected "any other bullet form". It rejected two of them. An
ordered item, an indented item and a pasted paragraph all passed, and the
entry check that follows only inspects '* ' lines, so those lines shipped
into a GitHub release with nothing about them verified:

  * Real entry ([#42](.../issues/42))
  1. Hand-written item with no issue link      <- old check: pass

Inverted it. A batched file holds only what .changie.yaml renders: the
version heading, one kind heading per section, and one '* ...' entry per
fragment. Allowing those three shapes and rejecting the rest covers every
form a human might reach for, including the ones nobody thought to list.

Verified against authentic output — `changie batch` (v1.25.0, current
config, all 27 unreleased fragments) and changes/v0.2.0.md both pass;
'1. text', '  - text', '- text' and a bare paragraph each fail with the
offending line numbered. The empty-notes and missing-file gates still
fire on changes/v0.1.0.md and an absent path.

* fix(ci): pin every release job to one commit, not to a mutable tag

All four jobs resolved `github.event.inputs.tag || github.ref` on their own.
A tag is a mutable pointer: force-update it, or delete and recreate it, while
the run is in flight — which is what a maintainer does on spotting that the
wrong commit got tagged — and the gates check out one commit while the release
job checks out another. "The gates passed" then says nothing about the
artefacts GoReleaser publishes.

A new `resolve` job checks out the ref once, records `git rev-parse HEAD`, and
every job downstream takes `ref: ${{ needs.resolve.outputs.sha }}`. It carries
the tag name and the dry-run flag too, so the `resolve_tag` step inside
`release` and the ref expression repeated four times both collapse into it.

github.sha alone would not do: it is immutable, but on workflow_dispatch it is
the branch the run was launched from, not the tag that was typed in.

The release checkout also needs `fetch-tags: true`, and that is not redundant
with `fetch-depth: 0`. actions/checkout always fetches with --no-tags and
brings tags down only through an explicit refspec; for a SHA ref that refspec
is the bare commit (ref-helper.ts getRefSpec, git-command-manager.ts fetch, at
the pinned de0fac2). Pinning without it hands GoReleaser a repo with no tags
and no way to name the version. With it, a tag that moved after `resolve` no
longer points at HEAD, and `git describe --exact-match` inside GoReleaser's
git pipe fails the run rather than mispublishing.

Verified: the job graph parses, every `needs` resolves, all four downstream
checkouts pin to needs.resolve.outputs.sha, and the three outputs read are the
three declared. The resolve step emits tag+sha on a tag push and adds
extra_flags=--snapshot only under dry_run. The notes gate still passes real
`changie batch` output and still rejects every hand-edited fixture.

* fix(ci): require the release notes heading to name the tag being released

The allowlist matched '^## ' and '^### ' by prefix, so it accepted any
version heading at all. Copy or rename an earlier release's notes file to
changes/<new-tag>.md and every check passed: the entries carry valid issue
links, nothing is hand-written, and GoReleaser hands the body through
verbatim — so the v1.0.0 release opens with a heading linking to the v0.2.0
release, dated to it.

The heading is now rebuilt from the tag and required exactly, exactly once.
Dots in the tag are escaped, which is not cosmetic: without it the regex for
v9.9.9 matches a heading reading v9x9x9.

Kind headings are restricted to the labels in .changie.yaml, read out of the
config rather than restated here so that adding a kind does not start failing
releases. If that extraction ever stops matching it yields an empty set,
which rejects every kind heading — the check fails loudly instead of silently
checking nothing, the same failure mode the empty-entry gate above exists for.

Verified against authentic `changie batch` output, 14 cases: correct tag
passes; the same file under a different tag, a stale heading, two headings,
no heading, '### Bugfixes', an ordered item, an indented item, a pasted
paragraph, an entry without an issue link, changes/v0.1.0.md and an absent
path all fail. changes/v0.2.0.md still passes under its own tag. Dot escaping
confirmed by control: the unescaped variant passes the v9x9x9 heading.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation services AWS service implementations tests Test code and test infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant