Repository: nginx/nginx-gateway-fabric (go)

Issue:
Support FileStream to NGINX agent
For larger files, agent is updating its API to support streaming large files in chunks. NGF needs to support this for "large" configuration files.

### Acceptance
- NGF implements the GetFileStream API and sends chunks of files when over the file size limit

Pull request:
Support sending chunked files to agent
Problem: If nginx.conf files are too large, they can't be sent over the existing gRPC stream to agent.

Solution: Agent has added an API and helper function to send files in chunks if over a certain size limit. Our controller will implement this API for agent to call when in this condition.

Testing: Verified that files over the threshold are chunked.

Closes #3315

### Checklist

Before creating a PR, run through this checklist and mark each as complete.

- [x] I have read the [CONTRIBUTING](https://github.com/nginx/nginx-gateway-fabric/blob/main/CONTRIBUTING.md) doc
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] I have checked that all unit tests pass after adding my changes
- [x] I have updated necessary documentation
- [x] I have rebased my branch onto main
- [x] I will ensure my PR is targeting the main branch and pulling from my branch from my own fork

### Release notes

If this PR introduces a change that affects users and needs to be mentioned in the [release notes](../blob/main/CHANGELOG.md),
please add a brief note that summarizes the change.

<!-- If this PR does not require a release note, you can just write NONE in the release-note block below. -->

```release-note

```


Patch:
diff --git a/build/Dockerfile.nginx b/build/Dockerfile.nginx
index 3d5dc242..8d229878 100644
--- a/build/Dockerfile.nginx
+++ b/build/Dockerfile.nginx
@@ -12,7 +12,7 @@ WORKDIR /tmp
 RUN apk add --no-cache git make \
     && git clone https://github.com/nginx/agent.git \
     && cd agent \
-    && git checkout e745a3236e0f02a579461a5a435b3bcd410a686c \
+    && git checkout 9b574fa90848c9a7c123e6e7e6153ccd602ae724 \
     && make build
 
 FROM nginx:1.28.0-alpine-otel
diff --git a/build/Dockerfile.nginxplus b/build/Dockerfile.nginxplus
index 2c7d7452..84ee8993 100644
--- a/build/Dockerfile.nginxplus
+++ b/build/Dockerfile.nginxplus
@@ -11,7 +11,7 @@ WORKDIR /tmp
 RUN apk add --no-cache git make \
     && git clone https://github.com/nginx/agent.git \
     && cd agent \
-    && git checkout e745a3236e0f02a579461a5a435b3bcd410a686c \
+    && git checkout 9b574fa90848c9a7c123e6e7e6153ccd602ae724 \
     && make build
 
 FROM alpine:3.21
diff --git a/go.mod b/go.mod
index 5dee1b22..21047e6a 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/go-logr/logr v1.4.2
 	github.com/google/go-cmp v0.7.0
 	github.com/google/uuid v1.6.0
-	github.com/nginx/agent/v3 v3.0.0-20250513105855-e745a3236e0f
+	github.com/nginx/agent/v3 v3.0.0-20250520100419-9b574fa90848
 	github.com/nginx/telemetry-exporter v0.1.4
 	github.com/onsi/ginkgo/v2 v2.23.4
 	github.com/onsi/gomega v1.37.0
diff --git a/go.sum b/go.sum
index d4e1b379..4eed9955 100644
--- a/go.sum
+++ b/go.sum
@@ -133,8 +133,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/nginx/agent/v3 v3.0.0-20250513105855-e745a3236e0f h1:fSUAaR1AxmmbmGMRkvKGY2+LhuVpBp7tbBFLLgDMjNQ=
-github.com/nginx/agent/v3 v3.0.0-20250513105855-e745a3236e0f/go.mod h1:O/31aKtii/mpiZmFGMcTNDoLtKzwTyTXOBMSRkMaPvs=
+github.com/nginx/agent/v3 v3.0.0-20250520100419-9b574fa90848 h1:BZ5WY30Ojw/+/SmmvsdbM7SXuEpUy9zgBkuMSVet540=
+github.com/nginx/agent/v3 v3.0.0-20250520100419-9b574fa90848/go.mod h1:O/31aKtii/mpiZmFGMcTNDoLtKzwTyTXOBMSRkMaPvs=
 github.com/nginx/telemetry-exporter v0.1.4 h1:3ikgKlyz/O57oaBLkxCInMjr74AhGTKr9rHdRAkkl/w=
 github.com/nginx/telemetry-exporter v0.1.4/go.mod h1:bl6qmsxgk4a9D0X8R5E3sUNXN2iECPEK1JNbRLhN5C4=
 github.com/nginxinc/nginx-plus-go-client/v2 v2.0.1 h1:5VVK38bnELMDWnwfF6dSv57ResXh9AUzeDa72ENj94o=
diff --git a/internal/mode/static/nginx/agent/command.go b/internal/mode/static/nginx/agent/command.go
index 8f694e58..a8b2ba84 100644
--- a/internal/mode/static/nginx/agent/command.go
+++ b/internal/mode/static/nginx/agent/command.go
@@ -522,9 +522,9 @@ func getNginxInstanceID(instances []*pb.Instance) string {
 }
 
 // UpdateDataPlaneHealth includes full health information about the data plane as reported by the agent.
-func (cs *commandService) UpdateDataPlaneHealth(
-	_ context.Context,
-	_ *pb.UpdateDataPlaneHealthRequest,
+func (*commandService) UpdateDataPlaneHealth(
+	context.Context,
+	*pb.UpdateDataPlaneHealthRequest,
 ) (*pb.UpdateDataPlaneHealthResponse, error) {
 	return &pb.UpdateDataPlaneHealthResponse{}, nil
 }
diff --git a/internal/mode/static/nginx/agent/file.go b/internal/mode/static/nginx/agent/file.go
index fa604bc1..17fac0a9 100644
--- a/internal/mode/static/nginx/agent/file.go
+++ b/internal/mode/static/nginx/agent/file.go
@@ -1,10 +1,13 @@
 package agent
 
 import (
+	"bytes"
 	"context"
+	"math"
 
 	"github.com/go-logr/logr"
 	pb "github.com/nginx/agent/v3/api/grpc/mpi/v1"
+	"github.com/nginx/agent/v3/pkg/files"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
@@ -13,6 +16,8 @@ import (
 	grpcContext "github.com/nginx/nginx-gateway-fabric/internal/mode/static/nginx/agent/grpc/context"
 )
 
+const defaultChunkSize uint32 = 2097152 // 2MB
+
 // File is an nginx configuration file that the nginx agent gets from the control plane
 // after a ConfigApplyRequest.
 type File struct {
@@ -51,15 +56,83 @@ func (fs *fileService) GetFile(
 	ctx context.Context,
 	req *pb.GetFileRequest,
 ) (*pb.GetFileResponse, error) {
-	filename := req.GetFileMeta().GetName()
-	hash := req.GetFileMeta().GetHash()
-
 	gi, ok := grpcContext.GrpcInfoFromContext(ctx)
 	if !ok {
 		return nil, agentgrpc.ErrStatusInvalidConnection
 	}
 
-	conn := fs.connTracker.GetConnection(gi.IPAddress)
+	if req.GetFileMeta() == nil {
+		return nil, status.Error(codes.InvalidArgument, "invalid request")
+	}
+
+	contents, err := fs.getFileContents(req, gi.IPAddress)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.GetFileResponse{
+		Contents: &pb.FileContents{
+			Contents: contents,
+		},
+	}, nil
+}
+
+// GetFileStream is called by the agent when it needs to download a file in chunks for a ConfigApplyRequest.
+// The deployment object used to get the files is already LOCKED when this function is called,
+// before the ConfigApply transaction is started.
+func (fs *fileService) GetFileStream(
+	req *pb.GetFileRequest,
+	server grpc.ServerStreamingServer[pb.FileDataChunk],
+) error {
+	gi, ok := grpcContext.GrpcInfoFromContext(server.Context())
+	if !ok {
+		return agentgrpc.ErrStatusInvalidConnection
+	}
+
+	if req.GetFileMeta() == nil || req.GetMessageMeta() == nil {
+		return status.Error(codes.InvalidArgument, "invalid request")
+	}
+
+	contents, err := fs.getFileContents(req, gi.IPAddress)
+	if err != nil {
+		return err
+	}
+
+	size := req.GetFileMeta().GetSize()
+	var sizeUint32 uint32
+	if size > math.MaxUint32 {
+		return status.Error(codes.Internal, "file size is too large and cannot be converted to uint32")
+	}
+	sizeUint32 = uint32(size) //nolint:gosec // validation check performed on previous line
+	hash := req.GetFileMeta().GetHash()
+
+	fs.logger.V(1).Info("Sending chunked file to agent", "file", req.GetFileMeta().GetName())
+
+	if err := files.SendChunkedFile(
+		req.GetMessageMeta(),
+		pb.FileDataChunk_Header{
+			Header: &pb.FileDataChunkHeader{
+				ChunkSize: defaultChunkSize,
+				Chunks:    calculateChunks(sizeUint32, defaultChunkSize),
+				FileMeta: &pb.FileMeta{
+					Name:        req.GetFileMeta().GetName(),
+					Hash:        hash,
+					Permissions: req.GetFileMeta().GetPermissions(),
+					Size:        size,
+				},
+			},
+		},
+		bytes.NewReader(contents),
+		server,
+	); err != nil {
+		return status.Error(codes.Aborted, err.Error())
+	}
+
+	return nil
+}
+
+func (fs *fileService) getFileContents(req *pb.GetFileRequest, connKey string) ([]byte, error) {
+	conn := fs.connTracker.GetConnection(connKey)
 	if conn.PodName == "" {
 		return nil, status.Errorf(codes.NotFound, "connection not found")
 	}
@@ -69,43 +142,47 @@ func (fs *fileService) GetFile(
 		return nil, status.Errorf(codes.NotFound, "deployment not found in store")
 	}
 
-	contents := deployment.GetFile(filename, hash)
+	filename := req.GetFileMeta().GetName()
+	contents := deployment.GetFile(filename, req.GetFileMeta().GetHash())
 	if len(contents) == 0 {
 		return nil, status.Errorf(codes.NotFound, "file not found")
 	}
 
 	fs.logger.V(1).Info("Getting file for agent", "file", filename)
 
-	return &pb.GetFileResponse{
-		Contents: &pb.FileContents{
-			Contents: contents,
-		},
-	}, nil
+	return contents, nil
+}
+
+func calculateChunks(fileSize uint32, chunkSize uint32) uint32 {
+	remainder, divide := fileSize%chunkSize, fileSize/chunkSize
+	if remainder > 0 {
+		return divide + 1
+	}
+	// if fileSize is divisible by chunkSize without remainder
+	// then we don't need the extra chunk for the remainder
+	return divide
 }
 
 // GetOverview gets the overview of files for a particular configuration version of an instance.
 // At the moment it doesn't appear to be used by the agent.
-func (fs *fileService) GetOverview(
-	_ context.Context,
-	_ *pb.GetOverviewRequest,
-) (*pb.GetOverviewResponse, error) {
+func (*fileService) GetOverview(context.Context, *pb.GetOverviewRequest) (*pb.GetOverviewResponse, error) {
 	return &pb.GetOverviewResponse{}, nil
 }
 
 // UpdateOverview is called by agent on startup and whenever any files change on the instance.
 // Since directly changing nginx configuration on the instance is not supported, this is a no-op for NGF.
-func (fs *fileService) UpdateOverview(
-	_ context.Context,
-	_ *pb.UpdateOverviewRequest,
-) (*pb.UpdateOverviewResponse, error) {
+func (*fileService) UpdateOverview(context.Context, *pb.UpdateOverviewRequest) (*pb.UpdateOverviewResponse, error) {
 	return &pb.UpdateOverviewResponse{}, nil
 }
 
 // UpdateFile is called by agent whenever any files change on the instance.
 // Since directly changing nginx configuration on the instance is not supported, this is a no-op for NGF.
-func (fs *fileService) UpdateFile(
-	_ context.Context,
-	_ *pb.UpdateFileRequest,
-) (*pb.UpdateFileResponse, error) {
+func (*fileService) UpdateFile(context.Context, *pb.UpdateFileRequest) (*pb.UpdateFileResponse, error) {
 	return &pb.UpdateFileResponse{}, nil
 }
+
+// UpdateFileStream is called by agent whenever any files change on the instance.
+// Since directly changing nginx configuration on the instance is not supported, this is a no-op for NGF.
+func (*fileService) UpdateFileStream(grpc.ClientStreamingServer[pb.FileDataChunk, pb.UpdateFileResponse]) error {
+	return nil
+}
diff --git a/internal/mode/static/nginx/config/generator.go b/internal/mode/static/nginx/config/generator.go
index 5e92544c..6b40b6ac 100644
--- a/internal/mode/static/nginx/config/generator.go
+++ b/internal/mode/static/nginx/config/generator.go
@@ -143,6 +143,7 @@ func (g GeneratorImpl) GenerateDeploymentContext(depCtx dataplane.DeploymentCont
 			Name:        mainIncludesFolder + "/deployment_ctx.json",
 			Hash:        filesHelper.GenerateHash(depCtxBytes),
 			Permissions: file.RegularFileMode,
+			Size:        int64(len(depCtxBytes)),
 		},
 		Contents: depCtxBytes,
 	}
@@ -178,6 +179,7 @@ func (g GeneratorImpl) executeConfigTemplates(
 				Name:        fp,
 				Hash:        filesHelper.GenerateHash(bytes),
 				Permissions: file.RegularFileMode,
+				Size:        int64(len(bytes)),
 			},
 			Contents: bytes,
 		})
@@ -218,6 +220,7 @@ func generatePEM(id dataplane.SSLKeyPairID, cert []byte, key []byte) agent.File
 			Name:        generatePEMFileName(id),
 			Hash:        filesHelper.GenerateHash(c),
 			Permissions: file.SecretFileMode,
+			Size:        int64(len(c)),
 		},
 		Contents: c,
 	}
@@ -233,6 +236,7 @@ func generateCertBundle(id dataplane.CertBundleID, cert []byte) agent.File {
 			Name:        generateCertBundleFileName(id),
 			Hash:        filesHelper.GenerateHash(cert),
 			Permissions: file.SecretFileMode,
+			Size:        int64(len(cert)),
 		},
 		Contents: cert,
 	}
