Skip to content

Commit aac6e20

Browse files
committed
feat: use generateName for leases to allow alias reuse after deletion
Lease names now use K8s generateName instead of fixed names, allowing custom lease IDs (aliases) to be reused after deletion. The user-provided alias is stored as a label and resolved transparently in Get/Delete/Update. Assisted-by: claude-opus-4.6 Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com>
1 parent 25d84c3 commit aac6e20

14 files changed

Lines changed: 486 additions & 132 deletions

File tree

controller/api/v1alpha1/lease_helpers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ func (l *Lease) ToProtobuf() *cpb.Lease {
323323
Name: l.Status.ExporterRef.Name,
324324
}))
325325
}
326+
if alias, ok := l.Labels[string(LeaseLabelName)]; ok {
327+
lease.Alias = ptr.To(alias)
328+
}
326329

327330
return &lease
328331
}

controller/api/v1alpha1/lease_helpers_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,4 +574,44 @@ var _ = Describe("Lease.ToProtobuf", func() {
574574

575575
Expect(pb.Tags).To(BeEmpty())
576576
})
577+
578+
It("should populate alias from lease-name label", func() {
579+
lease := &Lease{
580+
ObjectMeta: metav1.ObjectMeta{
581+
Name: "demo-x7k2q",
582+
Namespace: "default",
583+
Labels: map[string]string{
584+
string(LeaseLabelName): "demo",
585+
},
586+
},
587+
Spec: LeaseSpec{
588+
ClientRef: corev1.LocalObjectReference{Name: "test-client"},
589+
Duration: &metav1.Duration{Duration: time.Hour},
590+
Selector: metav1.LabelSelector{MatchLabels: map[string]string{"board": "rpi4"}},
591+
},
592+
}
593+
594+
pb := lease.ToProtobuf()
595+
596+
Expect(pb.Alias).NotTo(BeNil())
597+
Expect(*pb.Alias).To(Equal("demo"))
598+
})
599+
600+
It("should not set alias when lease-name label is absent", func() {
601+
lease := &Lease{
602+
ObjectMeta: metav1.ObjectMeta{
603+
Name: "lease-m9p3r",
604+
Namespace: "default",
605+
},
606+
Spec: LeaseSpec{
607+
ClientRef: corev1.LocalObjectReference{Name: "test-client"},
608+
Duration: &metav1.Duration{Duration: time.Hour},
609+
Selector: metav1.LabelSelector{MatchLabels: map[string]string{"board": "rpi4"}},
610+
},
611+
}
612+
613+
pb := lease.ToProtobuf()
614+
615+
Expect(pb.Alias).To(BeNil())
616+
})
577617
})

controller/api/v1alpha1/lease_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type LeaseLabel string
8282
const (
8383
LeaseLabelEnded LeaseLabel = "jumpstarter.dev/lease-ended"
8484
LeaseLabelEndedValue string = "true"
85+
LeaseLabelName LeaseLabel = "jumpstarter.dev/lease-name"
8586
LeaseTagMetadataPrefix string = "metadata.jumpstarter.dev/"
8687
)
8788

controller/internal/protocol/jumpstarter/client/v1/client.pb.go

Lines changed: 126 additions & 58 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

controller/internal/service/client/v1/client_service.go

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ package v1
1919
import (
2020
"context"
2121
"fmt"
22+
"sync"
2223

2324
"github.com/golang-jwt/jwt/v5"
24-
"github.com/google/uuid"
2525
jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1"
2626
"github.com/jumpstarter-dev/jumpstarter-controller/internal/oidc"
2727
cpb "github.com/jumpstarter-dev/jumpstarter-controller/internal/protocol/jumpstarter/client/v1"
@@ -43,8 +43,16 @@ type ClientService struct {
4343
cpb.UnimplementedClientServiceServer
4444
kclient.Client
4545
auth.Auth
46-
MaxTags int32
47-
Signer *oidc.Signer
46+
MaxTags int32
47+
Signer *oidc.Signer
48+
leaseAliasLocks sync.Map
49+
}
50+
51+
func (s *ClientService) lockAlias(alias string) *sync.Mutex {
52+
v, _ := s.leaseAliasLocks.LoadOrStore(alias, &sync.Mutex{})
53+
mu := v.(*sync.Mutex)
54+
mu.Lock()
55+
return mu
4856
}
4957

5058
func NewClientService(client kclient.Client, auth auth.Auth, maxTags int32, signer *oidc.Signer) *ClientService {
@@ -110,6 +118,77 @@ func (s *ClientService) ListExporters(
110118
return jexporters.ToProtobuf(), nil
111119
}
112120

121+
func (s *ClientService) resolveLeaseByNameOrAlias(ctx context.Context, namespace, nameOrAlias string) (*jumpstarterdevv1alpha1.Lease, error) {
122+
var jlease jumpstarterdevv1alpha1.Lease
123+
if err := s.Get(ctx, types.NamespacedName{Namespace: namespace, Name: nameOrAlias}, &jlease); err == nil {
124+
return &jlease, nil
125+
}
126+
127+
aliasReq, err := labels.NewRequirement(
128+
string(jumpstarterdevv1alpha1.LeaseLabelName),
129+
selection.Equals,
130+
[]string{nameOrAlias},
131+
)
132+
if err != nil {
133+
return nil, err
134+
}
135+
activeReq, err := labels.NewRequirement(
136+
string(jumpstarterdevv1alpha1.LeaseLabelEnded),
137+
selection.DoesNotExist,
138+
[]string{},
139+
)
140+
if err != nil {
141+
return nil, err
142+
}
143+
144+
var jleases jumpstarterdevv1alpha1.LeaseList
145+
if err := s.List(ctx, &jleases,
146+
kclient.InNamespace(namespace),
147+
kclient.MatchingLabelsSelector{Selector: labels.Everything().Add(*aliasReq).Add(*activeReq)},
148+
kclient.Limit(2),
149+
); err != nil {
150+
return nil, err
151+
}
152+
153+
switch len(jleases.Items) {
154+
case 0:
155+
return nil, status.Errorf(codes.NotFound, "lease %q not found", nameOrAlias)
156+
case 1:
157+
return &jleases.Items[0], nil
158+
default:
159+
return nil, status.Errorf(codes.FailedPrecondition, "multiple active leases match name %q, use exact lease name", nameOrAlias)
160+
}
161+
}
162+
163+
func (s *ClientService) hasActiveLeaseWithAlias(ctx context.Context, namespace, alias string) (bool, error) {
164+
aliasReq, err := labels.NewRequirement(
165+
string(jumpstarterdevv1alpha1.LeaseLabelName),
166+
selection.Equals,
167+
[]string{alias},
168+
)
169+
if err != nil {
170+
return false, err
171+
}
172+
activeReq, err := labels.NewRequirement(
173+
string(jumpstarterdevv1alpha1.LeaseLabelEnded),
174+
selection.DoesNotExist,
175+
[]string{},
176+
)
177+
if err != nil {
178+
return false, err
179+
}
180+
181+
var jleases jumpstarterdevv1alpha1.LeaseList
182+
if err := s.List(ctx, &jleases,
183+
kclient.InNamespace(namespace),
184+
kclient.MatchingLabelsSelector{Selector: labels.Everything().Add(*aliasReq).Add(*activeReq)},
185+
kclient.Limit(1),
186+
); err != nil {
187+
return false, err
188+
}
189+
return len(jleases.Items) > 0, nil
190+
}
191+
113192
func (s *ClientService) GetLease(ctx context.Context, req *cpb.GetLeaseRequest) (*cpb.Lease, error) {
114193
key, err := utils.ParseLeaseIdentifier(req.Name)
115194
if err != nil {
@@ -121,8 +200,8 @@ func (s *ClientService) GetLease(ctx context.Context, req *cpb.GetLeaseRequest)
121200
return nil, err
122201
}
123202

124-
var jlease jumpstarterdevv1alpha1.Lease
125-
if err := s.Get(ctx, *key, &jlease); err != nil {
203+
jlease, err := s.resolveLeaseByNameOrAlias(ctx, key.Namespace, key.Name)
204+
if err != nil {
126205
return nil, err
127206
}
128207

@@ -228,28 +307,38 @@ func (s *ClientService) CreateLease(ctx context.Context, req *cpb.CreateLeaseReq
228307
return nil, err
229308
}
230309

231-
// Use provided lease_id if specified, otherwise generate a UUIDv7
232-
name := req.LeaseId
233-
if name == "" {
234-
id, err := uuid.NewV7()
235-
if err != nil {
236-
return nil, err
237-
}
238-
name = id.String()
239-
}
240-
241310
jlease, err := jumpstarterdevv1alpha1.LeaseFromProtobuf(req.Lease, types.NamespacedName{
242311
Namespace: namespace,
243-
Name: name,
244312
}, corev1.LocalObjectReference{
245313
Name: jclient.Name,
246314
})
247315
if err != nil {
248316
return nil, err
249317
}
250318

251-
if err := s.Create(ctx, jlease); err != nil {
252-
return nil, err
319+
if req.LeaseId != "" {
320+
mu := s.lockAlias(req.LeaseId)
321+
defer mu.Unlock()
322+
exists, err := s.hasActiveLeaseWithAlias(ctx, namespace, req.LeaseId)
323+
if err != nil {
324+
return nil, err
325+
}
326+
if exists {
327+
return nil, status.Errorf(codes.AlreadyExists, "an active lease with name %q already exists", req.LeaseId)
328+
}
329+
jlease.GenerateName = req.LeaseId + "-"
330+
if jlease.Labels == nil {
331+
jlease.Labels = make(map[string]string)
332+
}
333+
jlease.Labels[string(jumpstarterdevv1alpha1.LeaseLabelName)] = req.LeaseId
334+
if err := s.Create(ctx, jlease); err != nil {
335+
return nil, err
336+
}
337+
} else {
338+
jlease.GenerateName = "lease-"
339+
if err := s.Create(ctx, jlease); err != nil {
340+
return nil, err
341+
}
253342
}
254343

255344
return jlease.ToProtobuf(), nil
@@ -280,10 +369,11 @@ func (s *ClientService) UpdateLease(ctx context.Context, req *cpb.UpdateLeaseReq
280369
return nil, err
281370
}
282371

283-
var jlease jumpstarterdevv1alpha1.Lease
284-
if err := s.Get(ctx, *key, &jlease); err != nil {
372+
resolved, err := s.resolveLeaseByNameOrAlias(ctx, key.Namespace, key.Name)
373+
if err != nil {
285374
return nil, err
286375
}
376+
jlease := *resolved
287377

288378
if jlease.Spec.ClientRef.Name != jclient.Name {
289379
return nil, fmt.Errorf("UpdateLease permission denied")
@@ -293,7 +383,8 @@ func (s *ClientService) UpdateLease(ctx context.Context, req *cpb.UpdateLeaseReq
293383

294384
// Only parse time fields from protobuf if any are being updated
295385
if req.Lease.BeginTime != nil || req.Lease.Duration != nil || req.Lease.EndTime != nil {
296-
desired, err := jumpstarterdevv1alpha1.LeaseFromProtobuf(req.Lease, *key,
386+
resolvedKey := types.NamespacedName{Namespace: jlease.Namespace, Name: jlease.Name}
387+
desired, err := jumpstarterdevv1alpha1.LeaseFromProtobuf(req.Lease, resolvedKey,
297388
corev1.LocalObjectReference{
298389
Name: jclient.Name,
299390
},
@@ -369,8 +460,8 @@ func (s *ClientService) DeleteLease(ctx context.Context, req *cpb.DeleteLeaseReq
369460
return nil, err
370461
}
371462

372-
var jlease jumpstarterdevv1alpha1.Lease
373-
if err := s.Get(ctx, *key, &jlease); err != nil {
463+
jlease, err := s.resolveLeaseByNameOrAlias(ctx, key.Namespace, key.Name)
464+
if err != nil {
374465
return nil, err
375466
}
376467

@@ -386,7 +477,7 @@ func (s *ClientService) DeleteLease(ctx context.Context, req *cpb.DeleteLeaseReq
386477

387478
jlease.Spec.Release = true
388479

389-
if err := s.Patch(ctx, &jlease, original); err != nil {
480+
if err := s.Patch(ctx, jlease, original); err != nil {
390481
return nil, err
391482
}
392483

e2e/test/hooks_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ var _ = Describe("Hooks E2E Tests", Label("hooks"), Ordered, func() {
260260
"--selector", "example.com/board=hooks", "j", "power", "on")
261261
Expect(err).NotTo(HaveOccurred(), out)
262262
Expect(out).To(ContainSubstring("BEFORE_HOOK:"))
263-
Expect(out).To(MatchRegexp(`lease=[0-9a-f-]+`))
263+
Expect(out).To(MatchRegexp(`lease=[a-z0-9-]+`))
264264
Expect(out).To(MatchRegexp(`client=`))
265265
})
266266
})

protocol/proto/jumpstarter/client/v1/client.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ message Lease {
141141
optional string exporter_name = 12 [(google.api.field_behavior) = IMMUTABLE];
142142
// The set of tags associated with the lease.
143143
map<string, string> tags = 13 [(google.api.field_behavior) = IMMUTABLE];
144+
// The user-provided alias for this lease (from lease_id at creation time).
145+
optional string alias = 14 [(google.api.field_behavior) = OUTPUT_ONLY];
144146
}
145147

146148
// Request to retrieve an exporter.

python/packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,8 @@ async def _shell_with_signal_handling( # noqa: C901
497497

498498
def _format_lease_display(lease) -> str:
499499
parts = []
500+
if getattr(lease, "alias", None):
501+
parts.append(f"alias={lease.alias}")
500502
if lease.exporter:
501503
parts.append(f"exporter={lease.exporter}")
502504
if lease.selector:

python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from jumpstarter_cli.shell import (
1616
_attempt_token_recovery,
17+
_format_lease_display,
1718
_monitor_token_expiry,
1819
_resolve_lease_from_active_async,
1920
_run_shell_with_lease_async,
@@ -977,3 +978,16 @@ async def fake_run_shell(*_args):
977978
):
978979
with pytest.raises((ExporterOfflineError, BaseExceptionGroup)):
979980
await _shell_with_signal_handling(config, None, None, None, timedelta(minutes=1), False, (), None)
981+
982+
983+
def test_format_lease_display_includes_alias():
984+
lease = _make_lease("demo-x7k2q")
985+
lease.alias = "demo"
986+
result = _format_lease_display(lease)
987+
assert "alias=demo" in result
988+
989+
990+
def test_format_lease_display_no_alias():
991+
lease = _make_lease("lease-m9p3r")
992+
result = _format_lease_display(lease)
993+
assert "alias" not in result

python/packages/jumpstarter-mcp/jumpstarter_mcp/server_test.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
_setup_logging,
2727
create_server,
2828
)
29-
from jumpstarter_mcp.tools.leases import _lease_status
29+
from jumpstarter_mcp.tools.leases import _lease_status, list_leases
3030

3131
# ---------------------------------------------------------------------------
3232
# Helpers / fixtures
@@ -270,6 +270,40 @@ def test_unknown_when_not_true(self):
270270
assert _lease_status(lease) == "unknown"
271271

272272

273+
# ---------------------------------------------------------------------------
274+
# list_leases (unit, mocked config)
275+
# ---------------------------------------------------------------------------
276+
277+
278+
@dataclass
279+
class FakeLeaseEntry:
280+
name: str
281+
client: str
282+
exporter: str
283+
selector: str
284+
conditions: list
285+
effective_begin_time: datetime | None = None
286+
effective_end_time: datetime | None = None
287+
duration: None = None
288+
alias: str | None = None
289+
290+
291+
@dataclass
292+
class FakeLeaseListResult:
293+
leases: list
294+
295+
296+
@pytest.mark.asyncio
297+
async def test_list_leases_includes_alias_when_present():
298+
lease_with = FakeLeaseEntry(name="demo-x7k2q", client="c", exporter="e", selector="s", conditions=[], alias="demo")
299+
lease_without = FakeLeaseEntry(name="lease-m9p3r", client="c", exporter="e", selector="s", conditions=[])
300+
config = AsyncMock()
301+
config.list_leases.return_value = FakeLeaseListResult(leases=[lease_with, lease_without])
302+
result = await list_leases(config)
303+
assert result[0]["alias"] == "demo"
304+
assert "alias" not in result[1]
305+
306+
273307
# ---------------------------------------------------------------------------
274308
# ConnectionManager (unit, no real connections)
275309
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)