Skip to content
Draft
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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ test_integration_restart_server: test_setup_restart_server
test_integration_single: test_setup
@export KOSLI_TESTS=true $(FAKE_CI_ENV) && $(GOTESTSUM) -- -p=1 ./... -run "${TARGET}"

test_smoke_aws: ## Run AWS contract and smoke tests against real AWS (requires AWS creds)
@echo "Running AWS contract and smoke tests against real AWS..."
@echo "Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set"
@$(GOTESTSUM) -- -v -p=1 -run "LambdaContract_RealAWS|AWSTestSuite/TestGetLambdaPackageData|AWSTestSuite/TestGetEcsTasksData|AWSTestSuite/TestGetS3Data" ./internal/aws/


test_docs: deps vet ensure_network test_setup ## Test docs
./bin/test_docs_cmds.sh docs.kosli.com/content/use_cases/simulating_a_devops_system/_index.md
Expand Down
60 changes: 60 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,63 @@
- [x] Slice 2: Add `--params` flag across all three commands
- [x] Slice 3: Show params in `--show-input` output
- [x] Slice 4: Update help text and examples

## Fakes & contract tests for cloud provider integrations (#758)

### Lambda (done — this PR)

- [x] Slice 1: Define `LambdaAPI` interface and refactor signatures
- [x] Slice 2: Contract test suite against real AWS
- [x] Slice 3: Build `FakeLambdaClient` that passes the contract
- [x] Slice 4: Fake-backed unit tests for filtering and pagination
- [x] Slice 5: Fake-backed unit tests for orchestration
- [x] Slice 6: Trim existing integration tests
- [x] Slice 7: Package-level factory + fake-backed command tests

### ECS (next)

- [ ] Define `ECSAPI` interface (`ListClusters`, `DescribeClusters`, `ListServices`, `ListTasks`, `DescribeTasks`) and refactor signatures
- [ ] Contract test suite against real AWS (env-gated)
- [ ] Build `FakeECSClient` that passes the contract (nested pagination: clusters → services → tasks)
- [ ] Fake-backed unit tests for filtering (cluster names, service names, regex, exclude patterns)
- [ ] Fake-backed unit tests for orchestration (concurrent cluster/service/task fetching, error propagation)
- [ ] `NewECSClientFunc` factory + inject fake into `snapshotECS_test.go` command tests
- [ ] Trim existing ECS integration tests to smoke tests
- [ ] Add ECS to `make test_smoke_aws`

### S3

- [ ] Define `S3API` interface (decide: fake at paginator level or raw `ListObjectsV2` level)
- [ ] Contract test suite against real AWS (env-gated)
- [ ] Build `FakeS3Client` that passes the contract
- [ ] Fake-backed unit tests for path include/exclude filtering and digest computation
- [ ] `NewS3ClientFunc` factory + inject fake into `snapshotS3_test.go` command tests
- [ ] Trim existing S3 integration tests to smoke tests
- [ ] Add S3 to `make test_smoke_aws`

### Azure Apps

- [ ] Define interfaces for ARM AppService + Azure Container Registry clients
- [ ] Contract test suite against real Azure (env-gated)
- [ ] Build fakes that pass the contracts
- [ ] Fake-backed unit tests for app listing, image fingerprinting, error propagation
- [ ] Factory + inject fakes into `snapshotAzureApps_test.go` command tests
- [ ] Trim existing Azure integration tests to smoke tests

### Docker

- [ ] Define `DockerAPI` interface (Pull, Push, Tag, Remove, Run, container operations)
- [ ] Contract test suite against real Docker daemon
- [ ] Build `FakeDockerClient` that passes the contract
- [ ] Fake-backed unit tests
- [ ] Factory + inject fake into `snapshotDocker_test.go` command tests
- [ ] Trim existing Docker integration tests to smoke tests

### Kubernetes

- [ ] Define interface for Kubernetes clientset operations (pod listing, namespace listing)
- [ ] Contract test suite against real cluster (KIND, env-gated)
- [ ] Build fake that passes the contract (semaphore pattern, namespace filtering)
- [ ] Fake-backed unit tests for filtering, large-scale concurrency, error propagation
- [ ] Factory + inject fake into `snapshotK8S_test.go` command tests
- [ ] Trim existing Kube integration tests to smoke tests
95 changes: 47 additions & 48 deletions cmd/kosli/snapshotLambda_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import (
"fmt"
"testing"

"github.com/kosli-dev/cli/internal/testHelpers"
"github.com/aws/aws-sdk-go-v2/service/lambda/types"
"github.com/kosli-dev/cli/internal/aws"
"github.com/stretchr/testify/suite"
)

Expand All @@ -19,10 +20,6 @@ type SnapshotLambdaTestSuite struct {
imageFunctionName string
}

type snapshotLambdaTestConfig struct {
requireAuthToBeSet bool
}