diff --git a/internal/mode/static/nginx/config/main_config.go b/internal/mode/static/nginx/config/main_config.go
index edb837d2..f6710bc5 100644
--- a/internal/mode/static/nginx/config/main_config.go
+++ b/internal/mode/static/nginx/config/main_config.go
@@ -69,6 +69,7 @@ func (g GeneratorImpl) generateMgmtFiles(conf dataplane.Configuration) []agent.F
 			Name:        secretsFolder + "/license.jwt",
 			Hash:        filesHelper.GenerateHash(tokenContent),
 			Permissions: file.SecretFileMode,
+			Size:        int64(len(tokenContent)),
 		},
 		Contents: tokenContent,
 	}
@@ -87,6 +88,7 @@ func (g GeneratorImpl) generateMgmtFiles(conf dataplane.Configuration) []agent.F
 				Name:        secretsFolder + "/mgmt-ca.crt",
 				Hash:        filesHelper.GenerateHash(content),
 				Permissions: file.SecretFileMode,
+				Size:        int64(len(content)),
 			},
 			Contents: content,
 		}
@@ -100,6 +102,7 @@ func (g GeneratorImpl) generateMgmtFiles(conf dataplane.Configuration) []agent.F
 				Name:        secretsFolder + "/mgmt-tls.crt",
 				Hash:        filesHelper.GenerateHash(content),
 				Permissions: file.SecretFileMode,
