Skip to content

Commit 1b79b66

Browse files
committed
Support large PAR executor messages
1 parent 292dd52 commit 1b79b66

4 files changed

Lines changed: 258 additions & 2 deletions

File tree

pkg/privateactionrunner/executor/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ dd_agent_go_test(
4949
"@com_github_stretchr_testify//assert",
5050
"@com_github_stretchr_testify//require",
5151
"@org_golang_google_grpc//:grpc",
52+
"@org_golang_google_grpc//codes",
5253
"@org_golang_google_grpc//credentials",
5354
"@org_golang_google_grpc//credentials/insecure",
55+
"@org_golang_google_grpc//status",
56+
"@org_golang_google_protobuf//proto",
5457
],
5558
)

pkg/privateactionrunner/executor/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ import (
3030
pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/privateactionrunner/executor"
3131
)
3232

33+
// maxMessageSize is the control<->executor protocol limit in bytes. Action
34+
// inputs and outputs can approach 15 MiB, so 20 MiB leaves protobuf headroom
35+
// while still bounding memory use. Keep this in sync with MAX_MESSAGE_SIZE in
36+
// par-control's executor client.
37+
const maxMessageSize = 20 * 1024 * 1024
38+
3339
type actionExecutor interface {
3440
PrepareTask(ctx context.Context, task *types.Task) (*runners.PreparedWorkflowTask, *types.Task, error)
3541
RunPrepared(ctx context.Context, prepared *runners.PreparedWorkflowTask) (interface{}, error)
@@ -204,6 +210,12 @@ const idleCheckDivisor = 10
204210
// Serve serves the Executor on lis until ctx is cancelled, then stops gracefully
205211
// bounded by the drain timeout. Pass grpcOpts to secure the socket.
206212
func Serve(ctx context.Context, lis net.Listener, srv *Server, opts ServeOptions, grpcOpts ...grpc.ServerOption) error {
213+
// Apply the protocol limits after caller-provided options so every executor
214+
// endpoint accepts the same bounded action payload sizes.
215+
grpcOpts = append(grpcOpts,
216+
grpc.MaxRecvMsgSize(maxMessageSize),
217+
grpc.MaxSendMsgSize(maxMessageSize),
218+
)
207219
grpcServer := grpc.NewServer(grpcOpts...)
208220
pb.RegisterExecutorServer(grpcServer, srv)
209221

pkg/privateactionrunner/executor/server_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@ import (
1818
"io"
1919
"math/big"
2020
"net"
21+
"strings"
2122
"testing"
2223
"time"
2324

2425
"github.com/benbjohnson/clock"
2526
"github.com/stretchr/testify/assert"
2627
"github.com/stretchr/testify/require"
2728
"google.golang.org/grpc"
29+
"google.golang.org/grpc/codes"
2830
"google.golang.org/grpc/credentials"
2931
"google.golang.org/grpc/credentials/insecure"
32+
"google.golang.org/grpc/status"
33+
"google.golang.org/protobuf/proto"
3034

3135
"github.com/DataDog/datadog-agent/pkg/privateactionrunner/runners"
3236
taskverifier "github.com/DataDog/datadog-agent/pkg/privateactionrunner/task-verifier"
@@ -95,6 +99,10 @@ func startTestServer(t *testing.T, srv *Server) pb.ExecutorClient {
9599
conn, err := grpc.NewClient(
96100
"passthrough:///"+socketPath,
97101
grpc.WithTransportCredentials(insecure.NewCredentials()),
102+
grpc.WithDefaultCallOptions(
103+
grpc.MaxCallRecvMsgSize(maxMessageSize*2),
104+
grpc.MaxCallSendMsgSize(maxMessageSize*2),
105+
),
98106
grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
99107
return Dial(dialCtx, socketPath, 2*time.Second)
100108
}),
@@ -132,6 +140,95 @@ func runAction(t *testing.T, client pb.ExecutorClient, taskBytes []byte) *pb.Act
132140
return result
133141
}
134142

143+
func taskWithEncodedSize(t *testing.T, target int) []byte {
144+
t.Helper()
145+
const prefix = `{"data":{"id":"task","attributes":{"padding":"`
146+
const suffix = `"}}}`
147+
padding := target - len(prefix) - len(suffix)
148+
require.Positive(t, padding)
149+
for {
150+
raw := []byte(prefix + strings.Repeat("a", padding) + suffix)
151+
size := proto.Size(&pb.RunActionRequest{Task: raw})
152+
if size == target {
153+
return raw
154+
}
155+
padding += target - size
156+
require.Positive(t, padding)
157+
}
158+
}
159+
160+
func outputWithEncodedSize(t *testing.T, target int) string {
161+
t.Helper()
162+
padding := target
163+
for {
164+
output := strings.Repeat("a", padding)
165+
outputJSON, err := json.Marshal(output)
166+
require.NoError(t, err)
167+
size := proto.Size(&pb.RunActionResponse{
168+
Event: &pb.RunActionResponse_Result{
169+
Result: &pb.ActionResult{
170+
Outcome: &pb.ActionResult_Output{Output: outputJSON},
171+
},
172+
},
173+
})
174+
if size == target {
175+
return output
176+
}
177+
padding += target - size
178+
require.Positive(t, padding)
179+
}
180+
}
181+
182+
func receiveRunActionError(client pb.ExecutorClient, task []byte) error {
183+
stream, err := client.RunAction(context.Background(), &pb.RunActionRequest{Task: task})
184+
if err != nil {
185+
return err
186+
}
187+
_, err = stream.Recv()
188+
return err
189+
}
190+
191+
func TestServeEnforcesRequestMessageLimit(t *testing.T) {
192+
fake := &fakeExecutor{
193+
prepared: &runners.PreparedWorkflowTask{Task: &types.Task{}},
194+
output: map[string]interface{}{},
195+
}
196+
srv := NewServer(fake, "test-version", nil)
197+
srv.SetReady(true)
198+
client := startTestServer(t, srv)
199+
200+
below := taskWithEncodedSize(t, maxMessageSize-1)
201+
result := runAction(t, client, below)
202+
require.NotNil(t, result.GetOutput())
203+
204+
above := taskWithEncodedSize(t, maxMessageSize+1)
205+
err := receiveRunActionError(client, above)
206+
require.Error(t, err)
207+
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
208+
}
209+
210+
func TestServeEnforcesResponseMessageLimit(t *testing.T) {
211+
below := outputWithEncodedSize(t, maxMessageSize-1)
212+
fake := &fakeExecutor{
213+
prepared: &runners.PreparedWorkflowTask{Task: &types.Task{}},
214+
output: below,
215+
}
216+
srv := NewServer(fake, "test-version", nil)
217+
srv.SetReady(true)
218+
client := startTestServer(t, srv)
219+
220+
result := runAction(t, client, []byte(`{"data":{"id":"task-1"}}`))
221+
belowJSON, err := json.Marshal(below)
222+
require.NoError(t, err)
223+
assert.Equal(t, belowJSON, result.GetOutput())
224+
225+
above := outputWithEncodedSize(t, maxMessageSize+1)
226+
fake.output = above
227+
err = receiveRunActionError(client, []byte(`{"data":{"id":"task-2"}}`))
228+
require.Error(t, err)
229+
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
230+
}
231+
135232
func TestServeSyncKeysSeedsAndReturnsCurrentSnapshot(t *testing.T) {
136233
keysManager := &fakeKeysManager{snapshot: []taskverifier.SigningKey{{
137234
ID: "fresh",

pkg/privateactionrunner/par-control/src/executor.rs

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@ use crate::proto::executor as pb;
1212
use crate::proto::executor::executor_client::ExecutorClient;
1313
use crate::transport;
1414
use anyhow::{Context, Result, bail};
15+
use prost::Message;
1516
use std::path::Path;
1617
use tonic::transport::Channel;
1718

19+
/// Control<->executor protocol limit in bytes. Action inputs and outputs can
20+
/// approach 15 MiB, so 20 MiB leaves protobuf headroom while still bounding
21+
/// memory use. Keep this in sync with `maxMessageSize` in the Go executor.
22+
const MAX_MESSAGE_SIZE: usize = 20 * 1024 * 1024;
23+
1824
/// Executor health snapshot used to gate dispatch.
1925
#[derive(Debug, Clone)]
2026
pub struct Health {
@@ -75,7 +81,9 @@ impl ExecutorDispatcher {
7581
None => transport::connect_lazy(socket),
7682
};
7783
ExecutorDispatcher {
78-
client: ExecutorClient::new(channel),
84+
client: ExecutorClient::new(channel)
85+
.max_encoding_message_size(MAX_MESSAGE_SIZE)
86+
.max_decoding_message_size(MAX_MESSAGE_SIZE),
7987
}
8088
}
8189
}
@@ -122,9 +130,16 @@ impl Dispatcher for ExecutorDispatcher {
122130
}
123131

124132
async fn run_action(&self, raw: Vec<u8>) -> Result<Outcome> {
133+
let request = pb::RunActionRequest { task: raw };
134+
if request.encoded_len() > MAX_MESSAGE_SIZE {
135+
return Err(tonic::Status::resource_exhausted(format!(
136+
"RunAction request exceeds the {MAX_MESSAGE_SIZE}-byte protocol limit"
137+
))
138+
.into());
139+
}
125140
let mut client = self.client.clone();
126141
let mut stream = client
127-
.run_action(pb::RunActionRequest { task: raw })
142+
.run_action(request)
128143
.await
129144
.context("executor RunAction failed")?
130145
.into_inner();
@@ -152,3 +167,132 @@ impl Dispatcher for ExecutorDispatcher {
152167
}
153168
}
154169
}
170+
171+
#[cfg(all(test, unix))]
172+
mod tests {
173+
use super::*;
174+
use crate::proto::executor::executor_server::{Executor, ExecutorServer};
175+
use std::pin::Pin;
176+
use tokio_stream::Stream;
177+
use tokio_stream::wrappers::UnixListenerStream;
178+
use tonic::{Request, Response, Status};
179+
180+
#[derive(Clone)]
181+
struct FakeExecutor {
182+
output: Vec<u8>,
183+
}
184+
185+
#[tonic::async_trait]
186+
impl Executor for FakeExecutor {
187+
type RunActionStream =
188+
Pin<Box<dyn Stream<Item = std::result::Result<pb::RunActionResponse, Status>> + Send>>;
189+
190+
async fn run_action(
191+
&self,
192+
_request: Request<pb::RunActionRequest>,
193+
) -> std::result::Result<Response<Self::RunActionStream>, Status> {
194+
let response = pb::RunActionResponse {
195+
event: Some(pb::run_action_response::Event::Result(pb::ActionResult {
196+
outcome: Some(pb::action_result::Outcome::Output(self.output.clone())),
197+
})),
198+
};
199+
Ok(Response::new(Box::pin(tokio_stream::once(Ok(response)))))
200+
}
201+
202+
async fn health(
203+
&self,
204+
_request: Request<pb::HealthRequest>,
205+
) -> std::result::Result<Response<pb::HealthResponse>, Status> {
206+
Ok(Response::new(pb::HealthResponse::default()))
207+
}
208+
209+
async fn sync_keys(
210+
&self,
211+
_request: Request<pb::SyncKeysRequest>,
212+
) -> std::result::Result<Response<pb::SyncKeysResponse>, Status> {
213+
Ok(Response::new(pb::SyncKeysResponse::default()))
214+
}
215+
}
216+
217+
async fn test_dispatcher(output: Vec<u8>) -> (ExecutorDispatcher, tempfile::TempDir) {
218+
let dir = tempfile::tempdir().expect("tempdir");
219+
let socket = dir.path().join("executor.sock");
220+
let listener = tokio::net::UnixListener::bind(&socket).expect("bind executor socket");
221+
tokio::spawn(async move {
222+
let service = ExecutorServer::new(FakeExecutor { output })
223+
.max_decoding_message_size(MAX_MESSAGE_SIZE * 2)
224+
.max_encoding_message_size(MAX_MESSAGE_SIZE * 2);
225+
let _ = tonic::transport::Server::builder()
226+
.add_service(service)
227+
.serve_with_incoming(UnixListenerStream::new(listener))
228+
.await;
229+
});
230+
(ExecutorDispatcher::new(&socket, None), dir)
231+
}
232+
233+
fn request_payload_with_encoded_size(target: usize) -> Vec<u8> {
234+
let mut payload = vec![b'a'; target];
235+
loop {
236+
let encoded = pb::RunActionRequest {
237+
task: payload.clone(),
238+
}
239+
.encoded_len();
240+
match encoded.cmp(&target) {
241+
std::cmp::Ordering::Equal => return payload,
242+
std::cmp::Ordering::Less => payload.resize(payload.len() + target - encoded, b'a'),
243+
std::cmp::Ordering::Greater => payload.truncate(payload.len() - (encoded - target)),
244+
}
245+
}
246+
}
247+
248+
fn response_output_with_encoded_size(target: usize) -> Vec<u8> {
249+
let mut output = vec![b'a'; target];
250+
loop {
251+
let encoded = pb::RunActionResponse {
252+
event: Some(pb::run_action_response::Event::Result(pb::ActionResult {
253+
outcome: Some(pb::action_result::Outcome::Output(output.clone())),
254+
})),
255+
}
256+
.encoded_len();
257+
match encoded.cmp(&target) {
258+
std::cmp::Ordering::Equal => return output,
259+
std::cmp::Ordering::Less => output.resize(output.len() + target - encoded, b'a'),
260+
std::cmp::Ordering::Greater => output.truncate(output.len() - (encoded - target)),
261+
}
262+
}
263+
}
264+
265+
fn assert_status(error: &anyhow::Error, expected: tonic::Code) {
266+
let status = error
267+
.downcast_ref::<Status>()
268+
.unwrap_or_else(|| panic!("expected tonic status, got {error:#}"));
269+
assert_eq!(status.code(), expected, "unexpected status: {status}");
270+
}
271+
272+
#[tokio::test]
273+
async fn enforces_request_encoding_limit() {
274+
let (dispatcher, _dir) = test_dispatcher(Vec::new()).await;
275+
let below = request_payload_with_encoded_size(MAX_MESSAGE_SIZE - 1);
276+
assert!(dispatcher.run_action(below).await.is_ok());
277+
278+
let above = request_payload_with_encoded_size(MAX_MESSAGE_SIZE + 1);
279+
let error = dispatcher.run_action(above).await.unwrap_err();
280+
assert_status(&error, tonic::Code::ResourceExhausted);
281+
}
282+
283+
#[tokio::test]
284+
async fn enforces_response_decoding_limit() {
285+
let below = response_output_with_encoded_size(MAX_MESSAGE_SIZE - 1);
286+
let (dispatcher, _dir) = test_dispatcher(below.clone()).await;
287+
match dispatcher.run_action(Vec::new()).await.unwrap() {
288+
Outcome::Success { output_json } => assert_eq!(output_json, below),
289+
Outcome::Failure { .. } => panic!("expected success"),
290+
}
291+
292+
let above = response_output_with_encoded_size(MAX_MESSAGE_SIZE + 1);
293+
let (dispatcher, _dir) = test_dispatcher(above).await;
294+
let error = dispatcher.run_action(Vec::new()).await.unwrap_err();
295+
// Tonic reports an oversized decoded response as OutOfRange.
296+
assert_status(&error, tonic::Code::OutOfRange);
297+
}
298+
}

0 commit comments

Comments
 (0)