func (suite *SnapshotLambdaTestSuite) SetupTest() {
suite.envName = "snapshot-lambda-env"
suite.zipFunctionName = "cli-tests"
Expand All @@ -35,67 +32,75 @@ func (suite *SnapshotLambdaTestSuite) SetupTest() {
suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken)

CreateEnv(global.Org, suite.envName, "lambda", suite.T())

// Inject fake Lambda client so tests run without AWS credentials.
// The fake is seeded with two functions matching the names used in test cases.
zipCodeSha256 := "Mh48OOkSYuXHLfS9QF6bF3tvTXUOGvC3jKLiuF1vkbQ="
imageCodeSha256 := "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e"
lastModified := "2024-01-15T10:30:00.000+0000"
zipName := suite.zipFunctionName
imageName := suite.imageFunctionName
aws.NewLambdaClientFunc = func(_ *aws.AWSStaticCreds) (aws.LambdaAPI, error) {
return &aws.FakeLambdaClient{
Functions: []types.FunctionConfiguration{
{
FunctionName: &zipName,
CodeSha256: &zipCodeSha256,
LastModified: &lastModified,
PackageType: types.PackageTypeZip,
},
{
FunctionName: &imageName,
CodeSha256: &imageCodeSha256,
LastModified: &lastModified,
PackageType: types.PackageTypeImage,
},
},
}, nil
}
}

func (suite *SnapshotLambdaTestSuite) TearDownTest() {
aws.ResetLambdaClientFactory()
}

func (suite *SnapshotLambdaTestSuite) TestSnapshotLambdaCmd() {
tests := []cmdTestCase{
{
name: "snapshot lambda works with deprecated --function-name for Zip package type",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-name %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda works with deprecated --function-name for Zip package type",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-name %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
golden: fmt.Sprintf("Flag --function-name has been deprecated, use --function-names instead\n1 lambda functions were reported to environment %s\n", suite.envName),
},
{
name: "snapshot lambda works with --function-names for Zip package type",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda works with --function-names for Zip package type",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
golden: fmt.Sprintf("1 lambda functions were reported to environment %s\n", suite.envName),
},
{
name: "snapshot lambda works with --function-names taking a list of functions",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s,%s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName, suite.imageFunctionName),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda works with --function-names taking a list of functions",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s,%s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName, suite.imageFunctionName),
golden: fmt.Sprintf("2 lambda functions were reported to environment %s\n", suite.envName),
},
{
name: "snapshot lambda works with --function-names for Image package type",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.imageFunctionName),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda works with --function-names for Image package type",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.imageFunctionName),
golden: fmt.Sprintf("1 lambda functions were reported to environment %s\n", suite.envName),
},
{
name: "snapshot lambda works with --function-names and deprecated --function-version which is ignored",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s --function-version 317`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda works with --function-names and deprecated --function-version which is ignored",
cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s --function-version 317`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
golden: fmt.Sprintf("Flag --function-version has been deprecated, --function-version is no longer supported. It will be removed in a future release.\n1 lambda functions were reported to environment %s\n", suite.envName),
},
{
wantError: false,
name: "snapshot lambda without --function-names will report all lambdas in the AWS account",
cmd: fmt.Sprintf(`snapshot lambda %s %s`, suite.envName, suite.defaultKosliArguments),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda without --function-names will report all lambdas",
cmd: fmt.Sprintf(`snapshot lambda %s %s`, suite.envName, suite.defaultKosliArguments),
goldenRegex: fmt.Sprintf("[0-9]+ lambda functions were reported to environment %s\n", suite.envName),
},
{
wantError: true,
name: "snapshot lambda fails when both of --function-name and --function-names are set",
cmd: fmt.Sprintf(`snapshot lambda %s --function-name foo --function-names foo %s`, suite.envName, suite.defaultKosliArguments),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
golden: "Flag --function-name has been deprecated, use --function-names instead\nError: only one of --function-name, --function-names, --exclude is allowed\n",
golden: "Flag --function-name has been deprecated, use --function-names instead\nError: only one of --function-name, --function-names, --exclude is allowed\n",
},
{
wantError: true,
Expand All @@ -122,19 +127,13 @@ func (suite *SnapshotLambdaTestSuite) TestSnapshotLambdaCmd() {
golden: "Error: only one of --function-name, --function-names, --exclude-regex is allowed\n",
},
{
name: "snapshot lambda works if both --exclude and --exclude-regex are set",
cmd: fmt.Sprintf(`snapshot lambda %s %s --exclude %s --exclude-regex function1`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
additionalConfig: snapshotLambdaTestConfig{
requireAuthToBeSet: true,
},
name: "snapshot lambda works if both --exclude and --exclude-regex are set",
cmd: fmt.Sprintf(`snapshot lambda %s %s --exclude %s --exclude-regex function1`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName),
goldenRegex: fmt.Sprintf("[0-9]+ lambda functions were reported to environment %s\n", suite.envName),
},
}

for _, t := range tests {
if t.additionalConfig != nil && t.additionalConfig.(snapshotLambdaTestConfig).requireAuthToBeSet {
testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"})
}
runTestCmd(suite.T(), []cmdTestCase{t})
}
}
Expand Down
36 changes: 31 additions & 5 deletions internal/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,27 @@ func (staticCreds *AWSStaticCreds) NewLambdaClient() (*lambda.Client, error) {
return lambda.NewFromConfig(cfg), nil
}

// LambdaAPI is the interface for AWS Lambda operations used by this package.
// The real *lambda.Client satisfies this implicitly.
type LambdaAPI interface {
ListFunctions(ctx context.Context, params *lambda.ListFunctionsInput, optFns ...func(*lambda.Options)) (*lambda.ListFunctionsOutput, error)
GetFunctionConfiguration(ctx context.Context, params *lambda.GetFunctionConfigurationInput, optFns ...func(*lambda.Options)) (*lambda.GetFunctionConfigurationOutput, error)
}

// defaultNewLambdaClient creates a real Lambda client from credentials.
func defaultNewLambdaClient(creds *AWSStaticCreds) (LambdaAPI, error) {
return creds.NewLambdaClient()
}

// NewLambdaClientFunc is the factory used by GetLambdaPackageData to create a
// LambdaAPI client. Tests can replace this to inject a FakeLambdaClient.
var NewLambdaClientFunc = defaultNewLambdaClient
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.

Observation (non-blocking): Package-level mutable globals for test injection are pragmatic here but worth noting: if internal/aws tests ever run with t.Parallel() at the package level, concurrent writes to NewLambdaClientFunc would race. Currently safe because go test serialises tests within a package by default, and the TearDownTest in snapshotLambda_test.go properly resets it. Just something to keep in mind as more providers adopt this pattern.


// ResetLambdaClientFactory restores the default (real AWS) client factory.
func ResetLambdaClientFactory() {
NewLambdaClientFunc = defaultNewLambdaClient
}

// NewECSClient returns a new ECS API client
func (staticCreds *AWSStaticCreds) NewECSClient() (*ecs.Client, error) {
cfg, err := staticCreds.NewAWSConfigFromEnvOrFlags()
Expand All @@ -149,7 +170,7 @@ func (staticCreds *AWSStaticCreds) NewECSClient() (*ecs.Client, error) {
}

// getFilteredLambdaFuncs fetches a filtered set of lambda functions recursively (50 at a time) and returns a list of FunctionConfiguration
func getFilteredLambdaFuncs(client *lambda.Client, nextMarker *string, allFunctions *[]types.FunctionConfiguration,
func getFilteredLambdaFuncs(client LambdaAPI, nextMarker *string, allFunctions *[]types.FunctionConfiguration,
filter *filters.ResourceFilterOptions) (*[]types.FunctionConfiguration, error) {
params := &lambda.ListFunctionsInput{}
if nextMarker != nil {
Expand Down Expand Up @@ -187,11 +208,16 @@ func getFilteredLambdaFuncs(client *lambda.Client, nextMarker *string, allFuncti

// GetLambdaPackageData returns a digest and metadata of a Lambda function package
func (staticCreds *AWSStaticCreds) GetLambdaPackageData(filter *filters.ResourceFilterOptions) ([]*LambdaData, error) {
lambdaData := []*LambdaData{}
client, err := staticCreds.NewLambdaClient()
client, err := NewLambdaClientFunc(staticCreds)
if err != nil {
return lambdaData, err
return []*LambdaData{}, err
}
return getLambdaPackageDataFromClient(client, filter)
}

// getLambdaPackageDataFromClient fetches Lambda function data using the provided LambdaAPI client.
func getLambdaPackageDataFromClient(client LambdaAPI, filter *filters.ResourceFilterOptions) ([]*LambdaData, error) {
lambdaData := []*LambdaData{}

filteredFunctions, err := getFilteredLambdaFuncs(client, nil, &[]types.FunctionConfiguration{}, filter)
if err != nil {
Expand Down Expand Up @@ -247,7 +273,7 @@ func (staticCreds *AWSStaticCreds) GetLambdaPackageData(filter *filters.Resource
}

// getAndProcessOneLambdaFunc get a lambda function by its name and return a LambdaData object from it
func getAndProcessOneLambdaFunc(client *lambda.Client, functionName string) (*LambdaData, error) {
func getAndProcessOneLambdaFunc(client LambdaAPI, functionName string) (*LambdaData, error) {
params := &lambda.GetFunctionConfigurationInput{
FunctionName: aws.String(functionName),
}
Expand Down
Loading
Loading