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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
- awsJson responses carry `X-Amz-Crc32`, as DynamoDB's do; the AWS SDK for Go v2 logged a checksum warning on every DynamoDB call without it.
- The `--tls-self-signed` certificate also covers `*.localhost` and `*.<public-host>`, so S3 Control calls (`<account>.localhost`, made by the Terraform and Pulumi AWS providers) verify over HTTPS. Delete `<data-dir>/tls/` to replace a certificate made by an earlier version.
- CloudWatch Logs, CloudWatch metrics and the Lambda environment reaper only see this installation's containers; on a Docker host shared with test runs they listed (and the reaper removed) containers of other accounts.
- IAM: `UpdateAccountPasswordPolicy`, `DeleteAccountPasswordPolicy` and a real `GetAccountPasswordPolicy` (the policy is stored and reported; console passwords are not yet checked against it), and `CreateAccountAlias`/`DeleteAccountAlias` with `ListAccountAliases` returning the alias ([#91](https://github.com/solinode/homecloud/issues/91), [#92](https://github.com/solinode/homecloud/issues/92)).
- KMS: `ListRetirableGrants` lists the grants a principal can retire across all keys ([#93](https://github.com/solinode/homecloud/issues/93)).
- EventBridge: `UpdateEventBus` changes a bus's description, dead-letter queue (checked for `sqs:SendMessage`) or KMS key, the default bus's included ([#94](https://github.com/solinode/homecloud/issues/94)).
- CloudWatch Logs: saved Logs Insights queries (`PutQueryDefinition`, `DescribeQueryDefinitions`, `DeleteQueryDefinition`) validate `maxResults` and are covered by update, pagination and restart tests ([#95](https://github.com/solinode/homecloud/issues/95)).
- DynamoDB: `UpdateContributorInsights` and `ListContributorInsights`; `DescribeContributorInsights` reports the stored setting of a table or GSI. No rules or metrics are produced ([#96](https://github.com/solinode/homecloud/issues/96)).

## 0.5.0 (2026-10-14)

Expand Down
15 changes: 13 additions & 2 deletions cli/internal/svc/cfn/awstypes_infra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ func (e *infraEnv) deploy(t *testing.T, verb, name, tmpl string, params ...strin
e.AWS(t, args...)
}

// stackFrom parses describe-stacks output. The helpers below use it rather
// than describing the stack again, which races with the stack's deletion.
func stackFrom(t *testing.T, out string) map[string]any {
t.Helper()
var d struct{ Stacks []map[string]any }
if err := json.Unmarshal([]byte(out), &d); err != nil || len(d.Stacks) == 0 {
t.Fatalf("describe-stacks: %v: %s", err, out)
}
return d.Stacks[0]
}

// wait polls a stack for one of the statuses, with time for containers.
func (e *infraEnv) wait(t *testing.T, name string, want ...string) map[string]any {
t.Helper()
Expand All @@ -120,7 +131,7 @@ func (e *infraEnv) wait(t *testing.T, name string, want ...string) map[string]an
for time.Now().Before(deadline) {
out, err := e.AWSErr(t, "cloudformation", "describe-stacks", "--stack-name", name)
if err == nil {
st := e.stack(t, name)
st := stackFrom(t, out)
last = str(st, "StackStatus")
for _, w := range want {
if last == w {
Expand Down Expand Up @@ -156,7 +167,7 @@ func (e *infraEnv) deleted(t *testing.T, name string) {
if err != nil && strings.Contains(out, "does not exist") {
return
}
if err == nil && str(e.stack(t, name), "StackStatus") == "DELETE_FAILED" {
if err == nil && str(stackFrom(t, out), "StackStatus") == "DELETE_FAILED" {
t.Fatalf("delete of %s failed: %v", name, e.eventLines(t, name))
}
time.Sleep(200 * time.Millisecond)
Expand Down
29 changes: 29 additions & 0 deletions cli/internal/svc/cloudwatch/aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
Expand All @@ -14,6 +15,7 @@ import (

"github.com/homecloudhq/homecloud/cli/internal/awsapi"
"github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest"
"github.com/homecloudhq/homecloud/cli/internal/store"
)

func newAWS(t *testing.T) (*awstest.Harness, *Service) {
Expand Down Expand Up @@ -269,6 +271,33 @@ func TestLogsQueryDefinitions(t *testing.T) {
if again := h.AWSJSON(t, "logs", "put-query-definition", "--query-definition-id", id, "--name", "app/errors", "--query-string", "fields @message")["queryDefinitionId"]; again != id {
t.Fatalf("update returned %v", again)
}
defs = h.AWSJSON(t, "logs", "describe-query-definitions", "--query-definition-name-prefix", "app/")["queryDefinitions"].([]any)
if d := defs[0].(map[string]any); len(defs) != 1 || d["queryString"] != "fields @message" || d["logGroupNames"] != nil || d["lastModified"].(float64) <= 0 {
t.Fatalf("after update: %v", defs)
}
if o, err := h.AWSErr(t, "logs", "put-query-definition", "--query-definition-id", "00000000-0000-0000-0000-000000000000", "--name", "x", "--query-string", "fields @message"); err == nil || !strings.Contains(o, "ResourceNotFoundException") {
t.Fatalf("update unknown id: %v %s", err, o)
}

// Pagination.
p1 := h.AWSJSON(t, "logs", "describe-query-definitions", "--max-results", "1")
if len(p1["queryDefinitions"].([]any)) != 1 || p1["nextToken"] == nil {
t.Fatalf("page 1: %v", p1)
}
p2 := h.AWSJSON(t, "logs", "describe-query-definitions", "--max-results", "1", "--next-token", p1["nextToken"].(string))
if l := p2["queryDefinitions"].([]any); len(l) != 1 || l[0].(map[string]any)["name"] != "other" {
t.Fatalf("page 2: %v", p2)
}

// Definitions are stored, so they survive a restart.
st, err := store.Open(filepath.Join(h.Env.Cfg.DataDir, "state.json"))
if err != nil {
t.Fatal(err)
}
if defs := store.List[queryDef](st, cQueryDefs); len(defs) != 2 {
t.Fatalf("stored definitions: %v", defs)
}

if d := h.AWSJSON(t, "logs", "delete-query-definition", "--query-definition-id", id); d["success"] != true {
t.Fatalf("delete: %v", d)
}
Expand Down
3 changes: 3 additions & 0 deletions cli/internal/svc/cloudwatch/logs_querydefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ func (s *Service) awsDescribeQueryDefinitions(q *awsapi.Req) (any, error) {
if err := q.Authorize("logs:DescribeQueryDefinitions", "*"); err != nil {
return nil, err
}
if in.MaxResults < 0 || in.MaxResults > 1000 {
return nil, logsErr("InvalidParameterException", "maxResults must be 1-1000")
}
all := store.List[queryDef](s.env.Store, cQueryDefs)
slices.SortFunc(all, func(a, b queryDef) int { return strings.Compare(a.Name+a.ID, b.Name+b.ID) })
var out []map[string]any
Expand Down
21 changes: 3 additions & 18 deletions cli/internal/svc/dynamodb/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func (s *Service) RegisterAWS() {
"TransactGetItems": s.awsTransactGet,
"DescribeKinesisStreamingDestination": s.awsDescribeKinesis,
"DescribeContributorInsights": s.awsDescribeContributorInsights,
"UpdateContributorInsights": s.awsUpdateContributorInsights,
"ListContributorInsights": s.awsListContributorInsights,
"ExecuteStatement": s.awsExecuteStatement,
"BatchExecuteStatement": s.awsBatchExecuteStatement,
"ExecuteTransaction": s.awsExecuteTransaction,
Expand Down Expand Up @@ -685,6 +687,7 @@ func (s *Service) removeIndex(t *Table, tx *bolt.Tx, name string) error {
if !found {
return errf("ResourceNotFoundException", "Requested resource not found: Index: %s not found", name)
}
delete(t.ContributorInsights, name)
// Drop attribute definitions no key uses any more.
cp := *t
cp.Attributes = nil
Expand Down Expand Up @@ -987,24 +990,6 @@ func (s *Service) awsDescribeKinesis(q *awsapi.Req) (any, error) {
return map[string]any{"TableName": in.TableName, "KinesisDataStreamDestinations": []any{}}, nil
}

func (s *Service) awsDescribeContributorInsights(q *awsapi.Req) (any, error) {
var in struct{ TableName, IndexName string }
if err := q.Bind(&in); err != nil {
return nil, err
}
if err := q.Authorize("dynamodb:DescribeContributorInsights", s.tableARN(in.TableName)); err != nil {
return nil, err
}
if _, err := s.getTable(in.TableName); err != nil {
return nil, err
}
res := map[string]any{"TableName": in.TableName, "ContributorInsightsStatus": "DISABLED"}
if in.IndexName != "" {
res["IndexName"] = in.IndexName
}
return res, nil
}

// ---- consumed capacity ----

func capacityUnits(bytes int, write bool, consistent bool) float64 {
Expand Down
93 changes: 93 additions & 0 deletions cli/internal/svc/dynamodb/aws_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package dynamodb

import (
"encoding/json"
"path/filepath"
"strings"
"testing"

"github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest"
"github.com/homecloudhq/homecloud/cli/internal/store"
)

func harness(t *testing.T) (*awstest.Harness, *Service) {
Expand Down Expand Up @@ -52,6 +54,97 @@ func TestAWSWarmThroughput(t *testing.T) {
}
}

// Contributor Insights settings are stored and reported (aws_dynamodb_contributor_insights).
func TestAWSContributorInsights(t *testing.T) {
h, _ := harness(t)
for _, name := range []string{"one", "two"} {
h.AWS(t, "dynamodb", "create-table", "--table-name", name,
"--attribute-definitions", "AttributeName=id,AttributeType=S", "AttributeName=g,AttributeType=S",
"--key-schema", "AttributeName=id,KeyType=HASH", "--billing-mode", "PAY_PER_REQUEST",
"--global-secondary-indexes", `[{"IndexName":"ByG","KeySchema":[{"AttributeName":"g","KeyType":"HASH"}],"Projection":{"ProjectionType":"ALL"}}]`)
}
if d := h.AWSJSON(t, "dynamodb", "describe-contributor-insights", "--table-name", "one"); d["ContributorInsightsStatus"] != "DISABLED" {
t.Fatalf("initial describe: %v", d)
}
if l := h.AWSJSON(t, "dynamodb", "list-contributor-insights")["ContributorInsightsSummaries"].([]any); len(l) != 0 {
t.Fatalf("initial list: %v", l)
}

up := h.AWSJSON(t, "dynamodb", "update-contributor-insights", "--table-name", "one", "--contributor-insights-action", "ENABLE")
if up["TableName"] != "one" || up["ContributorInsightsStatus"] != "ENABLED" || up["IndexName"] != nil {
t.Fatalf("update: %v", up)
}
d := h.AWSJSON(t, "dynamodb", "describe-contributor-insights", "--table-name", "one")
if d["ContributorInsightsStatus"] != "ENABLED" || d["LastUpdateDateTime"] == nil {
t.Fatalf("describe: %v", d)
}
up = h.AWSJSON(t, "dynamodb", "update-contributor-insights", "--table-name", "one", "--index-name", "ByG", "--contributor-insights-action", "ENABLE")
if up["IndexName"] != "ByG" || up["ContributorInsightsStatus"] != "ENABLED" {
t.Fatalf("update index: %v", up)
}
if d := h.AWSJSON(t, "dynamodb", "describe-contributor-insights", "--table-name", "one", "--index-name", "ByG"); d["ContributorInsightsStatus"] != "ENABLED" || d["IndexName"] != "ByG" {
t.Fatalf("describe index: %v", d)
}
h.AWS(t, "dynamodb", "update-contributor-insights", "--table-name", "two", "--contributor-insights-action", "ENABLE")
h.AWS(t, "dynamodb", "update-contributor-insights", "--table-name", "two", "--contributor-insights-action", "DISABLE")

summary := func(m any) string {
s := m.(map[string]any)
ix, _ := s["IndexName"].(string)
return s["TableName"].(string) + "/" + ix + "=" + s["ContributorInsightsStatus"].(string)
}
l := h.AWSJSON(t, "dynamodb", "list-contributor-insights")["ContributorInsightsSummaries"].([]any)
if len(l) != 3 || summary(l[0]) != "one/=ENABLED" || summary(l[1]) != "one/ByG=ENABLED" || summary(l[2]) != "two/=DISABLED" {
t.Fatalf("list: %v", l)
}
if l := h.AWSJSON(t, "dynamodb", "list-contributor-insights", "--table-name", "two")["ContributorInsightsSummaries"].([]any); len(l) != 1 || summary(l[0]) != "two/=DISABLED" {
t.Fatalf("list u: %v", l)
}
p1 := h.AWSJSON(t, "dynamodb", "list-contributor-insights", "--max-results", "2")
if len(p1["ContributorInsightsSummaries"].([]any)) != 2 || p1["NextToken"] == nil {
t.Fatalf("page 1: %v", p1)
}
p2 := h.AWSJSON(t, "dynamodb", "list-contributor-insights", "--max-results", "2", "--next-token", p1["NextToken"].(string))
if l := p2["ContributorInsightsSummaries"].([]any); len(l) != 1 || summary(l[0]) != "two/=DISABLED" || p2["NextToken"] != nil {
t.Fatalf("page 2: %v", p2)
}

for _, args := range [][]string{
{"--table-name", "nope", "--contributor-insights-action", "ENABLE"},
{"--table-name", "one", "--index-name", "Nope", "--contributor-insights-action", "ENABLE"},
} {
if out, err := h.AWSErr(t, append([]string{"dynamodb", "update-contributor-insights"}, args...)...); err == nil || !strings.Contains(out, "ResourceNotFoundException") {
t.Fatalf("%v: %v %s", args, err, out)
}
}

// The setting survives a restart.
st, err := store.Open(filepath.Join(h.Env.Cfg.DataDir, "state.json"))
if err != nil {
t.Fatal(err)
}
if tb, err := store.Get[Table](st, cTables, "one"); err != nil || tb.ContributorInsights[""].Status != "ENABLED" || tb.ContributorInsights["ByG"].Status != "ENABLED" {
t.Fatalf("stored table: %+v %v", tb.ContributorInsights, err)
}

// Deleting an index or the table removes its setting.
h.AWS(t, "dynamodb", "update-table", "--table-name", "one", "--global-secondary-index-updates", `[{"Delete":{"IndexName":"ByG"}}]`)
h.AWS(t, "dynamodb", "delete-table", "--table-name", "two")
l = h.AWSJSON(t, "dynamodb", "list-contributor-insights")["ContributorInsightsSummaries"].([]any)
if len(l) != 1 || summary(l[0]) != "one/=ENABLED" {
t.Fatalf("list after deletes: %v", l)
}

// Authorized on the table (or index) ARN.
akid, secret := h.User(t, "ro", "ReadOnlyAccess")
if out, err := h.AWSAs(t, akid, secret, "", "dynamodb", "update-contributor-insights", "--table-name", "one", "--contributor-insights-action", "DISABLE"); err == nil || !strings.Contains(out, "AccessDenied") {
t.Fatalf("read-only update: %v %s", err, out)
}
if out, err := h.AWSAs(t, akid, secret, "", "dynamodb", "list-contributor-insights"); err != nil {
t.Fatalf("read-only list: %v %s", err, out)
}
}

func TestAWSCLI(t *testing.T) {
h, _ := harness(t)
out := h.AWSJSON(t, "dynamodb", "create-table", "--table-name", "Music",
Expand Down
Loading
Loading