+				Size:        int64(len(content)),
 			},
 			Contents: content,
 		}
@@ -113,6 +116,7 @@ func (g GeneratorImpl) generateMgmtFiles(conf dataplane.Configuration) []agent.F
 				Name:        secretsFolder + "/mgmt-tls.key",
 				Hash:        filesHelper.GenerateHash(content),
 				Permissions: file.SecretFileMode,
+				Size:        int64(len(content)),
 			},
 			Contents: content,
 		}
@@ -133,6 +137,7 @@ func (g GeneratorImpl) generateMgmtFiles(conf dataplane.Configuration) []agent.F
 			Name:        mgmtIncludesFile,
 			Hash:        filesHelper.GenerateHash(mgmtContents),
 			Permissions: file.RegularFileMode,
+			Size:        int64(len(mgmtContents)),
 		},
 		Contents: mgmtContents,
 	}


Test patch:
diff --git a/internal/mode/static/nginx/agent/file_test.go b/internal/mode/static/nginx/agent/file_test.go
index 1e683eb2..22f8f4d9 100644
--- a/internal/mode/static/nginx/agent/file_test.go
+++ b/internal/mode/static/nginx/agent/file_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/go-logr/logr"
 	pb "github.com/nginx/agent/v3/api/grpc/mpi/v1"
 	. "github.com/onsi/gomega"
+	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 	"k8s.io/apimachinery/pkg/types"
@@ -16,6 +17,24 @@ import (
 	agentgrpcfakes "github.com/nginx/nginx-gateway-fabric/internal/mode/static/nginx/agent/grpc/grpcfakes"
 )
 
+type mockServerStreamingServer struct {
+	grpc.ServerStream
+	ctx        context.Context
+	sentChunks []*pb.FileDataChunk
+}
+
+func (m *mockServerStreamingServer) Send(chunk *pb.FileDataChunk) error {
+	m.sentChunks = append(m.sentChunks, chunk)
+
+	return nil
+}
+
+func (m *mockServerStreamingServer) Context() context.Context { return m.ctx }
+
+func newMockServerStreamingServer(ctx context.Context) *mockServerStreamingServer {
+	return &mockServerStreamingServer{ctx: ctx}
+}
+
 func TestGetFile(t *testing.T) {
 	t.Parallel()
 	g := NewWithT(t)
@@ -31,7 +50,7 @@ func TestGetFile(t *testing.T) {
 	connTracker.GetConnectionReturns(conn)
 
 	depStore := NewDeploymentStore(connTracker)
-	dep := depStore.GetOrStore(context.Background(), deploymentName, nil)
+	dep := depStore.GetOrStore(t.Context(), deploymentName, nil)
 
 	fileMeta := &pb.FileMeta{
 		Name: "test.conf",
@@ -48,7 +67,7 @@ func TestGetFile(t *testing.T) {
 
 	fs := newFileService(logr.Discard(), depStore, connTracker)
 
-	ctx := grpcContext.NewGrpcContext(context.Background(), grpcContext.GrpcInfo{
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
 		IPAddress: "127.0.0.1",
 	})
 
@@ -77,12 +96,44 @@ func TestGetFile_InvalidConnection(t *testing.T) {
 		},
 	}
 
-	resp, err := fs.GetFile(context.Background(), req)
+	resp, err := fs.GetFile(t.Context(), req)
 
 	g.Expect(err).To(Equal(agentgrpc.ErrStatusInvalidConnection))
 	g.Expect(resp).To(BeNil())
 }
 
+func TestGetFile_InvalidRequest(t *testing.T) {
+	t.Parallel()
+	g := NewWithT(t)
+
+	deploymentName := types.NamespacedName{Name: "nginx-deployment", Namespace: "default"}
+	connTracker := &agentgrpcfakes.FakeConnectionsTracker{}
+	conn := agentgrpc.Connection{
+		PodName:    "nginx-pod",
+		InstanceID: "12345",
+		Parent:     deploymentName,
+	}
+	connTracker.GetConnectionReturns(conn)
+
+	depStore := NewDeploymentStore(connTracker)
+	_ = depStore.GetOrStore(t.Context(), deploymentName, nil)
+
+	fs := newFileService(logr.Discard(), depStore, connTracker)
+
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
+		IPAddress: "127.0.0.1",
+	})
+
+	req := &pb.GetFileRequest{
+		FileMeta: nil,
+	}
+
+	resp, err := fs.GetFile(ctx, req)
+
+	g.Expect(err).To(Equal(status.Error(codes.InvalidArgument, "invalid request")))
+	g.Expect(resp).To(BeNil())
+}
+
 func TestGetFile_ConnectionNotFound(t *testing.T) {
 	t.Parallel()
 	g := NewWithT(t)
@@ -96,7 +147,7 @@ func TestGetFile_ConnectionNotFound(t *testing.T) {
 		},
 	}
 
