From 08054c38e73e289f63a0a0db1c1f95f05d010fdd Mon Sep 17 00:00:00 2001 From: Axel Kingsley Date: Tue, 13 Oct 2026 11:19:24 -0500 Subject: [PATCH] Add CRE Storage Service docker compose component Thin testcontainers-go wrapper over cre-storage-service's local-stack/compose.yaml, in the style of billing_platform_service. Returns internal (role alias) and external (host port) endpoints for the user service, node service and artifact store. Co-Authored-By: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + .../crestorageservice/cre_storage_service.md | 44 ++++ .../cre_storage_service.go | 211 ++++++++++++++++++ .../cre_storage_service_test.go | 40 ++++ framework/components/dockercompose/go.mod | 2 +- 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 book/src/framework/components/crestorageservice/cre_storage_service.md create mode 100644 framework/components/dockercompose/cre_storage_service/cre_storage_service.go create mode 100644 framework/components/dockercompose/cre_storage_service/cre_storage_service_test.go diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 0dcaff6d0..d768af5fb 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -50,6 +50,7 @@ - [S3](framework/components/storage/s3.md) - [Chip Router](framework/components/chiprouter/chip_router.md) - [Chip Ingress Set](framework/components/chipingresset/chip_ingress.md) + - [CRE Storage Service](framework/components/crestorageservice/cre_storage_service.md) - [Troubleshooting](framework/components/troubleshooting.md) - [Mono Repository Tooling](./monorepo-tools.md) - [Libraries](./libraries.md) diff --git a/book/src/framework/components/crestorageservice/cre_storage_service.md b/book/src/framework/components/crestorageservice/cre_storage_service.md new file mode 100644 index 000000000..36d60d2f7 --- /dev/null +++ b/book/src/framework/components/crestorageservice/cre_storage_service.md @@ -0,0 +1,44 @@ +# CRE Storage Service + +CRE Storage Service is a composite component that runs [cre-storage-service](https://github.com/smartcontractkit/cre-storage-service) with local dependencies (Postgres, LocalStack S3, a mock account registry). It is a thin `testcontainers-go` wrapper over the compose file that cre-storage-service owns, `local-stack/compose.yaml`; that file's README describes its services, images and inputs. + +The stack reads the nodes it authorizes from a CapabilitiesRegistry, so it needs a chain with one deployed, reachable from the stack's Docker network. + +## Configuration + +```toml +[cre_storage_service] + # local path, or a URL readable without credentials + compose_file = "../cre-storage-service/local-stack/compose.yaml" + # existing Docker network to join, defaults to "ctf" + # network = "ctf" + registry_rpc_url = "http://anvil-1337:8545" + registry_chain_selector = 3379446385462418246 + registry_address = "0x5FbDB2315678afecb367f032d93F642f64180aa3" + don_family = "zone-a" +``` + +Images default to locally built `:local` tags; `CRE_STORAGE_IMAGE`, `CRE_STORAGE_MIGRATION_IMAGE` and `MOCK_ACCOUNT_REGISTRY_IMAGE` env vars override them, and `LOG_LEVEL` sets the services' log level. + +## Endpoints + +On the Docker network the services answer to role aliases; `Output` also gives host URLs on free ports. gRPC is served without TLS. + +| Output | Role (alias) | Port | Purpose | +| --- | --- | --- | --- | +| `UserService` | `cre-deploy-api` | 50051 | workflow/artifact API for HMAC principals | +| `NodeService` | `cre-workflow-source`, `cre-artifact-api` | 50052 | node API (CSA key auth): workflow metadata, artifact URLs | +| `ArtifactStore` | `cre-artifact-store` | 4566 | S3; artifact URLs use the internal alias | + +## Usage + +```go +import storage "github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose/cre_storage_service" + +out, err := storage.New(in.CREStorageService) +require.NoError(t, err) +// out.UserService.ExternalURL, out.NodeService.InternalURL, ... + +// removes every stack started by New, with its volumes +require.NoError(t, storage.Stop()) +``` diff --git a/framework/components/dockercompose/cre_storage_service/cre_storage_service.go b/framework/components/dockercompose/cre_storage_service/cre_storage_service.go new file mode 100644 index 000000000..c262a8a70 --- /dev/null +++ b/framework/components/dockercompose/cre_storage_service/cre_storage_service.go @@ -0,0 +1,211 @@ +package cre_storage_service + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/testcontainers/testcontainers-go/modules/compose" + + "github.com/smartcontractkit/freeport" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose/utils" +) + +// Roles the stack's services answer to on the Docker network, as defined by the compose file in cre-storage-service. +const ( + DeployAPIRole = "cre-deploy-api" + WorkflowSourceRole = "cre-workflow-source" + ArtifactAPIRole = "cre-artifact-api" + ArtifactStoreRole = "cre-artifact-store" + + UserServicePort = "50051" + NodeServicePort = "50052" + ArtifactStorePort = "4566" +) + +const ( + DEFAULT_STACK_NAME = "cre-storage" + + userServiceName = "storage-user-service" + nodeServiceName = "storage-node-service" + artifactStoreName = "storage-s3" +) + +type Input struct { + // ComposeFile is local-stack/compose.yaml from cre-storage-service: a local path, or a URL readable without credentials. + ComposeFile string `toml:"compose_file"` + // Network the stack joins; it must exist. Defaults to framework.DefaultNetworkName. + Network string `toml:"network"` + // RegistryRPCURL, RegistryChainSelector and RegistryAddress locate the CapabilitiesRegistry the stack reads tenants' + // nodes from; the RPC URL must be reachable from Network. + RegistryRPCURL string `toml:"registry_rpc_url"` + RegistryChainSelector uint64 `toml:"registry_chain_selector"` + RegistryAddress string `toml:"registry_address"` + DonFamily string `toml:"don_family"` + Output *Output `toml:"output"` + UseCache bool `toml:"use_cache"` +} + +type Output struct { + // UserService serves cre-deploy-api (gRPC, HMAC auth) + UserService *Endpoint `toml:"user_service"` + // NodeService serves cre-workflow-source and cre-artifact-api (gRPC, CSA key auth) + NodeService *Endpoint `toml:"node_service"` + // ArtifactStore serves cre-artifact-store (S3); artifact URLs use the internal one + ArtifactStore *Endpoint `toml:"artifact_store"` +} + +// Endpoint is reachable at InternalURL from the stack's network and at ExternalURL from the host. gRPC endpoints are +// host:port, without TLS. +type Endpoint struct { + InternalURL string `toml:"internal_url"` + ExternalURL string `toml:"external_url"` +} + +// New starts cre-storage-service's local stack from its compose file. Image overrides (CRE_STORAGE_IMAGE, +// CRE_STORAGE_MIGRATION_IMAGE, MOCK_ACCOUNT_REGISTRY_IMAGE) and LOG_LEVEL are taken from the host process env vars. +func New(in *Input) (*Output, error) { + return NewWithContext(context.Background(), in) +} + +func NewWithContext(ctx context.Context, in *Input) (*Output, error) { + if in == nil { + return nil, errors.New("input is nil") + } + if in.UseCache && in.Output != nil { + return in.Output, nil + } + if err := validate(in); err != nil { + return nil, err + } + + composeFile, err := utils.ComposeFilePath(in.ComposeFile, DEFAULT_STACK_NAME) + if err != nil { + return nil, errors.Wrap(err, "failed to get compose file path") + } + + identifier := framework.DefaultTCName(DEFAULT_STACK_NAME) + stack, err := compose.NewDockerComposeWith( + compose.WithStackFiles(composeFile), + compose.StackIdentifier(identifier), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create compose stack for CRE Storage Service") + } + + hostPorts, err := freeport.Take(3) + if err != nil { + return nil, errors.Wrap(err, "failed to get free ports for CRE Storage Service") + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + framework.L.Info().Str("Compose file", composeFile).Msgf("Starting CRE Storage Service stack %s", identifier) + if err := stack.WithEnv(env(in, os.Environ(), hostPorts)).Up(ctx, compose.Wait(true)); err != nil { + // without ctx's deadline, which may be what failed Up + downCtx, downCancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) + defer downCancel() + if downErr := stack.Down(downCtx, compose.RemoveOrphans(true), compose.RemoveVolumes(true)); downErr != nil { + framework.L.Warn().Err(downErr).Msgf("failed to remove CRE Storage Service stack %s", identifier) + } + return nil, errors.Wrap(err, "failed to start stack for CRE Storage Service") + } + + external := func(service, port string) (string, error) { + c, err := stack.ServiceContainer(ctx, service) + if err != nil { + return "", errors.Wrapf(err, "failed to get %s container", service) + } + host, err := utils.GetContainerHost(ctx, c) + if err != nil { + return "", errors.Wrapf(err, "failed to get host for %s", service) + } + mapped, err := utils.FindMappedPort(ctx, 20*time.Second, c, port+"/tcp") + if err != nil { + return "", errors.Wrapf(err, "failed to get mapped port for %s", service) + } + return fmt.Sprintf("%s:%s", host, mapped.Port()), nil + } + + userExternal, err := external(userServiceName, UserServicePort) + if err != nil { + return nil, err + } + nodeExternal, err := external(nodeServiceName, NodeServicePort) + if err != nil { + return nil, err + } + storeExternal, err := external(artifactStoreName, ArtifactStorePort) + if err != nil { + return nil, err + } + + in.Output = &Output{ + UserService: &Endpoint{InternalURL: DeployAPIRole + ":" + UserServicePort, ExternalURL: userExternal}, + NodeService: &Endpoint{InternalURL: WorkflowSourceRole + ":" + NodeServicePort, ExternalURL: nodeExternal}, + ArtifactStore: &Endpoint{InternalURL: "http://" + ArtifactStoreRole + ":" + ArtifactStorePort, ExternalURL: "http://" + storeExternal}, + } + framework.L.Info().Msg("CRE Storage Service stack started") + return in.Output, nil +} + +// Stop removes every CRE Storage Service stack started by New, with its volumes. +func Stop() error { + return framework.RemoveTestStack(DEFAULT_STACK_NAME) +} + +func validate(in *Input) error { + var missing []string + if in.ComposeFile == "" { + missing = append(missing, "compose_file") + } + if in.RegistryRPCURL == "" { + missing = append(missing, "registry_rpc_url") + } + if in.RegistryChainSelector == 0 { + missing = append(missing, "registry_chain_selector") + } + if in.RegistryAddress == "" { + missing = append(missing, "registry_address") + } + if in.DonFamily == "" { + missing = append(missing, "don_family") + } + if len(missing) > 0 { + return fmt.Errorf("CRE Storage Service input is missing %s", strings.Join(missing, ", ")) + } + return nil +} + +// env passes the host process env vars through, so they can override images and log level, and sets the inputs and +// host ports over them. +func env(in *Input, hostEnv []string, hostPorts []int) map[string]string { + vars := make(map[string]string) + for _, kv := range hostEnv { + if k, v, ok := strings.Cut(kv, "="); ok { + vars[k] = v + } + } + + network := in.Network + if network == "" { + network = framework.DefaultNetworkName + } + vars["CRE_NETWORK"] = network + vars["CRE_NETWORK_EXTERNAL"] = "true" + vars["CRE_LOCAL_REGISTRY_RPC_URL"] = in.RegistryRPCURL + vars["CRE_LOCAL_REGISTRY_CHAIN_SELECTOR"] = strconv.FormatUint(in.RegistryChainSelector, 10) + vars["CRE_LOCAL_REGISTRY_ADDRESS"] = in.RegistryAddress + vars["CRE_LOCAL_DON_FAMILY"] = in.DonFamily + vars["USER_SERVICE_HOST_PORT"] = strconv.Itoa(hostPorts[0]) + vars["NODE_SERVICE_HOST_PORT"] = strconv.Itoa(hostPorts[1]) + vars["S3_HOST_PORT"] = strconv.Itoa(hostPorts[2]) + return vars +} diff --git a/framework/components/dockercompose/cre_storage_service/cre_storage_service_test.go b/framework/components/dockercompose/cre_storage_service/cre_storage_service_test.go new file mode 100644 index 000000000..126fbbc4f --- /dev/null +++ b/framework/components/dockercompose/cre_storage_service/cre_storage_service_test.go @@ -0,0 +1,40 @@ +package cre_storage_service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEnv(t *testing.T) { + in := &Input{ + RegistryRPCURL: "http://anvil-1337:8545", + RegistryChainSelector: 3379446385462418246, + RegistryAddress: "0xabc", + DonFamily: "test-don-family", + } + vars := env(in, []string{"CRE_STORAGE_IMAGE=custom:tag", "CRE_LOCAL_DON_FAMILY=from-host", "MALFORMED"}, []int{1, 2, 3}) + + require.Equal(t, "custom:tag", vars["CRE_STORAGE_IMAGE"]) + require.Equal(t, "test-don-family", vars["CRE_LOCAL_DON_FAMILY"]) + require.Equal(t, "ctf", vars["CRE_NETWORK"]) + require.Equal(t, "true", vars["CRE_NETWORK_EXTERNAL"]) + require.Equal(t, "http://anvil-1337:8545", vars["CRE_LOCAL_REGISTRY_RPC_URL"]) + require.Equal(t, "3379446385462418246", vars["CRE_LOCAL_REGISTRY_CHAIN_SELECTOR"]) + require.Equal(t, "0xabc", vars["CRE_LOCAL_REGISTRY_ADDRESS"]) + require.Equal(t, "1", vars["USER_SERVICE_HOST_PORT"]) + require.Equal(t, "2", vars["NODE_SERVICE_HOST_PORT"]) + require.Equal(t, "3", vars["S3_HOST_PORT"]) + require.NotContains(t, vars, "MALFORMED") + + in.Network = "custom" + require.Equal(t, "custom", env(in, nil, []int{1, 2, 3})["CRE_NETWORK"]) +} + +func TestNewRejectsIncompleteInput(t *testing.T) { + _, err := New(&Input{ComposeFile: "compose.yaml", RegistryChainSelector: 1}) + require.ErrorContains(t, err, "missing registry_rpc_url, registry_address, don_family") + + _, err = New(nil) + require.ErrorContains(t, err, "input is nil") +} diff --git a/framework/components/dockercompose/go.mod b/framework/components/dockercompose/go.mod index b0cbc76af..2f486c89d 100644 --- a/framework/components/dockercompose/go.mod +++ b/framework/components/dockercompose/go.mod @@ -17,6 +17,7 @@ require ( github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20251211140724-319861e514c4 github.com/smartcontractkit/chainlink-testing-framework/framework v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e + github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/compose v0.42.0 golang.org/x/oauth2 v0.36.0 @@ -134,7 +135,6 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.12.1 // indirect github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect