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
79 changes: 78 additions & 1 deletion core/capabilities/remote/executable/request/client_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type ClientRequest struct {

requestTimeout time.Duration

responsePolicy responsePolicy

respSent bool
mux sync.Mutex
wg *sync.WaitGroup
Expand Down Expand Up @@ -194,6 +196,7 @@ func newClientRequest(ctx context.Context, lggr logger.Logger, requestID string,
meteringResponses: make(map[[32]byte][]commoncap.MeteringNodeDetail),
errorCount: make(map[string]int),
responseReceived: responseReceived,
responsePolicy: newResponsePolicy(remoteCapabilityInfo, capMethodName),
responseCh: make(chan clientResponse, 1),
wg: &wg,
lggr: lggr,
Expand Down Expand Up @@ -263,6 +266,16 @@ func (c *ClientRequest) Cancel(err error) {
c.mux.Lock()
defer c.mux.Unlock()
if !c.respSent {
if c.responsePolicy != nil {
payload, ok, buildErr := c.responsePolicy.BuildDeterministicResponse(true)
if buildErr != nil {
c.lggr.Warnw("failed to build deterministic policy response", "error", buildErr)
}
if ok {
c.sendResponse(clientResponse{Result: payload})
return
}
}
c.sendResponse(clientResponse{Err: err})
}
}
Expand Down Expand Up @@ -330,14 +343,29 @@ func (c *ClientRequest) OnMessage(_ context.Context, msg *types.MessageBody) err
lggr.Warnw("received multiple unique responses for the same request", "count for responseID", len(c.responseIDCount))
}

if c.responseIDCount[responseID] == c.requiredIdenticalResponses {
if c.responseIDCount[responseID] == c.requiredIdenticalResponses && !c.shouldDeferIdenticalResponse(msg.Payload) {
payload, err := c.encodePayloadWithMetadata(msg, commoncap.ResponseMetadata{Metering: nodeReports})
if err != nil {
return fmt.Errorf("failed to encode payload with metadata: %w", err)
}

c.sendResponse(clientResponse{Result: payload})
}

if !c.respSent {
if c.responsePolicy != nil {
c.responsePolicy.ObserveOKResponse(msg, metadata)
payload, ok, buildErr := c.responsePolicy.BuildDeterministicResponse(c.allResponsesReceived())
if buildErr != nil {
return fmt.Errorf("failed to build deterministic policy response: %w", buildErr)
}
if ok {
c.sendResponse(clientResponse{Result: payload})
} else if err := c.maybeFinalizeResponsePolicyAfterAllResponses(); err != nil {
return err
}
}
}
} else {
c.lggr.Debugw("received error from peer", "error", msg.Error, "errorMsg", msg.ErrorMsg, "peer", sender)
c.errorCount[msg.ErrorMsg]++
Expand All @@ -347,6 +375,13 @@ func (c *ClientRequest) OnMessage(_ context.Context, msg *types.MessageBody) err
c.lggr.Warn("received multiple different errors for the same request, number of different errors received: %d", len(c.errorCount))
}

if c.responsePolicy != nil && c.responsePolicy.ShouldDeferErrorResponses() {
if err := c.maybeFinalizeResponsePolicyAfterAllResponses(); err != nil {
return err
}
return nil
}

if c.errorCount[msg.ErrorMsg] == c.requiredIdenticalResponses {
c.sendResponse(clientResponse{Err: fmt.Errorf("%s : %s", msg.Error, msg.ErrorMsg)})
} else if c.totalErrorCount == c.remoteNodeCount-c.requiredIdenticalResponses+1 {
Expand Down Expand Up @@ -396,3 +431,45 @@ func (c *ClientRequest) encodePayloadWithMetadata(msg *types.MessageBody, metada

return pb.MarshalCapabilityResponse(resp)
}

func (c *ClientRequest) shouldDeferIdenticalResponse(payload []byte) bool {
if c.responsePolicy == nil {
return false
}
return c.responsePolicy.ShouldDeferIdenticalResponse(payload)
}

func (c *ClientRequest) allResponsesReceived() bool {
if len(c.responseReceived) == 0 {
return false
}

for _, received := range c.responseReceived {
if !received {
return false
}
}

return true
}

func (c *ClientRequest) maybeFinalizeResponsePolicyAfterAllResponses() error {
if c.responsePolicy == nil || c.respSent {
return nil
}

response, ok, err := c.responsePolicy.FinalizeAfterAllResponses(responsePolicyState{
AllResponsesReceived: c.allResponsesReceived(),
ResponseVariants: len(c.responseIDCount),
TotalErrorCount: c.totalErrorCount,
})
if err != nil {
return err
}
if !ok || response == nil {
return nil
}

c.sendResponse(*response)
return nil
}
41 changes: 41 additions & 0 deletions core/capabilities/remote/executable/request/response_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package request

import (
commoncap "github.com/smartcontractkit/chainlink-common/pkg/capabilities"

"github.com/smartcontractkit/chainlink/v2/core/capabilities/remote/types"
)

// responsePolicy allows request finalization behavior to be extended for specific
// capabilities without coupling generic request handling to chain-specific logic.
type responsePolicy interface {
ShouldDeferIdenticalResponse(payload []byte) bool
ObserveOKResponse(msg *types.MessageBody, metadata commoncap.ResponseMetadata)
BuildDeterministicResponse(allowNoQuorum bool) ([]byte, bool, error)
ShouldDeferErrorResponses() bool
FinalizeAfterAllResponses(state responsePolicyState) (*clientResponse, bool, error)
}

type responsePolicyState struct {
AllResponsesReceived bool
ResponseVariants int
TotalErrorCount int
}

type responsePolicyBuilder func(remoteCapabilityInfo commoncap.CapabilityInfo, capabilityMethod string) responsePolicy

var responsePolicyBuilders []responsePolicyBuilder

func registerResponsePolicyBuilder(builder responsePolicyBuilder) {
responsePolicyBuilders = append(responsePolicyBuilders, builder)
}

func newResponsePolicy(remoteCapabilityInfo commoncap.CapabilityInfo, capabilityMethod string) responsePolicy {
for _, builder := range responsePolicyBuilders {
policy := builder(remoteCapabilityInfo, capabilityMethod)
if policy != nil {
return policy
}
}
return nil
}
199 changes: 199 additions & 0 deletions core/chainlink-local-deps.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
##
# Build image: Chainlink binary with plugins, using local chainlink-common (go.mod replace).
# Use when chainlink/go.mod has: replace github.com/smartcontractkit/chainlink-common => ../chainlink-common
#
# Build context MUST be the parent directory containing both chainlink/ and chainlink-common/
# (e.g. docker_ctx = ".." when running from chainlink repo). When CL_USE_LOCAL_CAPABILITIES=true,
# context must also contain capabilities/ (sibling of chainlink/) so private plugins are built from
# local source instead of GitHub (no GIT_AUTH_TOKEN needed).
##
FROM golang:1.25.7-bookworm AS buildgo
RUN go version
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*

WORKDIR /chainlink

# Satisfy go.mod replace ../chainlink-common before go mod download
COPY chainlink-common /chainlink-common

# When CL_USE_LOCAL_CAPABILITIES=true, context must include capabilities/ (script ensures it exists).
COPY capabilities /capabilities

COPY chainlink/GNUmakefile chainlink/package.json ./
COPY chainlink/tools/bin/ldflags ./tools/bin/

# ARG early so we can apply replace before first go mod download (avoids fetching capabilities from network).
ARG CL_USE_LOCAL_CAPABILITIES=false
ARG CL_USE_LOCAL_APTOS=false

ADD chainlink/go.mod chainlink/go.sum ./
# With local capabilities, add replace before any download so the module cache never hits the network for capabilities.
RUN if [ "${CL_USE_LOCAL_CAPABILITIES}" = "true" ] && [ -f /capabilities/go.mod ]; then \
go mod edit -replace=github.com/smartcontractkit/capabilities=../capabilities; \
fi
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY chainlink/ .

# Ensure go.mod is consistent for plugin builds (avoids "go: updates to go.mod needed; go mod tidy" during install-plugins-local).
RUN --mount=type=cache,target=/go/pkg/mod \
go mod tidy

# When using local capabilities: re-apply replace (COPY chainlink/ overwrote go.mod) and create go.work so capability
# submodules (e.g. libs) resolve when building plugins from local paths like /capabilities/cron.
# Optional: CL_USE_LOCAL_APTOS=true and context containing chainlink-aptos/ use local repo for
# capabilities/chain_capabilities/aptos (write-target work).
RUN if [ "${CL_USE_LOCAL_CAPABILITIES}" = "true" ] && [ -f /capabilities/go.mod ]; then \
go mod edit -replace=github.com/smartcontractkit/capabilities=../capabilities; \
printf '%s\n' \
'go 1.25.5' '' 'use (' \
'./chainlink' \
'./capabilities/libs' \
'./capabilities/cron' \
'./capabilities/readcontract' \
'./capabilities/consensus' \
'./capabilities/http_action' \
'./capabilities/http_trigger' \
'./capabilities/chain_capabilities/evm' \
'./capabilities/chain_capabilities/solana' \
'./capabilities/chain_capabilities/aptos' \
'./capabilities/mock' \
'./capabilities/kvstore' \
'./capabilities/workflowevent' \
')' '' \
'replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014124537-af6b1684fe15' \
> /go.work; \
fi
# When CL_USE_LOCAL_APTOS=true, copy chainlink-aptos from build context (excluding build artifacts
# to keep step fast) and add to go.work so capabilities/chain_capabilities/aptos builds against local
# chainlink-aptos (no conditional COPY).
RUN --mount=type=bind,source=.,target=/ctx \
if [ "${CL_USE_LOCAL_APTOS}" = "true" ] && [ -d /ctx/chainlink-aptos ] && [ -f /ctx/chainlink-aptos/go.mod ]; then \
apt-get update -qq && apt-get install -y -qq rsync && rm -rf /var/lib/apt/lists/* && \
mkdir -p /chainlink-aptos && \
rsync -a \
--exclude='build/' --exclude='target/' \
--exclude='*.hex' --exclude='*.zip' \
/ctx/chainlink-aptos/ /chainlink-aptos/ && \
sed -i '/^)$/i\ ./chainlink-aptos' /go.work; \
fi

# Install Delve for debugging with cache mounts
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/go-delve/delve/cmd/dlv@v1.24.2

# Flag to control installation of private plugins (default: true).
ARG CL_INSTALL_PRIVATE_PLUGINS=true
# Flag to control installation of testing plugins (default: false).
ARG CL_INSTALL_TESTING_PLUGINS=false
# Env vars needed for chainlink build
ARG COMMIT_SHA
ARG VERSION_TAG
# Flag to control whether this is a prod build (default: true).
ARG CL_IS_PROD_BUILD=true

ENV CL_LOOPINSTALL_OUTPUT_DIR=/tmp/loopinstall-output \
GIT_CONFIG_GLOBAL=/tmp/gitconfig-github-token
# Secret must be provided by the build (use a dummy empty file when CL_USE_LOCAL_CAPABILITIES=true and no token).
# When CL_USE_LOCAL_CAPABILITIES=true, set GOWORK only for install-plugins-local (and -private) so capabilities
# resolve from the workspace; clear cached capabilities. install-plugins-public must run without GOWORK so
# plugins built from GOMODCACHE are not required to be in go.work.
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -e && \
trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT && \
./plugins/scripts/setup_git_auth.sh && \
mkdir -p /gobins && mkdir -p "${CL_LOOPINSTALL_OUTPUT_DIR}" && \
if [ "${CL_USE_LOCAL_CAPABILITIES}" = "true" ] && [ -f /capabilities/go.mod ]; then \
rm -rf /go/pkg/mod/github.com/smartcontractkit/capabilities* /go/pkg/mod/cache/vcs/* 2>/dev/null || true; \
export GOWORK=/go.work; \
fi && \
GOBIN=/gobins CL_LOOPINSTALL_OUTPUT_DIR=${CL_LOOPINSTALL_OUTPUT_DIR} make install-plugins-local && \
GOWORK=off GOBIN=/gobins CL_LOOPINSTALL_OUTPUT_DIR=${CL_LOOPINSTALL_OUTPUT_DIR} make install-plugins-public && \
if [ "${CL_INSTALL_PRIVATE_PLUGINS}" = "true" ]; then \
if [ "${CL_USE_LOCAL_CAPABILITIES}" = "true" ] && [ -f /capabilities/go.mod ]; then \
cp plugins/plugins.private.local.yaml plugins/plugins.private.yaml; \
export GOWORK=/go.work; \
fi; \
GOBIN=/gobins CL_LOOPINSTALL_OUTPUT_DIR=${CL_LOOPINSTALL_OUTPUT_DIR} make install-plugins-private; \
fi && \
if [ "${CL_INSTALL_TESTING_PLUGINS}" = "true" ]; then \
GOBIN=/gobins CL_LOOPINSTALL_OUTPUT_DIR=${CL_LOOPINSTALL_OUTPUT_DIR} make install-plugins-testing; \
fi

# Copy any shared libraries.
RUN --mount=type=cache,target=/go/pkg/mod \
mkdir -p /tmp/lib && \
./plugins/scripts/copy_loopinstall_libs.sh \
"$CL_LOOPINSTALL_OUTPUT_DIR" \
/tmp/lib

# Build chainlink.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
if [ "$CL_IS_PROD_BUILD" = "false" ]; then \
GOBIN=/gobins make install-chainlink-dev; \
else \
GOBIN=/gobins make install-chainlink; \
fi

##
# Final Image
##
FROM ubuntu:24.04

ARG CHAINLINK_USER=root
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y ca-certificates gnupg lsb-release curl && rm -rf /var/lib/apt/lists/*

# Install Postgres for CLI tools, needed specifically for DB backups
RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
&& echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" |tee /etc/apt/sources.list.d/pgdg.list \
&& apt-get update && apt-get install -y postgresql-client-16 \
&& rm -rf /var/lib/apt/lists/*

RUN if [ ${CHAINLINK_USER} != root ]; then useradd --uid 14933 --create-home ${CHAINLINK_USER}; fi
USER ${CHAINLINK_USER}

# Set plugin environment variable configuration.
ENV CL_SOLANA_CMD=chainlink-solana

ARG CL_MEDIAN_CMD
ENV CL_MEDIAN_CMD=${CL_MEDIAN_CMD}
ARG CL_EVM_CMD
ENV CL_EVM_CMD=${CL_EVM_CMD}

# CCIP specific (path relative to context = parent; chainlink/ccip/config)
COPY chainlink/ccip/config /ccip-config
ARG CL_CHAIN_DEFAULTS
ENV CL_CHAIN_DEFAULTS=${CL_CHAIN_DEFAULTS}

# Copy the binaries from the build stage (plugins + chainlink).
COPY --from=buildgo /gobins/ /usr/local/bin/
# Copy shared libraries from the build stage.
COPY --from=buildgo /tmp/lib /usr/lib/
# Copy dlv (Delve debugger) from the build stage.
COPY --from=buildgo /go/bin/dlv /usr/local/bin/


WORKDIR /home/${CHAINLINK_USER}

# So capability_defaults.toml binary_path "./binaries/<name>" resolve when using this image without host copy.
RUN mkdir -p binaries && \
ln -sf /usr/local/bin/chainlink-aptos binaries/aptos 2>/dev/null || true && \
ln -sf /usr/local/bin/chainlink-cron binaries/cron 2>/dev/null || true

# Explicitly set the cache dir. Needed so both root and non-root user has an explicit location.
ENV XDG_CACHE_HOME=/home/${CHAINLINK_USER}/.cache
RUN mkdir -p ${XDG_CACHE_HOME}

# Set up env and dir for go coverage profiling https://go.dev/doc/build-cover#FAQ
ENV GOCOVERDIR=/var/tmp/go-coverage
RUN mkdir -p /var/tmp/go-coverage

EXPOSE 6688
ENTRYPOINT ["chainlink"]
HEALTHCHECK CMD curl -f http://localhost:6688/health || exit 1
CMD ["local", "node"]
3 changes: 3 additions & 0 deletions core/chainlink.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ ARG COMMIT_SHA
ARG VERSION_TAG
# Flag to control whether this is a prod build (default: true)
ARG CL_IS_PROD_BUILD=true
# Cache-bust marker for private Aptos capability plugin install layer.
ARG CL_CAPABILITIES_APTOS_REF=unknown

ENV CL_LOOPINSTALL_OUTPUT_DIR=/tmp/loopinstall-output \
GIT_CONFIG_GLOBAL=/tmp/gitconfig-github-token
Expand All @@ -37,6 +39,7 @@ RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
--mount=type=cache,target=/root/.cache/go-build \
set -e && \
trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT && \
echo "Aptos capability ref: ${CL_CAPABILITIES_APTOS_REF}" && \
./plugins/scripts/setup_git_auth.sh && \
mkdir -p /gobins && mkdir -p "${CL_LOOPINSTALL_OUTPUT_DIR}" && \
GOBIN=/gobins CL_LOOPINSTALL_OUTPUT_DIR=${CL_LOOPINSTALL_OUTPUT_DIR} make install-plugins-local install-plugins-public && \
Expand Down
Loading