Skip to content

Commit 3e32254

Browse files
Tests for OCI image volume
1 parent 8164221 commit 3e32254

1 file changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
// Contrast Security, Inc licenses this file to you under the Apache 2.0 License.
2+
// See the LICENSE file in the project root for more information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
using Contrast.K8s.AgentOperator.Core;
9+
using Contrast.K8s.AgentOperator.Core.Reactions.Injecting;
10+
using Contrast.K8s.AgentOperator.Core.Reactions.Injecting.Patching;
11+
using Contrast.K8s.AgentOperator.Core.Reactions.Injecting.Patching.Agents;
12+
using Contrast.K8s.AgentOperator.Core.Reactions.Matching;
13+
using Contrast.K8s.AgentOperator.Core.State.Resources;
14+
using Contrast.K8s.AgentOperator.Core.State.Resources.Primitives;
15+
using Contrast.K8s.AgentOperator.Core.Telemetry.Cluster;
16+
using Contrast.K8s.AgentOperator.Options;
17+
using FluentAssertions;
18+
using FluentAssertions.Execution;
19+
using k8s.Models;
20+
using NSubstitute;
21+
using Xunit;
22+
23+
namespace Contrast.K8s.AgentOperator.Tests.Core.Reactions.Injecting.Patching;
24+
25+
public class PodPatcherImageVolumeTests
26+
{
27+
private static PodPatcher CreatePatcher(
28+
OperatorOptions? operatorOptions = null,
29+
InitContainerOptions? initOptions = null,
30+
TelemetryOptions? telemetryOptions = null,
31+
IAgentPatcher? agentPatcher = null)
32+
{
33+
operatorOptions ??= new OperatorOptions(
34+
Namespace: "default",
35+
SettlingDurationSeconds: 5,
36+
EventQueueSize: 100,
37+
EventQueueFullMode: System.Threading.Channels.BoundedChannelFullMode.DropOldest,
38+
EventQueueMergeWindowSeconds: 1,
39+
RunInitContainersAsNonRoot: false,
40+
SuppressSeccompProfile: false,
41+
EnableAgentStdout: false,
42+
ChaosRatio: 0,
43+
UseImageVolumes: false
44+
);
45+
46+
initOptions ??= new InitContainerOptions("100m", "500m", "64Mi", "256Mi", "128Mi", "512Mi");
47+
telemetryOptions ??= new TelemetryOptions(false, "cluster-id", "default", "operator");
48+
49+
var patchers = agentPatcher != null
50+
? new[] { agentPatcher }
51+
: Array.Empty<IAgentPatcher>();
52+
53+
var globMatcher = Substitute.For<IGlobMatcher>();
54+
globMatcher.Matches(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
55+
56+
var clusterIdState = Substitute.For<IClusterIdState>();
57+
var typeConverter = Substitute.For<IAgentInjectionTypeConverter>();
58+
typeConverter.GetStringFromType(Arg.Any<AgentInjectionType?>()).Returns("java");
59+
60+
return new PodPatcher(
61+
() => patchers,
62+
globMatcher,
63+
clusterIdState,
64+
operatorOptions,
65+
initOptions,
66+
telemetryOptions,
67+
typeConverter
68+
);
69+
}
70+
71+
private static PatchingContext CreateContext(AgentInjectionType type = AgentInjectionType.Java)
72+
{
73+
var image = new ContainerImageReference("docker.io", "contrast/agent-java", "latest");
74+
var selector = new ResourceWithPodSpecSelector(
75+
new List<string> { "*" },
76+
new List<LabelPattern>(),
77+
new List<string> { "default" }
78+
);
79+
var connectionRef = new AgentInjectorConnectionReference("default", "my-connection", false);
80+
var configRef = new AgentConfigurationReference("default", "my-config", false);
81+
82+
var injector = new AgentInjectorResource(
83+
Enabled: true,
84+
Type: type,
85+
Image: image,
86+
Selector: selector,
87+
ConnectionReference: connectionRef,
88+
ConfigurationReference: configRef,
89+
ImagePullSecret: null,
90+
ImagePullPolicy: "IfNotPresent"
91+
);
92+
93+
var connection = new AgentConnectionResource(
94+
MountAsVolume: false,
95+
Token: null,
96+
TeamServerUri: "https://app.contrastsecurity.com",
97+
ApiKey: new SecretReference("default", "contrast-secret", "apiKey"),
98+
ServiceKey: new SecretReference("default", "contrast-secret", "serviceKey"),
99+
UserName: new SecretReference("default", "contrast-secret", "userName")
100+
);
101+
102+
return new PatchingContext(
103+
WorkloadName: "my-app",
104+
WorkloadNamespace: "default",
105+
Injector: injector,
106+
Connection: connection,
107+
Configuration: null,
108+
AgentMountPath: "/contrast/agent",
109+
WritableMountPath: "/contrast/data",
110+
ConnectionSecretMountPath: "/etc/contrast"
111+
);
112+
}
113+
114+
private static V1Pod CreatePod()
115+
{
116+
return new V1Pod
117+
{
118+
Metadata = new V1ObjectMeta
119+
{
120+
Name = "my-app-pod",
121+
NamespaceProperty = "default"
122+
},
123+
Spec = new V1PodSpec
124+
{
125+
Containers = new List<V1Container>
126+
{
127+
new()
128+
{
129+
Name = "app",
130+
Image = "myapp:latest"
131+
}
132+
}
133+
}
134+
};
135+
}
136+
137+
// --- Default (non-image-volume) behavior ---
138+
139+
[Fact]
140+
public async Task When_image_volumes_disabled_then_init_container_should_be_present()
141+
{
142+
var patcher = CreatePatcher();
143+
var context = CreateContext();
144+
var pod = CreatePod();
145+
146+
await patcher.Patch(context, pod);
147+
148+
pod.Spec.InitContainers.Should().ContainSingle(c => c.Name == "contrast-init");
149+
}
150+
151+
[Fact]
152+
public async Task When_image_volumes_disabled_then_agent_volume_should_use_emptydir()
153+
{
154+
var patcher = CreatePatcher();
155+
var context = CreateContext();
156+
var pod = CreatePod();
157+
158+
await patcher.Patch(context, pod);
159+
160+
var agentVolume = pod.Spec.Volumes.Single(v => v.Name == "contrast-agent");
161+
using (new AssertionScope())
162+
{
163+
agentVolume.EmptyDir.Should().NotBeNull();
164+
agentVolume.Image.Should().BeNull();
165+
}
166+
}
167+
168+
[Fact]
169+
public async Task When_image_volumes_disabled_then_injection_mode_annotation_should_not_be_set()
170+
{
171+
var patcher = CreatePatcher();
172+
var context = CreateContext();
173+
var pod = CreatePod();
174+
175+
await patcher.Patch(context, pod);
176+
177+
pod.Metadata.Annotations.Should().NotContainKey(InjectionConstants.InjectionModeAttributeName);
178+
}
179+
180+
// --- Image volume behavior ---
181+
182+
[Fact]
183+
public async Task When_image_volumes_enabled_then_init_container_should_not_be_present()
184+
{
185+
var options = new OperatorOptions("default", 5, 100,
186+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
187+
1, false, false, false, 0, UseImageVolumes: true);
188+
var patcher = CreatePatcher(operatorOptions: options);
189+
var context = CreateContext();
190+
var pod = CreatePod();
191+
192+
await patcher.Patch(context, pod);
193+
194+
pod.Spec.InitContainers.Should().BeNullOrEmpty();
195+
}
196+
197+
[Fact]
198+
public async Task When_image_volumes_enabled_then_agent_volume_should_use_image_source()
199+
{
200+
var options = new OperatorOptions("default", 5, 100,
201+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
202+
1, false, false, false, 0, UseImageVolumes: true);
203+
var patcher = CreatePatcher(operatorOptions: options);
204+
var context = CreateContext();
205+
var pod = CreatePod();
206+
207+
await patcher.Patch(context, pod);
208+
209+
var agentVolume = pod.Spec.Volumes.Single(v => v.Name == "contrast-agent");
210+
using (new AssertionScope())
211+
{
212+
agentVolume.Image.Should().NotBeNull();
213+
agentVolume.Image.Reference.Should().Be("docker.io/contrast/agent-java:latest");
214+
agentVolume.Image.PullPolicy.Should().Be("IfNotPresent");
215+
agentVolume.EmptyDir.Should().BeNull();
216+
}
217+
}
218+
219+
[Fact]
220+
public async Task When_image_volumes_enabled_then_injection_mode_annotation_should_be_set()
221+
{
222+
var options = new OperatorOptions("default", 5, 100,
223+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
224+
1, false, false, false, 0, UseImageVolumes: true);
225+
var patcher = CreatePatcher(operatorOptions: options);
226+
var context = CreateContext();
227+
var pod = CreatePod();
228+
229+
await patcher.Patch(context, pod);
230+
231+
pod.Metadata.Annotations.Should()
232+
.ContainKey(InjectionConstants.InjectionModeAttributeName)
233+
.WhoseValue.Should().Be("image-volume");
234+
}
235+
236+
[Fact]
237+
public async Task When_image_volumes_enabled_then_agent_volume_mount_should_use_image_volume_path()
238+
{
239+
var options = new OperatorOptions("default", 5, 100,
240+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
241+
1, false, false, false, 0, UseImageVolumes: true);
242+
var patcher = CreatePatcher(operatorOptions: options);
243+
var context = CreateContext();
244+
var pod = CreatePod();
245+
246+
await patcher.Patch(context, pod);
247+
248+
var container = pod.Spec.Containers.First();
249+
var agentMount = container.VolumeMounts.Single(vm => vm.Name == "contrast-agent");
250+
using (new AssertionScope())
251+
{
252+
agentMount.MountPath.Should().Be("/contrast/agent");
253+
agentMount.ReadOnlyProperty.Should().BeTrue();
254+
}
255+
}
256+
257+
[Fact]
258+
public async Task When_image_volumes_enabled_then_writable_volume_should_still_be_emptydir()
259+
{
260+
var options = new OperatorOptions("default", 5, 100,
261+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
262+
1, false, false, false, 0, UseImageVolumes: true);
263+
var patcher = CreatePatcher(operatorOptions: options);
264+
var context = CreateContext();
265+
var pod = CreatePod();
266+
267+
await patcher.Patch(context, pod);
268+
269+
var writableVolume = pod.Spec.Volumes.Single(v => v.Name == "contrast-writable");
270+
writableVolume.EmptyDir.Should().NotBeNull();
271+
}
272+
273+
[Fact]
274+
public async Task When_image_volumes_enabled_then_env_vars_should_use_image_volume_mount_path()
275+
{
276+
var options = new OperatorOptions("default", 5, 100,
277+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
278+
1, false, false, false, 0, UseImageVolumes: true);
279+
var patcher = CreatePatcher(operatorOptions: options);
280+
var context = CreateContext();
281+
var pod = CreatePod();
282+
283+
await patcher.Patch(context, pod);
284+
285+
var container = pod.Spec.Containers.First();
286+
var mountPathEnv = container.Env.Single(e => e.Name == "CONTRAST_MOUNT_PATH");
287+
var agentPathEnv = container.Env.Single(e => e.Name == "CONTRAST_MOUNT_AGENT_PATH");
288+
using (new AssertionScope())
289+
{
290+
// AgentMountPath is set to imageVolumeMountPath + "/contrast" = "/contrast/agent/contrast"
291+
mountPathEnv.Value.Should().Be("/contrast/agent/contrast");
292+
agentPathEnv.Value.Should().Be("/contrast/agent/contrast");
293+
}
294+
}
295+
296+
[Fact]
297+
public async Task When_image_volumes_enabled_then_agent_patcher_mount_path_override_should_be_skipped()
298+
{
299+
var options = new OperatorOptions("default", 5, 100,
300+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
301+
1, false, false, false, 0, UseImageVolumes: true);
302+
303+
var mockPatcher = Substitute.For<IAgentPatcher>();
304+
mockPatcher.Type.Returns(AgentInjectionType.Java);
305+
mockPatcher.GetOverrideAgentMountPath().Returns("/opt/contrast");
306+
mockPatcher.GenerateEnvVars(Arg.Any<PatchingContext>()).Returns(callInfo =>
307+
{
308+
var ctx = callInfo.Arg<PatchingContext>();
309+
return new[]
310+
{
311+
new V1EnvVar { Name = "TEST_AGENT_MOUNT_PATH", Value = ctx.AgentMountPath }
312+
};
313+
});
314+
315+
var patcher = CreatePatcher(operatorOptions: options, agentPatcher: mockPatcher);
316+
var context = CreateContext();
317+
var pod = CreatePod();
318+
319+
await patcher.Patch(context, pod);
320+
321+
var container = pod.Spec.Containers.First();
322+
var testEnv = container.Env.Single(e => e.Name == "TEST_AGENT_MOUNT_PATH");
323+
// Should use image volume path, not the Java override of /opt/contrast
324+
testEnv.Value.Should().Be("/contrast/agent/contrast");
325+
}
326+
327+
[Fact]
328+
public async Task When_image_volumes_disabled_then_agent_patcher_mount_path_override_should_apply()
329+
{
330+
var options = new OperatorOptions("default", 5, 100,
331+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
332+
1, false, false, false, 0, UseImageVolumes: false);
333+
334+
var mockPatcher = Substitute.For<IAgentPatcher>();
335+
mockPatcher.Type.Returns(AgentInjectionType.Java);
336+
mockPatcher.GetOverrideAgentMountPath().Returns("/opt/contrast");
337+
mockPatcher.GenerateEnvVars(Arg.Any<PatchingContext>()).Returns(callInfo =>
338+
{
339+
var ctx = callInfo.Arg<PatchingContext>();
340+
return new[]
341+
{
342+
new V1EnvVar { Name = "TEST_AGENT_MOUNT_PATH", Value = ctx.AgentMountPath }
343+
};
344+
});
345+
346+
var patcher = CreatePatcher(operatorOptions: options, agentPatcher: mockPatcher);
347+
var context = CreateContext();
348+
var pod = CreatePod();
349+
350+
await patcher.Patch(context, pod);
351+
352+
var container = pod.Spec.Containers.First();
353+
var testEnv = container.Env.Single(e => e.Name == "TEST_AGENT_MOUNT_PATH");
354+
// Should use the Java override path
355+
testEnv.Value.Should().Be("/opt/contrast");
356+
}
357+
358+
[Fact]
359+
public async Task When_image_volumes_enabled_then_standard_annotations_should_still_be_set()
360+
{
361+
var options = new OperatorOptions("default", 5, 100,
362+
System.Threading.Channels.BoundedChannelFullMode.DropOldest,
363+
1, false, false, false, 0, UseImageVolumes: true);
364+
var patcher = CreatePatcher(operatorOptions: options);
365+
var context = CreateContext();
366+
var pod = CreatePod();
367+
368+
await patcher.Patch(context, pod);
369+
370+
using (new AssertionScope())
371+
{
372+
pod.Metadata.Annotations.Should().ContainKey(InjectionConstants.IsInjectedAttributeName)
373+
.WhoseValue.Should().Be("True");
374+
pod.Metadata.Annotations.Should().ContainKey(InjectionConstants.InjectedOnAttributeName);
375+
pod.Metadata.Annotations.Should().ContainKey(InjectionConstants.InjectedByAttributeName);
376+
pod.Metadata.Annotations.Should().ContainKey(InjectionConstants.InjectorTypeAttributeName)
377+
.WhoseValue.Should().Be("Java");
378+
}
379+
}
380+
}

0 commit comments

Comments
 (0)