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
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
```
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 1 addition & 1 deletion framework/components/dockercompose/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading