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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ Every service links to its own page with a coverage breakdown — audited operat
| [Data Lifecycle Manager](services/dlm/README.md) | A | 8 | clean |
| [EFS](services/efs/README.md) | A | 31 | 2 gaps; 2 deferred |
| [FSx](services/fsx/README.md) | A | — | 13 families; 4 gaps |
| [S3](services/s3/README.md) | A | 20 | 11 gaps |
| [S3](services/s3/README.md) | A | 20 | 10 gaps |
| [S3 Control](services/s3control/README.md) | A | 45 | 6 gaps; 3 deferred |
| [S3 Glacier](services/glacier/README.md) | A | 33 | 2 gaps |
| [S3 Tables](services/s3tables/README.md) | A | 49 | 1 gap |
Expand Down
60 changes: 60 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,11 @@ func buildEchoServer(
return c.Redirect(http.StatusFound, "/dashboard/static/favicon.png")
})
e.GET("/_gopherstack/health", buildHealthHandler(services))
e.GET("/_localstack/health", buildLocalstackHealthHandler(services))
e.GET("/_aws/health", buildLocalstackHealthHandler(services))
e.GET("/_localstack/init", buildLocalstackInitHandler())
e.GET("/_localstack/init/ready", buildLocalstackInitHandler())
e.GET("/_localstack/info", buildLocalstackInfoHandler())
e.POST("/_gopherstack/reset", buildResetHandler(services))
e.POST("/_gopherstack/snapshot", buildSnapshotHandler(persistManager))
e.POST("/_gopherstack/load", buildLoadHandler(persistManager))
Expand Down Expand Up @@ -10434,6 +10439,61 @@ type healthResponse struct {
NumGC uint32 `json:"num_gc"`
}

// localstackHealthResponse is the JSON body returned by LocalStack-compatible health endpoints.
type localstackHealthResponse struct {
Services map[string]string `json:"services"`
Version string `json:"version"`
Edition string `json:"edition"`
}

// localstackInitResponse is the JSON body returned by LocalStack-compatible init endpoints.
type localstackInitResponse struct {
Scripts []string `json:"scripts"`
Completed bool `json:"completed"`
}

func buildLocalstackHealthHandler(services []service.Registerable) echo.HandlerFunc {
return func(c *echo.Context) error {
svcMap := make(map[string]string, len(services))
for _, svc := range services {
svcMap[strings.ToLower(svc.Name())] = "available"
}

return c.JSON(http.StatusOK, localstackHealthResponse{
Services: svcMap,
Version: version.Get(),
Edition: "community",
})
}
}

func buildLocalstackInitHandler() echo.HandlerFunc {
return func(c *echo.Context) error {
return c.JSON(http.StatusOK, localstackInitResponse{
Completed: true,
Scripts: []string{},
})
}
}

type localstackInfoResponse struct {
Version string `json:"version"`
Edition string `json:"edition"`
SessionID string `json:"session_id"`
IsAuth bool `json:"is_auth"`
}

func buildLocalstackInfoHandler() echo.HandlerFunc {
return func(c *echo.Context) error {
return c.JSON(http.StatusOK, localstackInfoResponse{
Version: version.Get(),
Edition: "community",
IsAuth: false,
SessionID: "00000000-0000-0000-0000-000000000000",
})
}
}

func setupChaosAndRegistry(
e *echo.Echo,
log *slog.Logger,
Expand Down
107 changes: 107 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3061,6 +3061,113 @@ func TestHealthEndpoint_GoroutineAndMemStats(t *testing.T) {
}
}

