mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
refactor: register ACM control plane from its domain
domains/acm/awsapi registers the acm JSON 1.1 surface through the ingress dispatch registry, wired by the awsgw role. The gateway drops ACM_Request, the ACM action table and every legacy acm entry; the import invariant now covers each registered domain.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/predastore/s3api"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
@@ -48,7 +49,8 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
// Registered services contribute their declared inventory, which needs no
|
||||
// live dependencies; nothing here serves a request.
|
||||
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
})
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service ACM clients sign for.
|
||||
const ServiceName = "acm"
|
||||
|
||||
// Handler is the signature every ACM control-plane action implements. nc is
|
||||
// the gateway NATS connection (handlers relay onto acm.* subjects); accountID
|
||||
// is the resolved caller account; body is the raw JSON 1.1 request payload.
|
||||
type Handler func(ctx context.Context, nc *nats.Conn, accountID string, body []byte) (any, error)
|
||||
|
||||
// Deps are the composed capabilities the ACM control plane dispatches to. A
|
||||
// nil NATS connection answers every action with ServerInternal.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
}
|
||||
|
||||
// Actions maps the ACM control-plane action namespace onto the domain's
|
||||
// existing NATS-backed adapters.
|
||||
var Actions = map[string]Handler{
|
||||
"ImportCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return ImportCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"RequestCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return RequestCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"DescribeCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return DescribeCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"GetCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return GetCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"ListCertificates": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return ListCertificates(ctx, nc, acct, b)
|
||||
},
|
||||
"DeleteCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return DeleteCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"ListTagsForCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return ListTagsForCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"AddTagsToCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return AddTagsToCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
"RemoveTagsFromCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
|
||||
return RemoveTagsFromCertificate(ctx, nc, acct, b)
|
||||
},
|
||||
}
|
||||
|
||||
// NewRegistration returns the ACM AWS JSON 1.1 control-plane registration.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeJSON,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves. Every
|
||||
// ACM action is implemented, so none is Stubbed or Unsupported.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
return dispatch.Inventory{
|
||||
Registered: slices.Sorted(maps.Keys(Actions)),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAction is the one X-Amz-Target parser telemetry and dispatch share.
|
||||
func resolveAction(r *http.Request) string {
|
||||
return actionFromTarget(r.Header.Get("X-Amz-Target"))
|
||||
}
|
||||
|
||||
// actionFromTarget extracts the action suffix from an X-Amz-Target header.
|
||||
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
|
||||
func actionFromTarget(target string) string {
|
||||
if i := strings.LastIndex(target, "."); i >= 0 {
|
||||
return target[i+1:]
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// dispatch resolves, scopes and authorizes the request from one read of its
|
||||
// body, then runs the action. Errors are awserrors codes the gateway renders.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
action := resolveAction(r)
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
|
||||
handler, ok := Actions[action]
|
||||
if !ok {
|
||||
slog.DebugContext(r.Context(), "ACM: unknown action", "action", action)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.ErrorContext(r.Context(), "ACM dispatch: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
body, err := readBoundedBody(r)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "ACM dispatch: failed to read body", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
resources, err := ResourceARNs(action, inv.Region, accountID, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.NATS == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
output, err := handler(r.Context(), d.NATS, accountID, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
WriteJSONResponse(w, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readBoundedBody reads the body under the SigV4 payload cap, so a body read
|
||||
// ahead of the policy gate can neither bypass it nor exhaust memory.
|
||||
func readBoundedBody(r *http.Request) ([]byte, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
regAccount = "123456789012"
|
||||
regRegion = "us-east-1"
|
||||
)
|
||||
|
||||
// authCall is one inv.Authorize call as the dispatcher made it.
|
||||
type authCall struct {
|
||||
service, action string
|
||||
resources []string
|
||||
keys iampolicy.ConditionKeys
|
||||
}
|
||||
|
||||
// fakeInvocation records every Authorize call into log, answering each with
|
||||
// deny.
|
||||
type fakeInvocation struct {
|
||||
log []string
|
||||
auths []authCall
|
||||
deny error
|
||||
}
|
||||
|
||||
func (f *fakeInvocation) invocation(target, body string) dispatch.Invocation {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
if target != "" {
|
||||
req.Header.Set("X-Amz-Target", target)
|
||||
}
|
||||
return dispatch.Invocation{
|
||||
Request: req,
|
||||
AccountID: regAccount,
|
||||
Region: regRegion,
|
||||
Authorize: func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
|
||||
f.log = append(f.log, "authorize")
|
||||
f.auths = append(f.auths, authCall{service, action, resources, keys})
|
||||
return f.deny
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchWith(t *testing.T, deps Deps, inv dispatch.Invocation) (*httptest.ResponseRecorder, error) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
return w, NewRegistration(deps).Dispatch(w, inv)
|
||||
}
|
||||
|
||||
func TestActionFromTarget(t *testing.T) {
|
||||
for target, want := range map[string]string{
|
||||
"CertificateManager.ListCertificates": "ListCertificates",
|
||||
"a.b.ListCertificates": "ListCertificates",
|
||||
"ListCertificates": "ListCertificates",
|
||||
"CertificateManager.": "",
|
||||
"": "",
|
||||
} {
|
||||
assert.Equal(t, want, actionFromTarget(target), "target %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionsScopeTableIsExhaustive is what stops the next ACM action being
|
||||
// added with a silent account-wide grant. It asserts both directions, so a
|
||||
// scope left behind by a deleted or renamed action fails too.
|
||||
func TestActionsScopeTableIsExhaustive(t *testing.T) {
|
||||
for action := range Actions {
|
||||
assert.True(t, HasScope(action),
|
||||
"ACM action %q has no resource scope entry: add one to authz.go", action)
|
||||
}
|
||||
for _, action := range ScopedActions() {
|
||||
_, ok := Actions[action]
|
||||
assert.True(t, ok,
|
||||
"acmScopes has an entry for %q, which Actions does not serve: remove it from authz.go", action)
|
||||
}
|
||||
}
|
||||
|
||||
// The coverage tool reads OperationInventory directly; a live registration
|
||||
// must declare exactly the same inventory to the registry.
|
||||
func TestRegistrationInventoryIsTheDeclaredInventory(t *testing.T) {
|
||||
reg := NewRegistration(Deps{})
|
||||
assert.Equal(t, OperationInventory(), reg.Inventory)
|
||||
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(reg))
|
||||
assert.Equal(t, map[string]dispatch.Inventory{ServiceName: OperationInventory()}, b.Build().Inventory())
|
||||
}
|
||||
|
||||
// Every ACM action is implemented: none is Stubbed or Unsupported.
|
||||
func TestRegistrationInventoryHasNoStubsOrUnsupported(t *testing.T) {
|
||||
inv := OperationInventory()
|
||||
assert.Len(t, inv.Registered, 9)
|
||||
assert.Empty(t, inv.Stubbed)
|
||||
assert.Empty(t, inv.Unsupported)
|
||||
}
|
||||
|
||||
func TestRegistrationDeclaresJSONEnvelopeForACM(t *testing.T) {
|
||||
reg := NewRegistration(Deps{})
|
||||
assert.Equal(t, ServiceName, reg.Service)
|
||||
assert.Equal(t, dispatch.ErrorEnvelopeJSON, reg.Errors)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-Amz-Target", "CertificateManager.ListCertificates")
|
||||
assert.Equal(t, "ListCertificates", reg.ResolveAction(req))
|
||||
}
|
||||
|
||||
// A denied request must never reach the NATS gate: Authorize runs first, and
|
||||
// its own error — not ServerInternal from a nil connection — is what the
|
||||
// caller sees. A permitted request reaches the NATS gate and fails there,
|
||||
// which is what proves Authorize ran ahead of it.
|
||||
func TestDispatch_AuthorizesBeforeTheNATSGate(t *testing.T) {
|
||||
denied := &fakeInvocation{deny: errors.New(awserrors.ErrorAccessDenied)}
|
||||
_, err := dispatchWith(t, Deps{}, denied.invocation("CertificateManager.ListCertificates", "{}"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
|
||||
assert.Equal(t, []string{"authorize"}, denied.log)
|
||||
|
||||
permitted := &fakeInvocation{}
|
||||
_, err = dispatchWith(t, Deps{}, permitted.invocation("CertificateManager.ListCertificates", "{}"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error(), "authorize passed; the nil NATS connection is what fails next")
|
||||
require.Len(t, permitted.auths, 1)
|
||||
assert.Equal(t, authCall{
|
||||
service: ServiceName,
|
||||
action: "ListCertificates",
|
||||
resources: []string{"*"},
|
||||
}, permitted.auths[0])
|
||||
}
|
||||
|
||||
// Requests dispatch rejects before scoping never reach Authorize.
|
||||
func TestDispatch_RejectsBeforeAuthorize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, target, body string
|
||||
account string
|
||||
want string
|
||||
}{
|
||||
{"absent target", "", "{}", regAccount, awserrors.ErrorMissingAction},
|
||||
{"trailing dot", "CertificateManager.", "{}", regAccount, awserrors.ErrorMissingAction},
|
||||
{"unknown action", "CertificateManager.MadeUpAction", "{}", regAccount, awserrors.ErrorInvalidAction},
|
||||
{"other service's action", "AmazonEC2ContainerServiceV20141113.ListClusters", "{}", regAccount, awserrors.ErrorInvalidAction},
|
||||
{"missing account", "CertificateManager.ListCertificates", "{}", "", awserrors.ErrorInternalError},
|
||||
{"oversized body", "CertificateManager.ListCertificates", strings.Repeat("x", sigv4.MaxPayloadLen+1), regAccount, awserrors.ErrorRequestEntityTooLarge},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &fakeInvocation{}
|
||||
inv := f.invocation(tc.target, tc.body)
|
||||
inv.AccountID = tc.account
|
||||
_, err := dispatchWith(t, Deps{}, inv)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.want, err.Error())
|
||||
assert.Empty(t, f.auths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every registered action answers ServerInternal once authorized, with no
|
||||
// NATS connection to relay onto.
|
||||
func TestDispatch_NilNATSIsServerInternal(t *testing.T) {
|
||||
for action := range Actions {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
f := &fakeInvocation{}
|
||||
_, err := dispatchWith(t, Deps{}, f.invocation("CertificateManager."+action, "{}"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Equal(t, []string{"authorize"}, f.log)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// acmHandler invokes a per-action ACM domain adapter after generic gateway
|
||||
// authentication and authorization have completed.
|
||||
type acmHandler func(ctx context.Context, gw *GatewayConfig, accountID string, body []byte) (any, error)
|
||||
|
||||
// acmActions maps the action suffix of X-Amz-Target (CertificateManager.<Action>) to its handler.
|
||||
var acmActions = map[string]acmHandler{
|
||||
"ImportCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.ImportCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"RequestCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.RequestCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"DescribeCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.DescribeCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"GetCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.GetCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"ListCertificates": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.ListCertificates(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"DeleteCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.DeleteCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"ListTagsForCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.ListTagsForCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"AddTagsToCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.AddTagsToCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
"RemoveTagsFromCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
|
||||
return acmawsapi.RemoveTagsFromCertificate(ctx, gw.NATSConn, acct, b)
|
||||
},
|
||||
}
|
||||
|
||||
// acmActionFromTarget extracts the action suffix from an X-Amz-Target header.
|
||||
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
|
||||
func acmActionFromTarget(target string) string {
|
||||
if i := strings.LastIndex(target, "."); i >= 0 {
|
||||
return target[i+1:]
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// ACM_Request dispatches AWS JSON 1.1 ACM requests. The action comes from
|
||||
// X-Amz-Target; errors are returned as awserrors codes.
|
||||
func (gw *GatewayConfig) ACM_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
action := acmActionFromTarget(r.Header.Get("X-Amz-Target"))
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
|
||||
handler, ok := acmActions[action]
|
||||
if !ok {
|
||||
slog.DebugContext(r.Context(), "ACM: unknown action", "action", action)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// Hoisted above the policy check because the resolver builds ARNs from the
|
||||
// caller's account and from the same body bytes the handler unmarshals.
|
||||
accountID, _ := r.Context().Value(ctxAccountID).(string)
|
||||
if accountID == "" {
|
||||
slog.ErrorContext(r.Context(), "ACM_Request: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
body, err := readBoundedBody(r)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "ACM_Request: failed to read body", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
resources, err := acmawsapi.ResourceARNs(action, gw.Region, accountID, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gw.checkPolicyResources(r, "acm", action, resources); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
output, err := handler(r.Context(), gw, accountID, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
acmawsapi.WriteJSONResponse(w, output)
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//test:in-package — acmActions is unexported here, and the completeness test
|
||||
// exists to compare it against the scope table.
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestACMScopeTableIsExhaustive is what stops the next ACM action being added
|
||||
// with a silent account-wide grant. It asserts both directions, so a scope left
|
||||
// behind by a deleted or renamed action fails too.
|
||||
func TestACMScopeTableIsExhaustive(t *testing.T) {
|
||||
for action := range acmActions {
|
||||
assert.True(t, acmawsapi.HasScope(action),
|
||||
"acm action %q has no resource scope entry: add one to domains/acm/awsapi/authz.go", action)
|
||||
}
|
||||
|
||||
for _, action := range acmawsapi.ScopedActions() {
|
||||
_, ok := acmActions[action]
|
||||
assert.True(t, ok,
|
||||
"acmScopes has an entry for %q, which the dispatch table does not serve: remove it from domains/acm/awsapi/authz.go", action)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func acmCertARN(id string) string {
|
||||
// which is what proves the policy check ran ahead of the certificate existing.
|
||||
func dispatchACM(t *testing.T, gw *GatewayConfig, action, body string) error {
|
||||
t.Helper()
|
||||
return gw.ACM_Request(httptest.NewRecorder(), setupACMRequest("CertificateManager."+action, body))
|
||||
return gw.serveACM(httptest.NewRecorder(), setupACMRequest("CertificateManager."+action, body))
|
||||
}
|
||||
|
||||
// TestACMRequest_ScopedDenyFires is the bypass this work closes. An operator
|
||||
@@ -185,7 +185,7 @@ func TestACMRequest_MissingAccountID(t *testing.T) {
|
||||
req := setupACMRequest("CertificateManager.ListCertificates", `{}`)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxAccountID, ""))
|
||||
|
||||
err := gw.ACM_Request(httptest.NewRecorder(), req)
|
||||
err := gw.serveACM(httptest.NewRecorder(), req)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func acmPipelineApp(t *testing.T, docs []handlers_iam.PolicyDocument) (*GatewayC
|
||||
},
|
||||
}
|
||||
|
||||
withACM(gw, acmawsapi.Deps{})
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
r.HandleFunc("/*", gw.Request)
|
||||
@@ -165,12 +167,14 @@ func TestACMActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
|
||||
{"bare action", "ListCertificates", "ListCertificates"},
|
||||
{"trailing dot", "CertificateManager.", ""},
|
||||
}
|
||||
acmEntry, ok := withACM(&GatewayConfig{}, acmawsapi.Deps{}).registered(acmawsapi.ServiceName)
|
||||
require.True(t, ok)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.wantAction, acmActionFromTarget(tc.target))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-Amz-Target", tc.target)
|
||||
assert.Equal(t, tc.wantAction, acmEntry.ResolveAction(req))
|
||||
|
||||
assert.Equal(t, tc.wantAction, resolveNonQueryAction(req, "acm"),
|
||||
"dispatch and telemetry parsers must agree on %q", tc.target)
|
||||
})
|
||||
@@ -236,7 +240,7 @@ func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
|
||||
func TestRequest_ClusterUnavailableNilConn_ACM(t *testing.T) {
|
||||
for _, action := range []string{"ListCertificates", "DeleteCertificate"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
|
||||
gw := withACM(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}, acmawsapi.Deps{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-Amz-Target", "CertificateManager."+action)
|
||||
@@ -324,7 +328,7 @@ func TestACMPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
|
||||
// that drops or adds an action, including by moving ACM onto the
|
||||
// registration seam, fails here.
|
||||
func TestACMInventory_RegisteredArePinned(t *testing.T) {
|
||||
acm := AWSOperationInventory(nil)["acm"]
|
||||
acm := AWSOperationInventory(acmRegistrationInventory())["acm"]
|
||||
|
||||
want := []string{
|
||||
"ImportCertificate", "RequestCertificate", "DescribeCertificate", "GetCertificate",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
)
|
||||
|
||||
// withACM registers the ACM control plane on gw backed by deps, as the awsgw
|
||||
// role composes it. An unset deps.NATS takes gw.NATSConn.
|
||||
func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
|
||||
if deps.NATS == nil {
|
||||
deps.NATS = gw.NATSConn
|
||||
}
|
||||
b := dispatch.NewBuilder()
|
||||
if err := b.Register(acmawsapi.NewRegistration(deps)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
gw.Services = b.Build()
|
||||
return gw
|
||||
}
|
||||
|
||||
// serveACM dispatches r to gw's registered ACM service exactly as Request does
|
||||
// past its NATS gate, first registering one with no capabilities if needed.
|
||||
func (gw *GatewayConfig) serveACM(w http.ResponseWriter, r *http.Request) error {
|
||||
entry, ok := gw.registered(acmawsapi.ServiceName)
|
||||
if !ok {
|
||||
withACM(gw, acmawsapi.Deps{})
|
||||
entry, _ = gw.registered(acmawsapi.ServiceName)
|
||||
}
|
||||
return gw.dispatchRegistered(entry, w, r)
|
||||
}
|
||||
|
||||
// acmRegistrationInventory is the inventory the ACM registration declares to
|
||||
// a registry, keyed as AWSOperationInventory expects.
|
||||
func acmRegistrationInventory() map[string]dispatch.Inventory {
|
||||
return withACM(&GatewayConfig{}, acmawsapi.Deps{}).Services.Inventory()
|
||||
}
|
||||
@@ -22,35 +22,10 @@ func setupACMRequest(target, body string) *http.Request {
|
||||
return withTestIdentity(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
func TestACMActionFromTarget(t *testing.T) {
|
||||
assert.Equal(t, "ImportCertificate", acmActionFromTarget("CertificateManager.ImportCertificate"))
|
||||
assert.Equal(t, "ListCertificates", acmActionFromTarget("ListCertificates"))
|
||||
assert.Empty(t, acmActionFromTarget(""))
|
||||
}
|
||||
|
||||
func TestACMActionsMap_AllActionsRegistered(t *testing.T) {
|
||||
expected := []string{
|
||||
"ImportCertificate",
|
||||
"DescribeCertificate",
|
||||
"GetCertificate",
|
||||
"ListCertificates",
|
||||
"DeleteCertificate",
|
||||
"ListTagsForCertificate",
|
||||
"AddTagsToCertificate",
|
||||
"RemoveTagsFromCertificate",
|
||||
"RequestCertificate",
|
||||
}
|
||||
for _, action := range expected {
|
||||
_, ok := acmActions[action]
|
||||
assert.True(t, ok, "action %q should be registered in acmActions", action)
|
||||
}
|
||||
assert.Len(t, acmActions, len(expected))
|
||||
}
|
||||
|
||||
func TestACMRequest_MissingTarget(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
|
||||
w := httptest.NewRecorder()
|
||||
err := gw.ACM_Request(w, setupACMRequest("", ""))
|
||||
err := gw.serveACM(w, setupACMRequest("", ""))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
|
||||
}
|
||||
@@ -58,7 +33,7 @@ func TestACMRequest_MissingTarget(t *testing.T) {
|
||||
func TestACMRequest_UnknownAction(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
|
||||
w := httptest.NewRecorder()
|
||||
err := gw.ACM_Request(w, setupACMRequest("CertificateManager.BogusAction", "{}"))
|
||||
err := gw.serveACM(w, setupACMRequest("CertificateManager.BogusAction", "{}"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
|
||||
}
|
||||
@@ -68,7 +43,7 @@ func TestACMRequest_UnknownAction(t *testing.T) {
|
||||
func TestACMRequest_KnownActionNoNATS(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
|
||||
w := httptest.NewRecorder()
|
||||
err := gw.ACM_Request(w, setupACMRequest("CertificateManager.ListCertificates", "{}"))
|
||||
err := gw.serveACM(w, setupACMRequest("CertificateManager.ListCertificates", "{}"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
|
||||
"io"
|
||||
@@ -2674,7 +2676,11 @@ func TestSigV4Auth_UnservedServiceNoJSONTellGetsXMLEnvelope(t *testing.T) {
|
||||
// envelope by exactly the path it did before the fallback was added — a
|
||||
// regression guard proving the fallback widened nothing for served scopes.
|
||||
func TestWriteSigV4Error_JSONErrorServicesUnaffectedByFallback(t *testing.T) {
|
||||
gw := withECR(&GatewayConfig{DisableLogging: true}, awsapi.Deps{})
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
|
||||
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
gw.Services = b.Build()
|
||||
|
||||
for _, svc := range []string{"eks", "ecr", "acm", "ecs", "tagging",
|
||||
"bedrock", "bedrock-runtime", "bedrock-agent", "bedrock-agent-runtime"} {
|
||||
@@ -2745,7 +2751,11 @@ func TestSigV4Auth_ResolvesCtxActionForNonQueryServices(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
gw := withECR(&GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: mockSvc}, awsapi.Deps{})
|
||||
gw := &GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: mockSvc}
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
|
||||
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
gw.Services = b.Build()
|
||||
|
||||
var gotAction, gotThrottleKey string
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -419,7 +419,7 @@ func TestValidateServices(t *testing.T) {
|
||||
t.Run("claiming a legacy name fails", func(t *testing.T) {
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(dispatch.Registration{
|
||||
Service: "acm",
|
||||
Service: "ec2",
|
||||
Dispatch: func(http.ResponseWriter, dispatch.Invocation) error { return nil },
|
||||
Errors: dispatch.ErrorEnvelopeJSON,
|
||||
Inventory: dispatch.Inventory{Registered: []string{fakeAction}},
|
||||
|
||||
@@ -299,7 +299,6 @@ var supportedServices = map[string]bool{
|
||||
"elasticloadbalancing": true,
|
||||
"eks": true,
|
||||
"ecs": true,
|
||||
"acm": true,
|
||||
"rds": true,
|
||||
"tagging": true,
|
||||
"spinifex": true,
|
||||
@@ -401,7 +400,7 @@ func (gw *GatewayConfig) throttleKeyFuncs() []ratelimit.KeyFunc {
|
||||
// XML body to these clients is an unparseable "<?xml…" deserialization error.
|
||||
func jsonErrorService(svc string) bool {
|
||||
switch svc {
|
||||
case "eks", "acm", "ecs", "tagging",
|
||||
case "eks", "ecs", "tagging",
|
||||
"bedrock", "bedrock-runtime", "bedrock-agent", "bedrock-agent-runtime":
|
||||
return true
|
||||
}
|
||||
@@ -523,8 +522,6 @@ func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
|
||||
err = gw.BedrockAgentRuntime_Request(w, r)
|
||||
case "ecs":
|
||||
err = gw.ECS_Request(w, r)
|
||||
case "acm":
|
||||
err = gw.ACM_Request(w, r)
|
||||
case "rds":
|
||||
err = gw.RDS_Request(w, r)
|
||||
case "tagging":
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"io"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -171,7 +173,7 @@ func TestErrorHandler_PrefersCallSiteMessage(t *testing.T) {
|
||||
// ResourceInUseException collision check: ACM's DeleteCertificate must not
|
||||
// surface EKS's "cluster already exists" wording for the shared wire code.
|
||||
func TestErrorHandler_ACMResourceInUse_UsesACMWording(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
|
||||
gw := withACM(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}, acmawsapi.Deps{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := context.WithValue(req.Context(), ctxService, "acm")
|
||||
req = req.WithContext(ctx)
|
||||
@@ -234,8 +236,12 @@ func TestErrorHandler_NoMessageSupplied_MatchesErrorLookup(t *testing.T) {
|
||||
// validation code outside EC2: the default must not be EC2's Reserved Instance
|
||||
// or resource-ID wording, whichever envelope the service uses.
|
||||
func TestErrorHandler_NonEC2ValidationDefaults_DropEC2Wording(t *testing.T) {
|
||||
gw := withECR(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}, awsapi.Deps{})
|
||||
services := append(slices.Collect(maps.Keys(supportedServices)), awsapi.ServiceName)
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
|
||||
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
gw.Services = b.Build()
|
||||
services := append(slices.Collect(maps.Keys(supportedServices)), awsapi.ServiceName, acmawsapi.ServiceName)
|
||||
for _, svc := range services {
|
||||
if svc == "ec2" {
|
||||
continue
|
||||
|
||||
@@ -24,9 +24,6 @@ type ServiceOperationInventory struct {
|
||||
// operations and delegates that REST surface to Predastore.
|
||||
func AWSOperationInventory(registered map[string]dispatch.Inventory) map[string]ServiceOperationInventory {
|
||||
inventory := map[string]ServiceOperationInventory{
|
||||
"acm": {
|
||||
Registered: mapKeys(acmActions),
|
||||
},
|
||||
"ec2": {
|
||||
Registered: mapKeys(ec2Actions),
|
||||
},
|
||||
|
||||
@@ -77,13 +77,24 @@ var declaredInventoryReaders = map[string]bool{
|
||||
"cmd/aws-model-coverage": true,
|
||||
}
|
||||
|
||||
// domainAWSAPIImpls are the registered domains' awsapi packages: the
|
||||
// implementations ADR-0002 acceptance 3 confines to the awsgw role,
|
||||
// alongside the registration contract itself.
|
||||
var domainAWSAPIImpls = map[string]string{
|
||||
modulePrefix + "domains/ecr/awsapi": "ECR implementation",
|
||||
modulePrefix + "domains/acm/awsapi": "ACM implementation",
|
||||
}
|
||||
|
||||
// ADR-0002 acceptance 3: the awsgw role is the only production composition
|
||||
// point that imports both the registration contract and ECR implementation.
|
||||
// point that imports both the registration contract and a registered
|
||||
// domain's awsapi implementation (ECR, ACM, …).
|
||||
func TestS4_OnlyAWSGWRoleImportsDispatchAndECRAWSAPI(t *testing.T) {
|
||||
const (
|
||||
contract = modulePrefix + "ingress/aws/dispatch"
|
||||
ecrImpl = modulePrefix + "domains/ecr/awsapi"
|
||||
)
|
||||
const contract = modulePrefix + "ingress/aws/dispatch"
|
||||
watched := map[string]bool{contract: true}
|
||||
for impl := range domainAWSAPIImpls {
|
||||
watched[impl] = true
|
||||
}
|
||||
|
||||
moduleRoot := filepath.Dir(filepath.Dir(filepath.Dir(ingressAWSDir(t))))
|
||||
imports := map[string]map[string]token.Position{}
|
||||
fset := token.NewFileSet()
|
||||
@@ -112,7 +123,7 @@ func TestS4_OnlyAWSGWRoleImportsDispatchAndECRAWSAPI(t *testing.T) {
|
||||
pkg := filepath.ToSlash(rel)
|
||||
for _, imp := range f.Imports {
|
||||
p, _ := strconv.Unquote(imp.Path.Value)
|
||||
if p != contract && p != ecrImpl {
|
||||
if !watched[p] {
|
||||
continue
|
||||
}
|
||||
if imports[pkg] == nil {
|
||||
@@ -127,14 +138,22 @@ func TestS4_OnlyAWSGWRoleImportsDispatchAndECRAWSAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(imports[compositionRoot]) != 2 {
|
||||
t.Fatalf("%s no longer imports both %s and %s; the invariant would pass vacuously", compositionRoot, contract, ecrImpl)
|
||||
if len(imports[compositionRoot]) != len(watched) {
|
||||
t.Fatalf("%s no longer imports the registration contract and every registered domain awsapi (%d of %d); the invariant would pass vacuously",
|
||||
compositionRoot, len(imports[compositionRoot]), len(watched))
|
||||
}
|
||||
for pkg, found := range imports {
|
||||
if len(found) < 2 || pkg == compositionRoot || declaredInventoryReaders[pkg] {
|
||||
if pkg == compositionRoot || declaredInventoryReaders[pkg] {
|
||||
continue
|
||||
}
|
||||
t.Errorf("ADR-0002 acceptance 3 \"The awsgw role is the only production composition point that imports both the registration contract and ECR implementation\": %s imports %s (%s) and %s (%s); register ECR in runtime/roles/awsgw instead",
|
||||
pkg, contract, found[contract], ecrImpl, found[ecrImpl])
|
||||
if _, hasContract := found[contract]; !hasContract {
|
||||
continue
|
||||
}
|
||||
for impl, label := range domainAWSAPIImpls {
|
||||
if pos, ok := found[impl]; ok {
|
||||
t.Errorf("ADR-0002 acceptance 3 \"The awsgw role is the only production composition point that imports both the registration contract and a registered domain's awsapi implementation\": %s imports %s (%s) and %s (%s, the %s); register it in runtime/roles/awsgw instead",
|
||||
pkg, contract, found[contract], impl, pos, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/daemon"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
@@ -502,7 +503,8 @@ func launchService(config *config.ClusterConfig) error {
|
||||
AuthorizationToken: awsapi.NewAuthorizationTokenActionService(ecrIssuer, ecrEndpoint),
|
||||
NATS: natsConn,
|
||||
})
|
||||
if err := wireServiceRegistry(&gw, ecrControlPlane); err != nil {
|
||||
acmControlPlane := acmawsapi.NewRegistration(acmawsapi.Deps{NATS: natsConn})
|
||||
if err := wireServiceRegistry(&gw, ecrControlPlane, acmControlPlane); err != nil {
|
||||
return fmt.Errorf("awsgw: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
@@ -14,9 +15,10 @@ import (
|
||||
)
|
||||
|
||||
// wireServiceRegistry is the exact call launchService makes to wire
|
||||
// gw.Services before serving a request. Production's ECR registration must
|
||||
// wire an overlap-free registry serving ecr; a duplicate or a registration
|
||||
// claiming a legacy name must fail it, so the role refuses to start.
|
||||
// gw.Services before serving a request. Production's ECR and ACM
|
||||
// registrations must wire an overlap-free registry serving ecr and acm; a
|
||||
// duplicate or a registration claiming a legacy name must fail it, so the
|
||||
// role refuses to start.
|
||||
func TestWireServiceRegistry(t *testing.T) {
|
||||
t.Run("ECR registration wires and passes the overlap guard", func(t *testing.T) {
|
||||
gw := &gateway.GatewayConfig{}
|
||||
@@ -32,10 +34,35 @@ func TestWireServiceRegistry(t *testing.T) {
|
||||
require.Error(t, wireServiceRegistry(gw, reg, reg))
|
||||
})
|
||||
|
||||
t.Run("ACM registration wires and passes the overlap guard", func(t *testing.T) {
|
||||
gw := &gateway.GatewayConfig{}
|
||||
require.NoError(t, wireServiceRegistry(gw, acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
require.NotNil(t, gw.Services)
|
||||
_, ok := gw.Services.Lookup("acm")
|
||||
assert.True(t, ok)
|
||||
})
|
||||
|
||||
t.Run("a duplicate ACM registration fails startup", func(t *testing.T) {
|
||||
gw := &gateway.GatewayConfig{}
|
||||
reg := acmawsapi.NewRegistration(acmawsapi.Deps{})
|
||||
require.Error(t, wireServiceRegistry(gw, reg, reg))
|
||||
})
|
||||
|
||||
t.Run("ECR and ACM registrations wire together, as production does", func(t *testing.T) {
|
||||
gw := &gateway.GatewayConfig{}
|
||||
require.NoError(t, wireServiceRegistry(gw,
|
||||
awsapi.NewRegistration(awsapi.Deps{}), acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
require.NotNil(t, gw.Services)
|
||||
_, ecrOK := gw.Services.Lookup("ecr")
|
||||
_, acmOK := gw.Services.Lookup("acm")
|
||||
assert.True(t, ecrOK)
|
||||
assert.True(t, acmOK)
|
||||
})
|
||||
|
||||
t.Run("a registration claiming a legacy name fails startup", func(t *testing.T) {
|
||||
gw := &gateway.GatewayConfig{}
|
||||
err := wireServiceRegistry(gw, dispatch.Registration{
|
||||
Service: "acm",
|
||||
Service: "ec2",
|
||||
Dispatch: func(http.ResponseWriter, dispatch.Invocation) error { return nil },
|
||||
Errors: dispatch.ErrorEnvelopeJSON,
|
||||
Inventory: dispatch.Inventory{Registered: []string{"Whatever"}},
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"encoding/json"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
@@ -180,6 +181,7 @@ func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option)
|
||||
AuthorizationToken: awsapi.NewAuthorizationTokenActionService(ecrIssuer, ecrEndpoint),
|
||||
NATS: nc,
|
||||
})))
|
||||
require.NoError(t, services.Register(acmawsapi.NewRegistration(acmawsapi.Deps{NATS: nc})))
|
||||
|
||||
cfg := &gateway.GatewayConfig{
|
||||
DisableLogging: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
awscreds "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
awsdispatch "github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
@@ -34,7 +35,8 @@ const slowRequest = 500 * time.Millisecond
|
||||
// daemon-lite wired, and records each verdict for the suite report.
|
||||
func TestRequestConformance(t *testing.T) {
|
||||
inventory := gateway.AWSOperationInventory(map[string]awsdispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
})
|
||||
for _, service := range awsmodel.Services() {
|
||||
// Predastore serves S3, not the gateway.
|
||||
|
||||
Reference in New Issue
Block a user