-	ctx := grpcContext.NewGrpcContext(context.Background(), grpcContext.GrpcInfo{
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
 		IPAddress: "127.0.0.1",
 	})
 
@@ -129,7 +180,7 @@ func TestGetFile_DeploymentNotFound(t *testing.T) {
 		},
 	}
 
-	ctx := grpcContext.NewGrpcContext(context.Background(), grpcContext.GrpcInfo{
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
 		IPAddress: "127.0.0.1",
 	})
 
@@ -154,7 +205,7 @@ func TestGetFile_FileNotFound(t *testing.T) {
 	connTracker.GetConnectionReturns(conn)
 
 	depStore := NewDeploymentStore(connTracker)
-	depStore.GetOrStore(context.Background(), deploymentName, nil)
+	depStore.GetOrStore(t.Context(), deploymentName, nil)
 
 	fs := newFileService(logr.Discard(), depStore, connTracker)
 
@@ -165,7 +216,7 @@ func TestGetFile_FileNotFound(t *testing.T) {
 		},
 	}
 
-	ctx := grpcContext.NewGrpcContext(context.Background(), grpcContext.GrpcInfo{
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
 		IPAddress: "127.0.0.1",
 	})
 
@@ -175,12 +226,136 @@ func TestGetFile_FileNotFound(t *testing.T) {
 	g.Expect(resp).To(BeNil())
 }
 