//nolint:paralleltest // uses a fixed port that cannot be parallelised
func TestLocalstackCompatibilityEndpoints(t *testing.T) {
port := freeTCPPort(t)
cli := parseCLI(t, map[string]string{"PORT": strconv.Itoa(port)})

ctx, cancel := context.WithCancel(t.Context())
defer cancel()

errCh := make(chan error, 1)
go func() {
errCh <- run(ctx, cli)
}()

client := &http.Client{Timeout: 2 * time.Second}

require.Eventually(t, func() bool {
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/_gopherstack/health", port))
if err != nil {
return false
}
resp.Body.Close()

return resp.StatusCode == http.StatusOK
}, 3*time.Second, 50*time.Millisecond, "server did not become ready")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

tests := []struct {
validate func(t *testing.T, body map[string]any)
name string
path string
}{
{
name: "localstack_health",
path: "/_localstack/health",
validate: func(t *testing.T, body map[string]any) {
t.Helper()
assert.Equal(t, "community", body["edition"])
assert.NotEmpty(t, body["version"])
services, ok := body["services"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "available", services["s3"])
assert.Equal(t, "available", services["dynamodb"])
},
},
{
name: "aws_health",
path: "/_aws/health",
validate: func(t *testing.T, body map[string]any) {
t.Helper()
assert.Equal(t, "community", body["edition"])
services, ok := body["services"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "available", services["s3"])
},
},
{
name: "localstack_init",
path: "/_localstack/init",
validate: func(t *testing.T, body map[string]any) {
t.Helper()
assert.Equal(t, true, body["completed"])
assert.NotNil(t, body["scripts"])
},
},
{
name: "localstack_init_ready",
path: "/_localstack/init/ready",
validate: func(t *testing.T, body map[string]any) {
t.Helper()
assert.Equal(t, true, body["completed"])
},
},
{
name: "localstack_info",
path: "/_localstack/info",
validate: func(t *testing.T, body map[string]any) {
t.Helper()
assert.Equal(t, "community", body["edition"])
assert.Equal(t, false, body["is_auth"])
assert.NotEmpty(t, body["version"])
assert.Equal(t, "00000000-0000-0000-0000-000000000000", body["session_id"])
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := client.Get(fmt.Sprintf("http://localhost:%d%s", port, tt.path))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
tt.validate(t, body)
})
}

cancel()

select {
case shutdownErr := <-errCh:
require.NoError(t, shutdownErr)
case <-time.After(5 * time.Second):
require.FailNow(t, "server did not shut down within timeout")
}
}

// TestCustomHTTPErrorHandler_LogsServerErrors verifies that the custom Echo error
// handler returns the correct status code for server errors (5xx).
func TestCustomHTTPErrorHandler_LogsServerErrors(t *testing.T) {
Expand Down
24 changes: 20 additions & 4 deletions pkgs/awsmeta/awsmeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type Metadata struct {
Partition string
// RequestID is the X-Amz-Request-Id correlation value.
RequestID string
// AccessKeyID is the AWS access key ID extracted from the SigV4 credential scope.
AccessKeyID string
// Service is the AWS service name extracted from the SigV4 credential scope.
Service string
}

// DefaultAccount is the gopherstack default 12-digit account ID.
Expand Down Expand Up @@ -73,6 +77,16 @@ func Partition(ctx context.Context) string {
return Get(ctx).Partition
}

// AccessKeyID returns Get(ctx).AccessKeyID.
func AccessKeyID(ctx context.Context) string {
return Get(ctx).AccessKeyID
}

// Service returns Get(ctx).Service.
func Service(ctx context.Context) string {
return Get(ctx).Service
}

// FromRequest builds a Metadata from r. defaultRegion is applied when no
// region is derivable from the SigV4 scope. Always returns non-nil with
// Account and Partition populated.
Expand All @@ -85,10 +99,12 @@ func FromRequest(r *http.Request, defaultRegion string) *Metadata {
}

m := &Metadata{
Account: DefaultAccount,
Region: httputils.ExtractRegionFromRequest(r, defaultRegion),
Partition: DefaultPartition,
RequestID: httputils.SanitizeHeaderString(r.Header.Get("X-Amz-Request-Id")),
Account: DefaultAccount,
Region: httputils.ExtractRegionFromRequest(r, defaultRegion),
Partition: DefaultPartition,
RequestID: httputils.SanitizeHeaderString(r.Header.Get("X-Amz-Request-Id")),
AccessKeyID: httputils.ExtractAccessKeyFromRequest(r),
Service: httputils.ExtractServiceFromRequest(r),
}

if v := r.Header.Get("X-Amz-Account-Id"); v != "" {
Expand Down
Loading
Loading