Skip to content

Commit 2c55bbd

Browse files
committed
Align volume ownership semantics
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: adc97cfc-61a5-473f-b0a2-e1b981651796
1 parent b01fd05 commit 2c55bbd

7 files changed

Lines changed: 420 additions & 45 deletions

File tree

api/v2/physical_container_volume_types.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,14 @@ type PhysicalContainerVolumeSpec struct {
7373
// VolumeName is the runtime name to use when creating a new volume. Required when volumeID is omitted.
7474
VolumeName string `json:"volumeName,omitempty"`
7575

76-
// Persistent keeps the runtime volume in place when this resource is deleted.
77-
// By default the runtime volume is removed, including when this resource only tracks a
78-
// volume it did not create.
76+
// Persistent keeps a runtime volume created by this resource in place when the resource is deleted.
77+
// Existing runtime volumes referenced by volumeID are always retained.
7978
Persistent bool `json:"persistent,omitempty"`
8079

80+
// ReplaceExisting removes an existing runtime volume with volumeName before creating a new one.
81+
// Replacement waits while the existing volume is in use and never removes attached containers.
82+
ReplaceExisting bool `json:"replaceExisting,omitempty"`
83+
8184
// Labels contains labels to apply to a newly-created runtime volume.
8285
// +listType=map
8386
// +listMapKey=key
@@ -185,6 +188,9 @@ func (pv *PhysicalContainerVolume) Validate(ctx context.Context) field.ErrorList
185188
errorList = append(errorList, commonapi.ValidateAnnotationsSize(pv.Annotations, field.NewPath("metadata", "annotations"))...)
186189

187190
if pv.Spec.VolumeID != "" {
191+
if pv.Spec.Persistent {
192+
errorList = append(errorList, field.Forbidden(specPath.Child("persistent"), "persistent cannot be set when volumeID is set"))
193+
}
188194
if strings.TrimSpace(pv.Spec.VolumeID) != pv.Spec.VolumeID {
189195
errorList = append(errorList, field.Invalid(specPath.Child("volumeID"), pv.Spec.VolumeID, "volumeID must not have leading or trailing whitespace"))
190196
}
@@ -194,6 +200,9 @@ func (pv *PhysicalContainerVolume) Validate(ctx context.Context) field.ErrorList
194200
if len(pv.Spec.Labels) > 0 {
195201
errorList = append(errorList, field.Forbidden(specPath.Child("labels"), "labels cannot be set when volumeID is set"))
196202
}
203+
if pv.Spec.ReplaceExisting {
204+
errorList = append(errorList, field.Forbidden(specPath.Child("replaceExisting"), "replaceExisting cannot be set when volumeID is set"))
205+
}
197206
return errorList
198207
}
199208

api/v2/physical_container_volume_types_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ func TestPhysicalContainerVolumeValidate(t *testing.T) {
3838
Spec: PhysicalContainerVolumeSpec{VolumeID: "test-runtime-volume"},
3939
},
4040
},
41+
{
42+
name: "valid replacement volume",
43+
volume: PhysicalContainerVolume{
44+
ObjectMeta: metav1.ObjectMeta{Name: "test-volume", Namespace: "test-namespace"},
45+
Spec: PhysicalContainerVolumeSpec{
46+
VolumeName: "test-runtime-volume",
47+
ReplaceExisting: true,
48+
},
49+
},
50+
},
4151
{
4252
name: "missing namespace",
4353
volume: PhysicalContainerVolume{
@@ -61,6 +71,17 @@ func TestPhysicalContainerVolumeValidate(t *testing.T) {
6171
},
6272
expectedError: "spec.volumeName",
6373
},
74+
{
75+
name: "persistent with tracked volume",
76+
volume: PhysicalContainerVolume{
77+
ObjectMeta: metav1.ObjectMeta{Name: "test-volume", Namespace: "test-namespace"},
78+
Spec: PhysicalContainerVolumeSpec{
79+
VolumeID: "test-runtime-volume",
80+
Persistent: true,
81+
},
82+
},
83+
expectedError: "spec.persistent",
84+
},
6485
{
6586
name: "volume name with tracked volume",
6687
volume: PhysicalContainerVolume{
@@ -72,6 +93,17 @@ func TestPhysicalContainerVolumeValidate(t *testing.T) {
7293
},
7394
expectedError: "spec.volumeName",
7495
},
96+
{
97+
name: "replace existing with tracked volume",
98+
volume: PhysicalContainerVolume{
99+
ObjectMeta: metav1.ObjectMeta{Name: "test-volume", Namespace: "test-namespace"},
100+
Spec: PhysicalContainerVolumeSpec{
101+
VolumeID: "test-runtime-volume",
102+
ReplaceExisting: true,
103+
},
104+
},
105+
expectedError: "spec.replaceExisting",
106+
},
75107
{
76108
name: "labels with tracked volume",
77109
volume: PhysicalContainerVolume{

controllers/physical_container_volume_controller.go

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,22 @@ func (r *PhysicalContainerVolumeReconciler) createPhysicalContainerVolume(
276276
data *physicalContainerVolumeData,
277277
log logr.Logger,
278278
) {
279+
if volume.Spec.ReplaceExisting {
280+
replaced, replaceErr := r.replacePhysicalContainerVolume(ctx, volume, data, log)
281+
if replaceErr != nil {
282+
log.Error(replaceErr, "Failed to replace existing runtime volume", "VolumeName", volume.Spec.VolumeName)
283+
data.conditionReason = apiv2.PhysicalContainerVolumeReasonReconciliationFailed
284+
data.failureMessage = fmt.Sprintf("Failed to replace existing runtime volume: %v", replaceErr)
285+
data.retryAfter = time.Now().Add(delayDurations[LongDelay].Duration)
286+
r.queuePhysicalContainerVolumeDataResult(volume, stateKey, data)
287+
return
288+
}
289+
if !replaced {
290+
r.queuePhysicalContainerVolumeDataResult(volume, stateKey, data)
291+
return
292+
}
293+
}
294+
279295
createErr := r.orchestrator.CreateVolume(ctx, containers.CreateVolumeOptions{
280296
Name: volume.Spec.VolumeName,
281297
Labels: physicalContainerVolumeCreationLabels(volume, log),
@@ -284,6 +300,43 @@ func (r *PhysicalContainerVolumeReconciler) createPhysicalContainerVolume(
284300
r.queuePhysicalContainerVolumeDataResult(volume, stateKey, data)
285301
}
286302

303+
func (r *PhysicalContainerVolumeReconciler) replacePhysicalContainerVolume(
304+
ctx context.Context,
305+
volume *apiv2.PhysicalContainerVolume,
306+
data *physicalContainerVolumeData,
307+
log logr.Logger,
308+
) (bool, error) {
309+
inspectedVolume, inspectErr := inspectPhysicalContainerVolume(ctx, r.orchestrator, volume.Spec.VolumeName)
310+
if errors.Is(inspectErr, containers.ErrNotFound) {
311+
return true, nil
312+
}
313+
if inspectErr != nil {
314+
return false, fmt.Errorf("inspect runtime volume %q: %w", volume.Spec.VolumeName, inspectErr)
315+
}
316+
if inspectedVolume.Name == "" {
317+
return false, fmt.Errorf("inspect runtime volume %q returned an empty name", volume.Spec.VolumeName)
318+
}
319+
if physicalContainerVolumeBelongsToResource(inspectedVolume, volume) {
320+
data.conditionReason = apiv2.PhysicalContainerVolumeReasonCreated
321+
data.volumeID = inspectedVolume.Name
322+
data.failureMessage = ""
323+
data.retryAfter = time.Time{}
324+
log.V(1).Info("Adopted runtime volume created by an earlier attempt", "VolumeID", inspectedVolume.Name)
325+
return false, nil
326+
}
327+
328+
if !r.removeRuntimeVolume(ctx, inspectedVolume.Name, log) {
329+
return false, fmt.Errorf("remove runtime volume %q", inspectedVolume.Name)
330+
}
331+
332+
log.V(1).Info(
333+
"Removed existing runtime volume before replacement",
334+
"VolumeID", inspectedVolume.Name,
335+
"VolumeName", inspectedVolume.Name,
336+
)
337+
return true, nil
338+
}
339+
287340
func (r *PhysicalContainerVolumeReconciler) applyPhysicalContainerVolumeCreateResult(
288341
ctx context.Context,
289342
volume *apiv2.PhysicalContainerVolume,
@@ -293,6 +346,7 @@ func (r *PhysicalContainerVolumeReconciler) applyPhysicalContainerVolumeCreateRe
293346
) {
294347
if createErr != nil {
295348
log.Error(createErr, "Failed to create runtime volume", "VolumeName", volume.Spec.VolumeName)
349+
data.failureMessage = fmt.Sprintf("Failed to create runtime volume: %v", createErr)
296350
}
297351

298352
inspectedVolume, inspectErr := inspectPhysicalContainerVolume(ctx, r.orchestrator, volume.Spec.VolumeName)
@@ -304,9 +358,17 @@ func (r *PhysicalContainerVolumeReconciler) applyPhysicalContainerVolumeCreateRe
304358
return
305359
}
306360
if inspectErr == nil {
307-
data.conditionReason = apiv2.PhysicalContainerVolumeReasonCreateFailed
308-
data.failureMessage = fmt.Sprintf("Runtime volume name %q is already in use.", volume.Spec.VolumeName)
309-
data.retryAfter = time.Time{}
361+
if volume.Spec.ReplaceExisting {
362+
data.conditionReason = apiv2.PhysicalContainerVolumeReasonReconciliationFailed
363+
if data.failureMessage == "" {
364+
data.failureMessage = fmt.Sprintf("Runtime volume name %q was claimed during replacement.", volume.Spec.VolumeName)
365+
}
366+
data.retryAfter = time.Now().Add(delayDurations[LongDelay].Duration)
367+
} else {
368+
data.conditionReason = apiv2.PhysicalContainerVolumeReasonCreateFailed
369+
data.failureMessage = fmt.Sprintf("Runtime volume name %q is already in use.", volume.Spec.VolumeName)
370+
data.retryAfter = time.Time{}
371+
}
310372
return
311373
}
312374

@@ -384,18 +446,29 @@ func handlePhysicalContainerVolumeRecoverableCreateFailed(
384446

385447
inspectedVolume, inspectErr := inspectPhysicalContainerVolume(ctx, reconciler.orchestrator, volume.Spec.VolumeName)
386448
if inspectErr == nil {
387-
if !physicalContainerVolumeBelongsToResource(inspectedVolume, volume) {
449+
belongsToResource := physicalContainerVolumeBelongsToResource(inspectedVolume, volume)
450+
if !belongsToResource && !volume.Spec.ReplaceExisting {
388451
data.conditionReason = apiv2.PhysicalContainerVolumeReasonCreateFailed
389452
data.failureMessage = fmt.Sprintf("Runtime volume name %q is already in use.", volume.Spec.VolumeName)
390453
data.retryAfter = time.Time{}
391-
} else {
392-
data.conditionReason = apiv2.PhysicalContainerVolumeReasonCreated
393-
data.volumeID = inspectedVolume.Name
394-
data.failureMessage = ""
395-
data.retryAfter = time.Time{}
454+
stateKey, _ := reconciler.volumeData.BorrowByNamespacedName(volume.NamespacedName())
455+
if reconciler.volumeData.Update(volume.NamespacedName(), stateKey, data) {
456+
return data.applyTo(volume)
457+
}
458+
return additionalReconciliationNeeded
459+
}
460+
if !belongsToResource {
461+
log.V(1).Info("Retrying runtime volume replacement", "VolumeID", inspectedVolume.Name, "VolumeName", inspectedVolume.Name)
462+
return reconciler.schedulePhysicalContainerVolumeCreate(volume, log)
396463
}
464+
465+
data.conditionReason = apiv2.PhysicalContainerVolumeReasonCreated
466+
data.volumeID = inspectedVolume.Name
467+
data.failureMessage = ""
468+
data.retryAfter = time.Time{}
397469
stateKey, _ := reconciler.volumeData.BorrowByNamespacedName(volume.NamespacedName())
398470
if reconciler.volumeData.Update(volume.NamespacedName(), stateKey, data) {
471+
log.V(1).Info("Adopted runtime volume created by an earlier attempt", "VolumeID", inspectedVolume.Name)
399472
return data.applyTo(volume) | applyReadyPhysicalContainerVolumeStatus(volume, inspectedVolume)
400473
}
401474
return additionalReconciliationNeeded
@@ -444,7 +517,7 @@ func (r *PhysicalContainerVolumeReconciler) handleDeletionRequest(ctx context.Co
444517
if volumeID == "" {
445518
volumeID = volume.Spec.VolumeID
446519
}
447-
if !volume.Spec.Persistent &&
520+
if volume.Spec.VolumeID == "" && !volume.Spec.Persistent &&
448521
volumeID == "" && data != nil &&
449522
data.conditionReason == apiv2.PhysicalContainerVolumeReasonReconciliationFailed {
450523
inspectedVolume, inspectErr := inspectPhysicalContainerVolume(ctx, r.orchestrator, volume.Spec.VolumeName)
@@ -456,7 +529,7 @@ func (r *PhysicalContainerVolumeReconciler) handleDeletionRequest(ctx context.Co
456529
}
457530
}
458531

459-
if !volume.Spec.Persistent && volumeID != "" && !r.removeRuntimeVolume(ctx, volumeID, log) {
532+
if volume.Spec.VolumeID == "" && !volume.Spec.Persistent && volumeID != "" && !r.removeRuntimeVolume(ctx, volumeID, log) {
460533
return additionalReconciliationNeeded
461534
}
462535

docs/v2-resource-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ This document tracks the intended direction for DCP V2 resources. The current V2
7272
- `PhysicalContainerImage` provides source image pull and build workflows.
7373
- `PhysicalContainer` creates or tracks one runtime container, reports runtime status and port mappings, and references a same-namespace `PhysicalContainerImage`.
7474
- `PhysicalContainerNetwork` creates or references one runtime container network and reports its observed identity, driver, and address allocations. Networks referenced by runtime ID are always retained. Created networks are retained when `persistent` is true; otherwise deletion enumerates running and stopped attachments, forcibly disconnects each container without removing it, and then removes the network. Name collisions are terminal unless `replaceExisting` is true, in which case the controller safely removes the specifically resolved network before creating its replacement. Runtime adapters classify their own built-in, non-removable networks, and replacement rejects them before disconnecting any attachments.
75-
- `PhysicalContainerVolume` creates or tracks one runtime container volume and reports its observed name, driver, scope, mount point, and creation time. Unless persistent, deletion removes the volume after it is no longer referenced by a container. It deliberately does not use force removal: Docker still rejects in-use volumes, while Podman force removal deletes the containers using the volume. Created volumes carry creator and persistence labels, and startup harvesting removes abandoned non-persistent volumes after abandoned containers.
75+
- `PhysicalContainerVolume` creates or references one runtime container volume and reports its observed name, driver, scope, mount point, and creation time. Volumes referenced by runtime ID are always retained. Created volumes are retained when `persistent` is true; otherwise deletion removes them after they are no longer referenced by a container. Name collisions are terminal unless `replaceExisting` is true, in which case the controller safely removes the specifically resolved volume before creating its replacement. Removal deliberately does not use force: Docker still rejects in-use volumes, while Podman force removal deletes the containers using the volume. Created volumes carry creator and persistence labels, and startup harvesting removes abandoned non-persistent volumes after abandoned containers.
7676
- The physical resources use in-memory progress data, standardized `Ready` conditions, and queued work where side effects can block.
7777

7878
## Follow-up roadmap

internal/testutil/ctrlutil/test_container_orchestrator.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ type TestContainerOrchestrator struct {
8383
createVolumeCalls map[string]int
8484
createVolumeBlocks map[string]chan struct{}
8585
createVolumePostErrors map[string][]error
86+
removeVolumeCalls map[string]int
87+
removeVolumeErrors map[string][]error
88+
removeVolumePostErrors map[string][]error
8689
inspectVolumeCalls map[string]int
8790
pullImageCalls map[string]int
8891
pullImageBlocks map[string]chan struct{}
@@ -212,6 +215,9 @@ func NewTestContainerOrchestrator(
212215
createVolumeCalls: map[string]int{},
213216
createVolumeBlocks: map[string]chan struct{}{},
214217
createVolumePostErrors: map[string][]error{},
218+
removeVolumeCalls: map[string]int{},
219+
removeVolumeErrors: map[string][]error{},
220+
removeVolumePostErrors: map[string][]error{},
215221
inspectVolumeCalls: map[string]int{},
216222
pullImageCalls: map[string]int{},
217223
pullImageBlocks: map[string]chan struct{}{},
@@ -790,6 +796,11 @@ func (to *TestContainerOrchestrator) CreateVolume(ctx context.Context, options c
790796
}
791797

792798
func (to *TestContainerOrchestrator) RemoveVolumes(ctx context.Context, options containers.RemoveVolumesOptions) ([]string, error) {
799+
removeErr, postRemoveErr := to.recordRemoveVolumesOperation(options.Volumes)
800+
if removeErr != nil {
801+
return nil, removeErr
802+
}
803+
793804
to.mutex.Lock()
794805
defer to.mutex.Unlock()
795806

@@ -838,7 +849,7 @@ func (to *TestContainerOrchestrator) RemoveVolumes(ctx context.Context, options
838849
err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all volumes were removed, expected %d but got %d", len(options.Volumes), len(removed))))
839850
}
840851

841-
return removed, err
852+
return removed, errors.Join(err, postRemoveErr)
842853
}
843854

844855
func (to *TestContainerOrchestrator) InspectVolumes(ctx context.Context, options containers.InspectVolumesOptions) ([]containers.InspectedVolume, error) {
@@ -1343,6 +1354,36 @@ func (to *TestContainerOrchestrator) InspectVolumeCallCount(volume string) int {
13431354
return to.inspectVolumeCalls[volume]
13441355
}
13451356

1357+
func (to *TestContainerOrchestrator) FailNextRemoveVolume(name string, removeErr error) {
1358+
if removeErr == nil {
1359+
removeErr = errors.New("simulated volume removal failure")
1360+
}
1361+
1362+
to.operationMutex.Lock()
1363+
defer to.operationMutex.Unlock()
1364+
1365+
to.removeVolumeErrors[name] = append(to.removeVolumeErrors[name], removeErr)
1366+
}
1367+
1368+
// FailNextRemoveVolumeAfterRemoval simulates a runtime removal whose result is uncertain to the caller.
1369+
func (to *TestContainerOrchestrator) FailNextRemoveVolumeAfterRemoval(name string, removeErr error) {
1370+
if removeErr == nil {
1371+
removeErr = errors.New("simulated lost volume removal response")
1372+
}
1373+
1374+
to.operationMutex.Lock()
1375+
defer to.operationMutex.Unlock()
1376+
1377+
to.removeVolumePostErrors[name] = append(to.removeVolumePostErrors[name], removeErr)
1378+
}
1379+
1380+
func (to *TestContainerOrchestrator) RemoveVolumeCallCount(name string) int {
1381+
to.operationMutex.Lock()
1382+
defer to.operationMutex.Unlock()
1383+
1384+
return to.removeVolumeCalls[name]
1385+
}
1386+
13461387
// FailNextCreateNetworkAfterCreation simulates a runtime create whose result is uncertain to the caller.
13471388
func (to *TestContainerOrchestrator) FailNextCreateNetworkAfterCreation(name string, createErr error) {
13481389
if createErr == nil {
@@ -1492,6 +1533,33 @@ func (to *TestContainerOrchestrator) recordInspectVolumesOperation(volumes []str
14921533
}
14931534
}
14941535

1536+
func (to *TestContainerOrchestrator) recordRemoveVolumesOperation(volumes []string) (error, error) {
1537+
to.operationMutex.Lock()
1538+
defer to.operationMutex.Unlock()
1539+
1540+
var removeErr error
1541+
var postRemoveErr error
1542+
for _, volume := range volumes {
1543+
to.removeVolumeCalls[volume]++
1544+
if len(to.removeVolumeErrors[volume]) > 0 {
1545+
removeErr = errors.Join(removeErr, to.removeVolumeErrors[volume][0])
1546+
to.removeVolumeErrors[volume] = to.removeVolumeErrors[volume][1:]
1547+
if len(to.removeVolumeErrors[volume]) == 0 {
1548+
delete(to.removeVolumeErrors, volume)
1549+
}
1550+
}
1551+
if len(to.removeVolumePostErrors[volume]) > 0 {
1552+
postRemoveErr = errors.Join(postRemoveErr, to.removeVolumePostErrors[volume][0])
1553+
to.removeVolumePostErrors[volume] = to.removeVolumePostErrors[volume][1:]
1554+
if len(to.removeVolumePostErrors[volume]) == 0 {
1555+
delete(to.removeVolumePostErrors, volume)
1556+
}
1557+
}
1558+
}
1559+
1560+
return removeErr, postRemoveErr
1561+
}
1562+
14951563
func (to *TestContainerOrchestrator) takeCreateNetworkPostError(name string) error {
14961564
to.operationMutex.Lock()
14971565
defer to.operationMutex.Unlock()

pkg/generated/openapi/zz_generated.openapi.go

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)