feat: rollback orphaned TrueNAS resources on alloc_image failure (#250)

Steps 2-4 of alloc_image (extent create, target find/create, targetextent
create) are now wrapped in an eval. On any failure the rollback runs in
reverse order, best-effort: target removed if it has no remaining extents
(i.e. it was just created for this disk), extent deleted with force=true,
zvol deleted recursively. This prevents the orphaned-resources-on-failure
state that previously required manual cleanup.

Also restore alias ("Proxmox VM <vmid>") on target creation. The alias was
removed during SCALE 25.04 debugging as a red herring — the actual bug was
int() type coercion (#264). Alias is unique per-VM and _resolve_vm_target
always checks for an existing target by name before attempting a POST, so
there is no alias-collision risk in normal operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kevin Adams
2026-05-27 08:47:49 -04:00
co-authored by Claude Sonnet 4.6
parent 728cb136cb
commit eab964c5a2
+44 -18
View File
@@ -320,6 +320,7 @@ sub _resolve_vm_target {
my $groups = _portal_groups_for_new_target($scfg);
my $new = _api($scfg, 'POST', '/iscsi/target', {
name => $target_name,
alias => "Proxmox VM $vmid",
mode => 'ISCSI',
groups => $groups,
});
@@ -444,26 +445,51 @@ sub alloc_image {
sparse => JSON::true,
});
# 2. Create the iSCSI extent
my $extent = _api($scfg, 'POST', '/iscsi/extent', {
name => $name,
type => 'DISK',
disk => "zvol/$zvol",
ro => JSON::false,
});
# Steps 2-4 wrapped in eval so the zvol is cleaned up on any failure.
my ($extent, $target, $lun_id);
eval {
# 2. Create the iSCSI extent
$extent = _api($scfg, 'POST', '/iscsi/extent', {
name => $name,
type => 'DISK',
disk => "zvol/$zvol",
ro => JSON::false,
});
# 3. Associate extent with the per-VM target
my $target = _resolve_vm_target($scfg, $vmid);
my $lun_id = _next_lun_id($scfg, $target->{id});
# 3. Associate extent with the per-VM target
$target = _resolve_vm_target($scfg, $vmid);
$lun_id = _next_lun_id($scfg, $target->{id});
# int() forces fresh IV scalars — decode_json IDs can become dual-vars
# (string+int) after log interpolation or hash-key lookup, which causes
# encode_json to emit "7" instead of 7, rejected by Pydantic v2.
_api($scfg, 'POST', '/iscsi/targetextent', {
target => int($target->{id}),
extent => int($extent->{id}),
lunid => int($lun_id),
});
# int() forces fresh IV scalars — decode_json IDs can become dual-vars
# (string+int) after log interpolation or hash-key lookup, which causes
# encode_json to emit "7" instead of 7, rejected by Pydantic v2.
_api($scfg, 'POST', '/iscsi/targetextent', {
target => int($target->{id}),
extent => int($extent->{id}),
lunid => int($lun_id),
});
};
if (my $err = $@) {
_log('warning', "alloc_image: partial failure — rolling back: $err");
# Reverse order, best-effort. Target: only removed if it has no
# remaining extents (i.e. it was just created for this disk).
if ($target) {
eval { _maybe_cleanup_vm_target($scfg, $vmid, $target->{id}) };
}
if ($extent) {
eval { _api($scfg, 'DELETE', "/iscsi/extent/id/$extent->{id}",
{ force => JSON::true }) };
_log('warning', "alloc_image rollback: extent delete failed: $@") if $@;
}
my $zvol_id = uri_escape($zvol, "^A-Za-z0-9\\-_.~");
eval { _api($scfg, 'DELETE', "/pool/dataset/id/$zvol_id",
{ recursive => JSON::true }) };
_log('warning', "alloc_image rollback: zvol delete failed: $@") if $@;
die $err;
}
_reload_iscsi($scfg);