+func TestGetFileStream(t *testing.T) {
+	t.Parallel()
+	g := NewWithT(t)
+
+	deploymentName := types.NamespacedName{Name: "nginx-deployment", Namespace: "default"}
+
+	connTracker := &agentgrpcfakes.FakeConnectionsTracker{}
+	conn := agentgrpc.Connection{
+		PodName:    "nginx-pod",
+		InstanceID: "12345",
+		Parent:     deploymentName,
+	}
+	connTracker.GetConnectionReturns(conn)
+
+	depStore := NewDeploymentStore(connTracker)
+	dep := depStore.GetOrStore(t.Context(), deploymentName, nil)
+
+	// Create a file larger than defaultChunkSize to ensure multiple chunks are sent
+	fileContent := make([]byte, defaultChunkSize+100)
+	for i := range fileContent {
+		fileContent[i] = byte(i % 256)
+	}
+	fileMeta := &pb.FileMeta{
+		Name: "bigfile.conf",
+		Hash: "big-hash",
+		Size: int64(len(fileContent)),
+	}
+
+	dep.files = []File{
+		{
+			Meta:     fileMeta,
+			Contents: fileContent,
+		},
+	}
+
+	fs := newFileService(logr.Discard(), depStore, connTracker)
+
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
+		IPAddress: "127.0.0.1",
+	})
+
+	req := &pb.GetFileRequest{
+		FileMeta:    fileMeta,
+		MessageMeta: &pb.MessageMeta{},
+	}
+
+	server := newMockServerStreamingServer(ctx)
+
+	err := fs.GetFileStream(req, server)
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(len(server.sentChunks)).To(BeNumerically(">", 1))
+	g.Expect(server.sentChunks[0].GetHeader()).ToNot(BeNil())
+
+	var received []byte
+	for _, c := range server.sentChunks {
+		if c.GetContent() != nil {
+			received = append(received, c.GetContent().Data...)
+		}
+	}
+	g.Expect(received).To(Equal(fileContent))
+}
+
+func TestGetFileStream_InvalidConnection(t *testing.T) {
+	t.Parallel()
+	g := NewWithT(t)
+
+	fs := newFileService(logr.Discard(), nil, nil)
+
+	req := &pb.GetFileRequest{
+		FileMeta:    &pb.FileMeta{Name: "test.conf", Hash: "some-hash"},
+		MessageMeta: &pb.MessageMeta{},
+	}
+
+	server := newMockServerStreamingServer(t.Context())
+
+	err := fs.GetFileStream(req, server)
+	g.Expect(err).To(Equal(agentgrpc.ErrStatusInvalidConnection))
+}
+
+func TestGetFileStream_InvalidRequest(t *testing.T) {
+	t.Parallel()
+	g := NewWithT(t)
+
+	deploymentName := types.NamespacedName{Name: "nginx-deployment", Namespace: "default"}
+	connTracker := &agentgrpcfakes.FakeConnectionsTracker{}
+	conn := agentgrpc.Connection{
+		PodName:    "nginx-pod",
+		InstanceID: "12345",
+		Parent:     deploymentName,
+	}
+	connTracker.GetConnectionReturns(conn)
+
+	depStore := NewDeploymentStore(connTracker)
+	_ = depStore.GetOrStore(t.Context(), deploymentName, nil)
+
+	fs := newFileService(logr.Discard(), depStore, connTracker)
+
+	ctx := grpcContext.NewGrpcContext(t.Context(), grpcContext.GrpcInfo{
+		IPAddress: "127.0.0.1",
+	})
+
+	// no filemeta
+	req := &pb.GetFileRequest{
+		FileMeta:    nil,
+		MessageMeta: &pb.MessageMeta{},
+	}
+
+	server := newMockServerStreamingServer(ctx)
+
+	err := fs.GetFileStream(req, server)
+	g.Expect(err).To(Equal(status.Error(codes.InvalidArgument, "invalid request")))
+	g.Expect(server.sentChunks).To(BeEmpty())
+
+	// no messagemeta
+	req = &pb.GetFileRequest{
+		FileMeta:    &pb.FileMeta{Name: "test.conf", Hash: "some-hash"},
+		MessageMeta: nil,
+	}
+
+	err = fs.GetFileStream(req, server)
+	g.Expect(err).To(Equal(status.Error(codes.InvalidArgument, "invalid request")))
+	g.Expect(server.sentChunks).To(BeEmpty())
+}
+
 func TestGetOverview(t *testing.T) {
 	t.Parallel()
 	g := NewWithT(t)
 
 	fs := newFileService(logr.Discard(), nil, nil)
-	resp, err := fs.GetOverview(context.Background(), &pb.GetOverviewRequest{})
+	resp, err := fs.GetOverview(t.Context(), &pb.GetOverviewRequest{})
 
 	g.Expect(err).ToNot(HaveOccurred())
 	g.Expect(resp).To(Equal(&pb.GetOverviewResponse{}))
@@ -191,7 +366,7 @@ func TestUpdateOverview(t *testing.T) {
 	g := NewWithT(t)
 
 	fs := newFileService(logr.Discard(), nil, nil)
-	resp, err := fs.UpdateOverview(context.Background(), &pb.UpdateOverviewRequest{})
+	resp, err := fs.UpdateOverview(t.Context(), &pb.UpdateOverviewRequest{})
 
 	g.Expect(err).ToNot(HaveOccurred())
 	g.Expect(resp).To(Equal(&pb.UpdateOverviewResponse{}))
@@ -202,8 +377,16 @@ func TestUpdateFile(t *testing.T) {
 	g := NewWithT(t)
 
 	fs := newFileService(logr.Discard(), nil, nil)
-	resp, err := fs.UpdateFile(context.Background(), &pb.UpdateFileRequest{})
+	resp, err := fs.UpdateFile(t.Context(), &pb.UpdateFileRequest{})
 
 	g.Expect(err).ToNot(HaveOccurred())
 	g.Expect(resp).To(Equal(&pb.UpdateFileResponse{}))
 }
+
+func TestUpdateFileStream(t *testing.T) {
+	t.Parallel()
+	g := NewWithT(t)
+
+	fs := newFileService(logr.Discard(), nil, nil)
+	g.Expect(fs.UpdateFileStream(nil)).To(Succeed())
+}
diff --git a/internal/mode/static/nginx/config/generator_test.go b/internal/mode/static/nginx/config/generator_test.go
index 9e0fe140..51b73361 100644
--- a/internal/mode/static/nginx/config/generator_test.go
+++ b/internal/mode/static/nginx/config/generator_test.go
@@ -261,6 +261,7 @@ func TestGenerate(t *testing.T) {
 			Name:        "/etc/nginx/secrets/test-keypair.pem",
 			Hash:        filesHelper.GenerateHash([]byte("test-cert\ntest-key")),
 			Permissions: file.SecretFileMode,
+			Size:        int64(len([]byte("test-cert\ntest-key"))),
 		},
 		Contents: []byte("test-cert\ntest-key"),
 	}))