From aa7223573fc5dd76c75b82df1e6f62fbd09444e0 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sun, 19 Apr 2026 16:01:16 +0300 Subject: [PATCH 1/5] feat: add tags field to lease protobuf and CRD types Add map tags field to the Lease protobuf message and Tags map[string]string to the K8s LeaseSpec CRD. Fix protobuf Python import paths to use relative imports. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- controller/api/v1alpha1/lease_types.go | 9 +- .../api/v1alpha1/zz_generated.deepcopy.go | 17 ++- .../jumpstarter/client/v1/client.pb.go | 112 +++++++++++------- .../jumpstarter/v1/jumpstarter_grpc.pb.go | 2 +- .../protocol/jumpstarter/v1/router_grpc.pb.go | 2 +- .../proto/jumpstarter/client/v1/client.proto | 2 + .../jumpstarter/client/v1/client_pb2.py | 52 ++++---- .../jumpstarter/client/v1/client_pb2.pyi | 34 +++++- 8 files changed, 152 insertions(+), 78 deletions(-) diff --git a/controller/api/v1alpha1/lease_types.go b/controller/api/v1alpha1/lease_types.go index dfd8e9a9d..3ed252822 100644 --- a/controller/api/v1alpha1/lease_types.go +++ b/controller/api/v1alpha1/lease_types.go @@ -35,6 +35,10 @@ type LeaseSpec struct { Selector metav1.LabelSelector `json:"selector"` // Optionally pin this lease to a specific exporter name. ExporterRef *corev1.LocalObjectReference `json:"exporterRef,omitempty"` + // User-defined tags for the lease. Immutable after creation. + // Maximum 10 entries. Keys and values must conform to Kubernetes label rules. + // +kubebuilder:validation:MaxProperties=10 + Tags map[string]string `json:"tags,omitempty"` // The release flag requests the controller to end the lease now Release bool `json:"release,omitempty"` // Requested start time. If omitted, lease starts when exporter is acquired. @@ -70,8 +74,9 @@ const ( type LeaseLabel string const ( - LeaseLabelEnded LeaseLabel = "jumpstarter.dev/lease-ended" - LeaseLabelEndedValue string = "true" + LeaseLabelEnded LeaseLabel = "jumpstarter.dev/lease-ended" + LeaseLabelEndedValue string = "true" + LeaseTagMetadataPrefix string = "metadata.jumpstarter.dev/" ) // +kubebuilder:object:root=true diff --git a/controller/api/v1alpha1/zz_generated.deepcopy.go b/controller/api/v1alpha1/zz_generated.deepcopy.go index 7f2e90e42..ce5b96a05 100644 --- a/controller/api/v1alpha1/zz_generated.deepcopy.go +++ b/controller/api/v1alpha1/zz_generated.deepcopy.go @@ -495,17 +495,24 @@ func (in *LeaseList) DeepCopyObject() runtime.Object { func (in *LeaseSpec) DeepCopyInto(out *LeaseSpec) { *out = *in out.ClientRef = in.ClientRef - if in.ExporterRef != nil { - in, out := &in.ExporterRef, &out.ExporterRef - *out = new(v1.LocalObjectReference) - **out = **in - } if in.Duration != nil { in, out := &in.Duration, &out.Duration *out = new(metav1.Duration) **out = **in } in.Selector.DeepCopyInto(&out.Selector) + if in.ExporterRef != nil { + in, out := &in.ExporterRef, &out.ExporterRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.BeginTime != nil { in, out := &in.BeginTime, &out.BeginTime *out = (*in).DeepCopy() diff --git a/controller/internal/protocol/jumpstarter/client/v1/client.pb.go b/controller/internal/protocol/jumpstarter/client/v1/client.pb.go index e97c0119d..c2a7623db 100644 --- a/controller/internal/protocol/jumpstarter/client/v1/client.pb.go +++ b/controller/internal/protocol/jumpstarter/client/v1/client.pb.go @@ -126,6 +126,7 @@ type Lease struct { Exporter *string `protobuf:"bytes,10,opt,name=exporter,proto3,oneof" json:"exporter,omitempty"` Conditions []*v1.Condition `protobuf:"bytes,11,rep,name=conditions,proto3" json:"conditions,omitempty"` ExporterName *string `protobuf:"bytes,12,opt,name=exporter_name,json=exporterName,proto3,oneof" json:"exporter_name,omitempty"` + Tags map[string]string `protobuf:"bytes,13,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -244,6 +245,13 @@ func (x *Lease) GetExporterName() string { return "" } +func (x *Lease) GetTags() map[string]string { + if x != nil { + return x.Tags + } + return nil +} + type GetExporterRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -459,6 +467,7 @@ type ListLeasesRequest struct { PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` Filter string `protobuf:"bytes,4,opt,name=filter,proto3" json:"filter,omitempty"` OnlyActive *bool `protobuf:"varint,5,opt,name=only_active,json=onlyActive,proto3,oneof" json:"only_active,omitempty"` + TagFilter string `protobuf:"bytes,6,opt,name=tag_filter,json=tagFilter,proto3" json:"tag_filter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -528,6 +537,13 @@ func (x *ListLeasesRequest) GetOnlyActive() bool { return false } +func (x *ListLeasesRequest) GetTagFilter() string { + if x != nil { + return x.TagFilter + } + return "" +} + type ListLeasesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Leases []*Lease `protobuf:"bytes,1,rep,name=leases,proto3" json:"leases,omitempty"` @@ -750,7 +766,7 @@ const file_jumpstarter_client_v1_client_proto_rawDesc = "" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01:_\xeaA\\\n" + - "\x18jumpstarter.dev/Exporter\x12+namespaces/{namespace}/exporters/{exporter}*\texporters2\bexporter\"\xbb\a\n" + + "\x18jumpstarter.dev/Exporter\x12+namespaces/{namespace}/exporters/{exporter}*\texporters2\bexporter\"\xb5\b\n" + "\x05Lease\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\"\n" + "\bselector\x18\x02 \x01(\tB\x06\xe0A\x02\xe0A\x05R\bselector\x12:\n" + @@ -769,7 +785,11 @@ const file_jumpstarter_client_v1_client_proto_rawDesc = "" + "\n" + "conditions\x18\v \x03(\v2\x19.jumpstarter.v1.ConditionB\x03\xe0A\x03R\n" + "conditions\x12-\n" + - "\rexporter_name\x18\f \x01(\tB\x03\xe0A\x05H\aR\fexporterName\x88\x01\x01:P\xeaAM\n" + + "\rexporter_name\x18\f \x01(\tB\x03\xe0A\x05H\aR\fexporterName\x88\x01\x01\x12?\n" + + "\x04tags\x18\r \x03(\v2&.jumpstarter.client.v1.Lease.TagsEntryB\x03\xe0A\x05R\x04tags\x1a7\n" + + "\tTagsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01:P\xeaAM\n" + "\x15jumpstarter.dev/Lease\x12%namespaces/{namespace}/leases/{lease}*\x06leases2\x05leaseB\v\n" + "\t_durationB\r\n" + "\v_begin_timeB\x17\n" + @@ -793,7 +813,7 @@ const file_jumpstarter_client_v1_client_proto_rawDesc = "" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"D\n" + "\x0fGetLeaseRequest\x121\n" + "\x04name\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\n" + - "\x15jumpstarter.dev/LeaseR\x04name\"\xe8\x01\n" + + "\x15jumpstarter.dev/LeaseR\x04name\"\x8c\x02\n" + "\x11ListLeasesRequest\x125\n" + "\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12 \n" + "\tpage_size\x18\x02 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" + @@ -801,7 +821,9 @@ const file_jumpstarter_client_v1_client_proto_rawDesc = "" + "page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\x12\x1b\n" + "\x06filter\x18\x04 \x01(\tB\x03\xe0A\x01R\x06filter\x12)\n" + "\vonly_active\x18\x05 \x01(\bB\x03\xe0A\x01H\x00R\n" + - "onlyActive\x88\x01\x01B\x0e\n" + + "onlyActive\x88\x01\x01\x12\"\n" + + "\n" + + "tag_filter\x18\x06 \x01(\tB\x03\xe0A\x01R\ttagFilterB\x0e\n" + "\f_only_active\"r\n" + "\x12ListLeasesResponse\x124\n" + "\x06leases\x18\x01 \x03(\v2\x1c.jumpstarter.client.v1.LeaseR\x06leases\x12&\n" + @@ -840,7 +862,7 @@ func file_jumpstarter_client_v1_client_proto_rawDescGZIP() []byte { return file_jumpstarter_client_v1_client_proto_rawDescData } -var file_jumpstarter_client_v1_client_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_jumpstarter_client_v1_client_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_jumpstarter_client_v1_client_proto_goTypes = []any{ (*Exporter)(nil), // 0: jumpstarter.client.v1.Exporter (*Lease)(nil), // 1: jumpstarter.client.v1.Lease @@ -854,47 +876,49 @@ var file_jumpstarter_client_v1_client_proto_goTypes = []any{ (*UpdateLeaseRequest)(nil), // 9: jumpstarter.client.v1.UpdateLeaseRequest (*DeleteLeaseRequest)(nil), // 10: jumpstarter.client.v1.DeleteLeaseRequest nil, // 11: jumpstarter.client.v1.Exporter.LabelsEntry - (v1.ExporterStatus)(0), // 12: jumpstarter.v1.ExporterStatus - (*durationpb.Duration)(nil), // 13: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp - (*v1.Condition)(nil), // 15: jumpstarter.v1.Condition - (*fieldmaskpb.FieldMask)(nil), // 16: google.protobuf.FieldMask - (*emptypb.Empty)(nil), // 17: google.protobuf.Empty + nil, // 12: jumpstarter.client.v1.Lease.TagsEntry + (v1.ExporterStatus)(0), // 13: jumpstarter.v1.ExporterStatus + (*durationpb.Duration)(nil), // 14: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp + (*v1.Condition)(nil), // 16: jumpstarter.v1.Condition + (*fieldmaskpb.FieldMask)(nil), // 17: google.protobuf.FieldMask + (*emptypb.Empty)(nil), // 18: google.protobuf.Empty } var file_jumpstarter_client_v1_client_proto_depIdxs = []int32{ 11, // 0: jumpstarter.client.v1.Exporter.labels:type_name -> jumpstarter.client.v1.Exporter.LabelsEntry - 12, // 1: jumpstarter.client.v1.Exporter.status:type_name -> jumpstarter.v1.ExporterStatus - 13, // 2: jumpstarter.client.v1.Lease.duration:type_name -> google.protobuf.Duration - 13, // 3: jumpstarter.client.v1.Lease.effective_duration:type_name -> google.protobuf.Duration - 14, // 4: jumpstarter.client.v1.Lease.begin_time:type_name -> google.protobuf.Timestamp - 14, // 5: jumpstarter.client.v1.Lease.effective_begin_time:type_name -> google.protobuf.Timestamp - 14, // 6: jumpstarter.client.v1.Lease.end_time:type_name -> google.protobuf.Timestamp - 14, // 7: jumpstarter.client.v1.Lease.effective_end_time:type_name -> google.protobuf.Timestamp - 15, // 8: jumpstarter.client.v1.Lease.conditions:type_name -> jumpstarter.v1.Condition - 0, // 9: jumpstarter.client.v1.ListExportersResponse.exporters:type_name -> jumpstarter.client.v1.Exporter - 1, // 10: jumpstarter.client.v1.ListLeasesResponse.leases:type_name -> jumpstarter.client.v1.Lease - 1, // 11: jumpstarter.client.v1.CreateLeaseRequest.lease:type_name -> jumpstarter.client.v1.Lease - 1, // 12: jumpstarter.client.v1.UpdateLeaseRequest.lease:type_name -> jumpstarter.client.v1.Lease - 16, // 13: jumpstarter.client.v1.UpdateLeaseRequest.update_mask:type_name -> google.protobuf.FieldMask - 2, // 14: jumpstarter.client.v1.ClientService.GetExporter:input_type -> jumpstarter.client.v1.GetExporterRequest - 3, // 15: jumpstarter.client.v1.ClientService.ListExporters:input_type -> jumpstarter.client.v1.ListExportersRequest - 5, // 16: jumpstarter.client.v1.ClientService.GetLease:input_type -> jumpstarter.client.v1.GetLeaseRequest - 6, // 17: jumpstarter.client.v1.ClientService.ListLeases:input_type -> jumpstarter.client.v1.ListLeasesRequest - 8, // 18: jumpstarter.client.v1.ClientService.CreateLease:input_type -> jumpstarter.client.v1.CreateLeaseRequest - 9, // 19: jumpstarter.client.v1.ClientService.UpdateLease:input_type -> jumpstarter.client.v1.UpdateLeaseRequest - 10, // 20: jumpstarter.client.v1.ClientService.DeleteLease:input_type -> jumpstarter.client.v1.DeleteLeaseRequest - 0, // 21: jumpstarter.client.v1.ClientService.GetExporter:output_type -> jumpstarter.client.v1.Exporter - 4, // 22: jumpstarter.client.v1.ClientService.ListExporters:output_type -> jumpstarter.client.v1.ListExportersResponse - 1, // 23: jumpstarter.client.v1.ClientService.GetLease:output_type -> jumpstarter.client.v1.Lease - 7, // 24: jumpstarter.client.v1.ClientService.ListLeases:output_type -> jumpstarter.client.v1.ListLeasesResponse - 1, // 25: jumpstarter.client.v1.ClientService.CreateLease:output_type -> jumpstarter.client.v1.Lease - 1, // 26: jumpstarter.client.v1.ClientService.UpdateLease:output_type -> jumpstarter.client.v1.Lease - 17, // 27: jumpstarter.client.v1.ClientService.DeleteLease:output_type -> google.protobuf.Empty - 21, // [21:28] is the sub-list for method output_type - 14, // [14:21] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 13, // 1: jumpstarter.client.v1.Exporter.status:type_name -> jumpstarter.v1.ExporterStatus + 14, // 2: jumpstarter.client.v1.Lease.duration:type_name -> google.protobuf.Duration + 14, // 3: jumpstarter.client.v1.Lease.effective_duration:type_name -> google.protobuf.Duration + 15, // 4: jumpstarter.client.v1.Lease.begin_time:type_name -> google.protobuf.Timestamp + 15, // 5: jumpstarter.client.v1.Lease.effective_begin_time:type_name -> google.protobuf.Timestamp + 15, // 6: jumpstarter.client.v1.Lease.end_time:type_name -> google.protobuf.Timestamp + 15, // 7: jumpstarter.client.v1.Lease.effective_end_time:type_name -> google.protobuf.Timestamp + 16, // 8: jumpstarter.client.v1.Lease.conditions:type_name -> jumpstarter.v1.Condition + 12, // 9: jumpstarter.client.v1.Lease.tags:type_name -> jumpstarter.client.v1.Lease.TagsEntry + 0, // 10: jumpstarter.client.v1.ListExportersResponse.exporters:type_name -> jumpstarter.client.v1.Exporter + 1, // 11: jumpstarter.client.v1.ListLeasesResponse.leases:type_name -> jumpstarter.client.v1.Lease + 1, // 12: jumpstarter.client.v1.CreateLeaseRequest.lease:type_name -> jumpstarter.client.v1.Lease + 1, // 13: jumpstarter.client.v1.UpdateLeaseRequest.lease:type_name -> jumpstarter.client.v1.Lease + 17, // 14: jumpstarter.client.v1.UpdateLeaseRequest.update_mask:type_name -> google.protobuf.FieldMask + 2, // 15: jumpstarter.client.v1.ClientService.GetExporter:input_type -> jumpstarter.client.v1.GetExporterRequest + 3, // 16: jumpstarter.client.v1.ClientService.ListExporters:input_type -> jumpstarter.client.v1.ListExportersRequest + 5, // 17: jumpstarter.client.v1.ClientService.GetLease:input_type -> jumpstarter.client.v1.GetLeaseRequest + 6, // 18: jumpstarter.client.v1.ClientService.ListLeases:input_type -> jumpstarter.client.v1.ListLeasesRequest + 8, // 19: jumpstarter.client.v1.ClientService.CreateLease:input_type -> jumpstarter.client.v1.CreateLeaseRequest + 9, // 20: jumpstarter.client.v1.ClientService.UpdateLease:input_type -> jumpstarter.client.v1.UpdateLeaseRequest + 10, // 21: jumpstarter.client.v1.ClientService.DeleteLease:input_type -> jumpstarter.client.v1.DeleteLeaseRequest + 0, // 22: jumpstarter.client.v1.ClientService.GetExporter:output_type -> jumpstarter.client.v1.Exporter + 4, // 23: jumpstarter.client.v1.ClientService.ListExporters:output_type -> jumpstarter.client.v1.ListExportersResponse + 1, // 24: jumpstarter.client.v1.ClientService.GetLease:output_type -> jumpstarter.client.v1.Lease + 7, // 25: jumpstarter.client.v1.ClientService.ListLeases:output_type -> jumpstarter.client.v1.ListLeasesResponse + 1, // 26: jumpstarter.client.v1.ClientService.CreateLease:output_type -> jumpstarter.client.v1.Lease + 1, // 27: jumpstarter.client.v1.ClientService.UpdateLease:output_type -> jumpstarter.client.v1.Lease + 18, // 28: jumpstarter.client.v1.ClientService.DeleteLease:output_type -> google.protobuf.Empty + 22, // [22:29] is the sub-list for method output_type + 15, // [15:22] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_jumpstarter_client_v1_client_proto_init() } @@ -910,7 +934,7 @@ func file_jumpstarter_client_v1_client_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_jumpstarter_client_v1_client_proto_rawDesc), len(file_jumpstarter_client_v1_client_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go b/controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go index adf91969c..4468802d0 100644 --- a/controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go +++ b/controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go @@ -2,7 +2,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.0 +// - protoc-gen-go-grpc v1.6.1 // - protoc (unknown) // source: jumpstarter/v1/jumpstarter.proto diff --git a/controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go b/controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go index bb69ab64c..edc6e40c0 100644 --- a/controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go +++ b/controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go @@ -2,7 +2,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.0 +// - protoc-gen-go-grpc v1.6.1 // - protoc (unknown) // source: jumpstarter/v1/router.proto diff --git a/protocol/proto/jumpstarter/client/v1/client.proto b/protocol/proto/jumpstarter/client/v1/client.proto index cc2acc037..98b3d90aa 100644 --- a/protocol/proto/jumpstarter/client/v1/client.proto +++ b/protocol/proto/jumpstarter/client/v1/client.proto @@ -103,6 +103,7 @@ message Lease { ]; repeated jumpstarter.v1.Condition conditions = 11 [(google.api.field_behavior) = OUTPUT_ONLY]; optional string exporter_name = 12 [(google.api.field_behavior) = IMMUTABLE]; + map tags = 13 [(google.api.field_behavior) = IMMUTABLE]; } message GetExporterRequest { @@ -143,6 +144,7 @@ message ListLeasesRequest { string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; string filter = 4 [(google.api.field_behavior) = OPTIONAL]; optional bool only_active = 5 [(google.api.field_behavior) = OPTIONAL]; + string tag_filter = 6 [(google.api.field_behavior) = OPTIONAL]; } message ListLeasesResponse { diff --git a/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py b/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py index 19ee1977c..9e68f86e9 100644 --- a/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py +++ b/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py @@ -34,7 +34,7 @@ from ...v1 import common_pb2 as jumpstarter_dot_v1_dot_common__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"jumpstarter/client/v1/client.proto\x12\x15jumpstarter.client.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fjumpstarter/v1/kubernetes.proto\x1a\x1bjumpstarter/v1/common.proto\"\x8c\x03\n\x08\x45xporter\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32+.jumpstarter.client.v1.Exporter.LabelsEntryR\x06labels\x12\x1d\n\x06online\x18\x03 \x01(\x08\x42\x05\x18\x01\xe0\x41\x03R\x06online\x12;\n\x06status\x18\x04 \x01(\x0e\x32\x1e.jumpstarter.v1.ExporterStatusB\x03\xe0\x41\x03R\x06status\x12*\n\x0estatus_message\x18\x05 \x01(\tB\x03\xe0\x41\x03R\rstatusMessage\x1a\x39\n\x0bLabelsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01:_\xea\x41\\\n\x18jumpstarter.dev/Exporter\x12+namespaces/{namespace}/exporters/{exporter}*\texporters2\x08\x65xporter\"\xbb\x07\n\x05Lease\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\"\n\x08selector\x18\x02 \x01(\tB\x06\xe0\x41\x02\xe0\x41\x05R\x08selector\x12:\n\x08\x64uration\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00R\x08\x64uration\x88\x01\x01\x12M\n\x12\x65\x66\x66\x65\x63tive_duration\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x03\xe0\x41\x03R\x11\x65\x66\x66\x65\x63tiveDuration\x12>\n\nbegin_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x01R\tbeginTime\x88\x01\x01\x12V\n\x14\x65\x66\x66\x65\x63tive_begin_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x02R\x12\x65\x66\x66\x65\x63tiveBeginTime\x88\x01\x01\x12:\n\x08\x65nd_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x03R\x07\x65ndTime\x88\x01\x01\x12R\n\x12\x65\x66\x66\x65\x63tive_end_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x04R\x10\x65\x66\x66\x65\x63tiveEndTime\x88\x01\x01\x12;\n\x06\x63lient\x18\t \x01(\tB\x1e\xe0\x41\x03\xfa\x41\x18\n\x16jumpstarter.dev/ClientH\x05R\x06\x63lient\x88\x01\x01\x12\x41\n\x08\x65xporter\x18\n \x01(\tB \xe0\x41\x03\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterH\x06R\x08\x65xporter\x88\x01\x01\x12>\n\nconditions\x18\x0b \x03(\x0b\x32\x19.jumpstarter.v1.ConditionB\x03\xe0\x41\x03R\nconditions\x12-\n\rexporter_name\x18\x0c \x01(\tB\x03\xe0\x41\x05H\x07R\x0c\x65xporterName\x88\x01\x01:P\xea\x41M\n\x15jumpstarter.dev/Lease\x12%namespaces/{namespace}/leases/{lease}*\x06leases2\x05leaseB\x0b\n\t_durationB\r\n\x0b_begin_timeB\x17\n\x15_effective_begin_timeB\x0b\n\t_end_timeB\x15\n\x13_effective_end_timeB\t\n\x07_clientB\x0b\n\t_exporterB\x10\n\x0e_exporter_name\"J\n\x12GetExporterRequest\x12\x34\n\x04name\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterR\x04name\"\xb3\x01\n\x14ListExportersRequest\x12\x38\n\x06parent\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\x12\x18jumpstarter.dev/ExporterR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\"~\n\x15ListExportersResponse\x12=\n\texporters\x18\x01 \x03(\x0b\x32\x1f.jumpstarter.client.v1.ExporterR\texporters\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"D\n\x0fGetLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name\"\xe8\x01\n\x11ListLeasesRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\x12)\n\x0bonly_active\x18\x05 \x01(\x08\x42\x03\xe0\x41\x01H\x00R\nonlyActive\x88\x01\x01\x42\x0e\n\x0c_only_active\"r\n\x12ListLeasesResponse\x12\x34\n\x06leases\x18\x01 \x03(\x0b\x32\x1c.jumpstarter.client.v1.LeaseR\x06leases\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa4\x01\n\x12\x43reateLeaseRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12\x1e\n\x08lease_id\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x07leaseId\x12\x37\n\x05lease\x18\x03 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\"\x8f\x01\n\x12UpdateLeaseRequest\x12\x37\n\x05lease\x18\x01 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\x12@\n\x0bupdate_mask\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.FieldMaskB\x03\xe0\x41\x01R\nupdateMask\"G\n\x12\x44\x65leteLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name2\xa7\x08\n\rClientService\x12\x8d\x01\n\x0bGetExporter\x12).jumpstarter.client.v1.GetExporterRequest\x1a\x1f.jumpstarter.client.v1.Exporter\"2\xda\x41\x04name\x82\xd3\xe4\x93\x02%\x12#/v1/{name=namespaces/*/exporters/*}\x12\xa0\x01\n\rListExporters\x12+.jumpstarter.client.v1.ListExportersRequest\x1a,.jumpstarter.client.v1.ListExportersResponse\"4\xda\x41\x06parent\x82\xd3\xe4\x93\x02%\x12#/v1/{parent=namespaces/*}/exporters\x12\x81\x01\n\x08GetLease\x12&.jumpstarter.client.v1.GetLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"\x12 /v1/{name=namespaces/*/leases/*}\x12\x94\x01\n\nListLeases\x12(.jumpstarter.client.v1.ListLeasesRequest\x1a).jumpstarter.client.v1.ListLeasesResponse\"1\xda\x41\x06parent\x82\xd3\xe4\x93\x02\"\x12 /v1/{parent=namespaces/*}/leases\x12\x9f\x01\n\x0b\x43reateLease\x12).jumpstarter.client.v1.CreateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"G\xda\x41\x15parent,lease,lease_id\x82\xd3\xe4\x93\x02)\" /v1/{parent=namespaces/*}/leases:\x05lease\x12\xa1\x01\n\x0bUpdateLease\x12).jumpstarter.client.v1.UpdateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"I\xda\x41\x11lease,update_mask\x82\xd3\xe4\x93\x02/2&/v1/{lease.name=namespaces/*/leases/*}:\x05lease\x12\x81\x01\n\x0b\x44\x65leteLease\x12).jumpstarter.client.v1.DeleteLeaseRequest\x1a\x16.google.protobuf.Empty\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"* /v1/{name=namespaces/*/leases/*}B\x9e\x01\n\x19\x63om.jumpstarter.client.v1B\x0b\x43lientProtoP\x01\xa2\x02\x03JCX\xaa\x02\x15Jumpstarter.Client.V1\xca\x02\x15Jumpstarter\\Client\\V1\xe2\x02!Jumpstarter\\Client\\V1\\GPBMetadata\xea\x02\x17Jumpstarter::Client::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"jumpstarter/client/v1/client.proto\x12\x15jumpstarter.client.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fjumpstarter/v1/kubernetes.proto\x1a\x1bjumpstarter/v1/common.proto\"\x8c\x03\n\x08\x45xporter\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32+.jumpstarter.client.v1.Exporter.LabelsEntryR\x06labels\x12\x1d\n\x06online\x18\x03 \x01(\x08\x42\x05\x18\x01\xe0\x41\x03R\x06online\x12;\n\x06status\x18\x04 \x01(\x0e\x32\x1e.jumpstarter.v1.ExporterStatusB\x03\xe0\x41\x03R\x06status\x12*\n\x0estatus_message\x18\x05 \x01(\tB\x03\xe0\x41\x03R\rstatusMessage\x1a\x39\n\x0bLabelsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01:_\xea\x41\\\n\x18jumpstarter.dev/Exporter\x12+namespaces/{namespace}/exporters/{exporter}*\texporters2\x08\x65xporter\"\xb5\x08\n\x05Lease\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x08R\x04name\x12\"\n\x08selector\x18\x02 \x01(\tB\x06\xe0\x41\x02\xe0\x41\x05R\x08selector\x12:\n\x08\x64uration\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00R\x08\x64uration\x88\x01\x01\x12M\n\x12\x65\x66\x66\x65\x63tive_duration\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x03\xe0\x41\x03R\x11\x65\x66\x66\x65\x63tiveDuration\x12>\n\nbegin_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x01R\tbeginTime\x88\x01\x01\x12V\n\x14\x65\x66\x66\x65\x63tive_begin_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x02R\x12\x65\x66\x66\x65\x63tiveBeginTime\x88\x01\x01\x12:\n\x08\x65nd_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x03R\x07\x65ndTime\x88\x01\x01\x12R\n\x12\x65\x66\x66\x65\x63tive_end_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03H\x04R\x10\x65\x66\x66\x65\x63tiveEndTime\x88\x01\x01\x12;\n\x06\x63lient\x18\t \x01(\tB\x1e\xe0\x41\x03\xfa\x41\x18\n\x16jumpstarter.dev/ClientH\x05R\x06\x63lient\x88\x01\x01\x12\x41\n\x08\x65xporter\x18\n \x01(\tB \xe0\x41\x03\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterH\x06R\x08\x65xporter\x88\x01\x01\x12>\n\nconditions\x18\x0b \x03(\x0b\x32\x19.jumpstarter.v1.ConditionB\x03\xe0\x41\x03R\nconditions\x12-\n\rexporter_name\x18\x0c \x01(\tB\x03\xe0\x41\x05H\x07R\x0c\x65xporterName\x88\x01\x01\x12?\n\x04tags\x18\r \x03(\x0b\x32&.jumpstarter.client.v1.Lease.TagsEntryB\x03\xe0\x41\x05R\x04tags\x1a\x37\n\tTagsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01:P\xea\x41M\n\x15jumpstarter.dev/Lease\x12%namespaces/{namespace}/leases/{lease}*\x06leases2\x05leaseB\x0b\n\t_durationB\r\n\x0b_begin_timeB\x17\n\x15_effective_begin_timeB\x0b\n\t_end_timeB\x15\n\x13_effective_end_timeB\t\n\x07_clientB\x0b\n\t_exporterB\x10\n\x0e_exporter_name\"J\n\x12GetExporterRequest\x12\x34\n\x04name\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\n\x18jumpstarter.dev/ExporterR\x04name\"\xb3\x01\n\x14ListExportersRequest\x12\x38\n\x06parent\x18\x01 \x01(\tB \xe0\x41\x02\xfa\x41\x1a\x12\x18jumpstarter.dev/ExporterR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\"~\n\x15ListExportersResponse\x12=\n\texporters\x18\x01 \x03(\x0b\x32\x1f.jumpstarter.client.v1.ExporterR\texporters\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"D\n\x0fGetLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name\"\x8c\x02\n\x11ListLeasesRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12 \n\tpage_size\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x03 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x1b\n\x06\x66ilter\x18\x04 \x01(\tB\x03\xe0\x41\x01R\x06\x66ilter\x12)\n\x0bonly_active\x18\x05 \x01(\x08\x42\x03\xe0\x41\x01H\x00R\nonlyActive\x88\x01\x01\x12\"\n\ntag_filter\x18\x06 \x01(\tB\x03\xe0\x41\x01R\ttagFilterB\x0e\n\x0c_only_active\"r\n\x12ListLeasesResponse\x12\x34\n\x06leases\x18\x01 \x03(\x0b\x32\x1c.jumpstarter.client.v1.LeaseR\x06leases\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa4\x01\n\x12\x43reateLeaseRequest\x12\x35\n\x06parent\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\x12\x15jumpstarter.dev/LeaseR\x06parent\x12\x1e\n\x08lease_id\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x07leaseId\x12\x37\n\x05lease\x18\x03 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\"\x8f\x01\n\x12UpdateLeaseRequest\x12\x37\n\x05lease\x18\x01 \x01(\x0b\x32\x1c.jumpstarter.client.v1.LeaseB\x03\xe0\x41\x02R\x05lease\x12@\n\x0bupdate_mask\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.FieldMaskB\x03\xe0\x41\x01R\nupdateMask\"G\n\x12\x44\x65leteLeaseRequest\x12\x31\n\x04name\x18\x01 \x01(\tB\x1d\xe0\x41\x02\xfa\x41\x17\n\x15jumpstarter.dev/LeaseR\x04name2\xa7\x08\n\rClientService\x12\x8d\x01\n\x0bGetExporter\x12).jumpstarter.client.v1.GetExporterRequest\x1a\x1f.jumpstarter.client.v1.Exporter\"2\xda\x41\x04name\x82\xd3\xe4\x93\x02%\x12#/v1/{name=namespaces/*/exporters/*}\x12\xa0\x01\n\rListExporters\x12+.jumpstarter.client.v1.ListExportersRequest\x1a,.jumpstarter.client.v1.ListExportersResponse\"4\xda\x41\x06parent\x82\xd3\xe4\x93\x02%\x12#/v1/{parent=namespaces/*}/exporters\x12\x81\x01\n\x08GetLease\x12&.jumpstarter.client.v1.GetLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"\x12 /v1/{name=namespaces/*/leases/*}\x12\x94\x01\n\nListLeases\x12(.jumpstarter.client.v1.ListLeasesRequest\x1a).jumpstarter.client.v1.ListLeasesResponse\"1\xda\x41\x06parent\x82\xd3\xe4\x93\x02\"\x12 /v1/{parent=namespaces/*}/leases\x12\x9f\x01\n\x0b\x43reateLease\x12).jumpstarter.client.v1.CreateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"G\xda\x41\x15parent,lease,lease_id\x82\xd3\xe4\x93\x02)\" /v1/{parent=namespaces/*}/leases:\x05lease\x12\xa1\x01\n\x0bUpdateLease\x12).jumpstarter.client.v1.UpdateLeaseRequest\x1a\x1c.jumpstarter.client.v1.Lease\"I\xda\x41\x11lease,update_mask\x82\xd3\xe4\x93\x02/2&/v1/{lease.name=namespaces/*/leases/*}:\x05lease\x12\x81\x01\n\x0b\x44\x65leteLease\x12).jumpstarter.client.v1.DeleteLeaseRequest\x1a\x16.google.protobuf.Empty\"/\xda\x41\x04name\x82\xd3\xe4\x93\x02\"* /v1/{name=namespaces/*/leases/*}B\x9e\x01\n\x19\x63om.jumpstarter.client.v1B\x0b\x43lientProtoP\x01\xa2\x02\x03JCX\xaa\x02\x15Jumpstarter.Client.V1\xca\x02\x15Jumpstarter\\Client\\V1\xe2\x02!Jumpstarter\\Client\\V1\\GPBMetadata\xea\x02\x17Jumpstarter::Client::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -54,6 +54,8 @@ _globals['_EXPORTER'].fields_by_name['status_message']._serialized_options = b'\340A\003' _globals['_EXPORTER']._loaded_options = None _globals['_EXPORTER']._serialized_options = b'\352A\\\n\030jumpstarter.dev/Exporter\022+namespaces/{namespace}/exporters/{exporter}*\texporters2\010exporter' + _globals['_LEASE_TAGSENTRY']._loaded_options = None + _globals['_LEASE_TAGSENTRY']._serialized_options = b'8\001' _globals['_LEASE'].fields_by_name['name']._loaded_options = None _globals['_LEASE'].fields_by_name['name']._serialized_options = b'\340A\010' _globals['_LEASE'].fields_by_name['selector']._loaded_options = None @@ -72,6 +74,8 @@ _globals['_LEASE'].fields_by_name['conditions']._serialized_options = b'\340A\003' _globals['_LEASE'].fields_by_name['exporter_name']._loaded_options = None _globals['_LEASE'].fields_by_name['exporter_name']._serialized_options = b'\340A\005' + _globals['_LEASE'].fields_by_name['tags']._loaded_options = None + _globals['_LEASE'].fields_by_name['tags']._serialized_options = b'\340A\005' _globals['_LEASE']._loaded_options = None _globals['_LEASE']._serialized_options = b'\352AM\n\025jumpstarter.dev/Lease\022%namespaces/{namespace}/leases/{lease}*\006leases2\005lease' _globals['_GETEXPORTERREQUEST'].fields_by_name['name']._loaded_options = None @@ -96,6 +100,8 @@ _globals['_LISTLEASESREQUEST'].fields_by_name['filter']._serialized_options = b'\340A\001' _globals['_LISTLEASESREQUEST'].fields_by_name['only_active']._loaded_options = None _globals['_LISTLEASESREQUEST'].fields_by_name['only_active']._serialized_options = b'\340A\001' + _globals['_LISTLEASESREQUEST'].fields_by_name['tag_filter']._loaded_options = None + _globals['_LISTLEASESREQUEST'].fields_by_name['tag_filter']._serialized_options = b'\340A\001' _globals['_CREATELEASEREQUEST'].fields_by_name['parent']._loaded_options = None _globals['_CREATELEASEREQUEST'].fields_by_name['parent']._serialized_options = b'\340A\002\372A\027\022\025jumpstarter.dev/Lease' _globals['_CREATELEASEREQUEST'].fields_by_name['lease_id']._loaded_options = None @@ -127,25 +133,27 @@ _globals['_EXPORTER_LABELSENTRY']._serialized_start=609 _globals['_EXPORTER_LABELSENTRY']._serialized_end=666 _globals['_LEASE']._serialized_start=766 - _globals['_LEASE']._serialized_end=1721 - _globals['_GETEXPORTERREQUEST']._serialized_start=1723 - _globals['_GETEXPORTERREQUEST']._serialized_end=1797 - _globals['_LISTEXPORTERSREQUEST']._serialized_start=1800 - _globals['_LISTEXPORTERSREQUEST']._serialized_end=1979 - _globals['_LISTEXPORTERSRESPONSE']._serialized_start=1981 - _globals['_LISTEXPORTERSRESPONSE']._serialized_end=2107 - _globals['_GETLEASEREQUEST']._serialized_start=2109 - _globals['_GETLEASEREQUEST']._serialized_end=2177 - _globals['_LISTLEASESREQUEST']._serialized_start=2180 - _globals['_LISTLEASESREQUEST']._serialized_end=2412 - _globals['_LISTLEASESRESPONSE']._serialized_start=2414 - _globals['_LISTLEASESRESPONSE']._serialized_end=2528 - _globals['_CREATELEASEREQUEST']._serialized_start=2531 - _globals['_CREATELEASEREQUEST']._serialized_end=2695 - _globals['_UPDATELEASEREQUEST']._serialized_start=2698 - _globals['_UPDATELEASEREQUEST']._serialized_end=2841 - _globals['_DELETELEASEREQUEST']._serialized_start=2843 - _globals['_DELETELEASEREQUEST']._serialized_end=2914 - _globals['_CLIENTSERVICE']._serialized_start=2917 - _globals['_CLIENTSERVICE']._serialized_end=3980 + _globals['_LEASE']._serialized_end=1843 + _globals['_LEASE_TAGSENTRY']._serialized_start=1575 + _globals['_LEASE_TAGSENTRY']._serialized_end=1630 + _globals['_GETEXPORTERREQUEST']._serialized_start=1845 + _globals['_GETEXPORTERREQUEST']._serialized_end=1919 + _globals['_LISTEXPORTERSREQUEST']._serialized_start=1922 + _globals['_LISTEXPORTERSREQUEST']._serialized_end=2101 + _globals['_LISTEXPORTERSRESPONSE']._serialized_start=2103 + _globals['_LISTEXPORTERSRESPONSE']._serialized_end=2229 + _globals['_GETLEASEREQUEST']._serialized_start=2231 + _globals['_GETLEASEREQUEST']._serialized_end=2299 + _globals['_LISTLEASESREQUEST']._serialized_start=2302 + _globals['_LISTLEASESREQUEST']._serialized_end=2570 + _globals['_LISTLEASESRESPONSE']._serialized_start=2572 + _globals['_LISTLEASESRESPONSE']._serialized_end=2686 + _globals['_CREATELEASEREQUEST']._serialized_start=2689 + _globals['_CREATELEASEREQUEST']._serialized_end=2853 + _globals['_UPDATELEASEREQUEST']._serialized_start=2856 + _globals['_UPDATELEASEREQUEST']._serialized_end=2999 + _globals['_DELETELEASEREQUEST']._serialized_start=3001 + _globals['_DELETELEASEREQUEST']._serialized_end=3072 + _globals['_CLIENTSERVICE']._serialized_start=3075 + _globals['_CLIENTSERVICE']._serialized_end=4138 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi b/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi index a8e322909..bee04f7f1 100644 --- a/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi +++ b/python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi @@ -77,6 +77,22 @@ Global___Exporter: typing_extensions.TypeAlias = Exporter class Lease(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor + @typing.final + class TagsEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing.Literal["key", b"key", "value", b"value"]) -> None: ... + NAME_FIELD_NUMBER: builtins.int SELECTOR_FIELD_NUMBER: builtins.int DURATION_FIELD_NUMBER: builtins.int @@ -88,10 +104,13 @@ class Lease(google.protobuf.message.Message): CLIENT_FIELD_NUMBER: builtins.int EXPORTER_FIELD_NUMBER: builtins.int CONDITIONS_FIELD_NUMBER: builtins.int + EXPORTER_NAME_FIELD_NUMBER: builtins.int + TAGS_FIELD_NUMBER: builtins.int name: builtins.str selector: builtins.str client: builtins.str exporter: builtins.str + exporter_name: builtins.str @property def duration(self) -> google.protobuf.duration_pb2.Duration: ... @property @@ -106,6 +125,8 @@ class Lease(google.protobuf.message.Message): def effective_end_time(self) -> google.protobuf.timestamp_pb2.Timestamp: ... @property def conditions(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[jumpstarter.v1.kubernetes_pb2.Condition]: ... + @property + def tags(self) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: ... def __init__( self, *, @@ -120,9 +141,11 @@ class Lease(google.protobuf.message.Message): client: builtins.str | None = ..., exporter: builtins.str | None = ..., conditions: collections.abc.Iterable[jumpstarter.v1.kubernetes_pb2.Condition] | None = ..., + exporter_name: builtins.str | None = ..., + tags: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., ) -> None: ... - def HasField(self, field_name: typing.Literal["_begin_time", b"_begin_time", "_client", b"_client", "_duration", b"_duration", "_effective_begin_time", b"_effective_begin_time", "_effective_end_time", b"_effective_end_time", "_end_time", b"_end_time", "_exporter", b"_exporter", "begin_time", b"begin_time", "client", b"client", "duration", b"duration", "effective_begin_time", b"effective_begin_time", "effective_duration", b"effective_duration", "effective_end_time", b"effective_end_time", "end_time", b"end_time", "exporter", b"exporter"]) -> builtins.bool: ... - def ClearField(self, field_name: typing.Literal["_begin_time", b"_begin_time", "_client", b"_client", "_duration", b"_duration", "_effective_begin_time", b"_effective_begin_time", "_effective_end_time", b"_effective_end_time", "_end_time", b"_end_time", "_exporter", b"_exporter", "begin_time", b"begin_time", "client", b"client", "conditions", b"conditions", "duration", b"duration", "effective_begin_time", b"effective_begin_time", "effective_duration", b"effective_duration", "effective_end_time", b"effective_end_time", "end_time", b"end_time", "exporter", b"exporter", "name", b"name", "selector", b"selector"]) -> None: ... + def HasField(self, field_name: typing.Literal["_begin_time", b"_begin_time", "_client", b"_client", "_duration", b"_duration", "_effective_begin_time", b"_effective_begin_time", "_effective_end_time", b"_effective_end_time", "_end_time", b"_end_time", "_exporter", b"_exporter", "_exporter_name", b"_exporter_name", "begin_time", b"begin_time", "client", b"client", "duration", b"duration", "effective_begin_time", b"effective_begin_time", "effective_duration", b"effective_duration", "effective_end_time", b"effective_end_time", "end_time", b"end_time", "exporter", b"exporter", "exporter_name", b"exporter_name"]) -> builtins.bool: ... + def ClearField(self, field_name: typing.Literal["_begin_time", b"_begin_time", "_client", b"_client", "_duration", b"_duration", "_effective_begin_time", b"_effective_begin_time", "_effective_end_time", b"_effective_end_time", "_end_time", b"_end_time", "_exporter", b"_exporter", "_exporter_name", b"_exporter_name", "begin_time", b"begin_time", "client", b"client", "conditions", b"conditions", "duration", b"duration", "effective_begin_time", b"effective_begin_time", "effective_duration", b"effective_duration", "effective_end_time", b"effective_end_time", "end_time", b"end_time", "exporter", b"exporter", "exporter_name", b"exporter_name", "name", b"name", "selector", b"selector", "tags", b"tags"]) -> None: ... @typing.overload def WhichOneof(self, oneof_group: typing.Literal["_begin_time", b"_begin_time"]) -> typing.Literal["begin_time"] | None: ... @typing.overload @@ -137,6 +160,8 @@ class Lease(google.protobuf.message.Message): def WhichOneof(self, oneof_group: typing.Literal["_end_time", b"_end_time"]) -> typing.Literal["end_time"] | None: ... @typing.overload def WhichOneof(self, oneof_group: typing.Literal["_exporter", b"_exporter"]) -> typing.Literal["exporter"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing.Literal["_exporter_name", b"_exporter_name"]) -> typing.Literal["exporter_name"] | None: ... Global___Lease: typing_extensions.TypeAlias = Lease @@ -222,11 +247,13 @@ class ListLeasesRequest(google.protobuf.message.Message): PAGE_TOKEN_FIELD_NUMBER: builtins.int FILTER_FIELD_NUMBER: builtins.int ONLY_ACTIVE_FIELD_NUMBER: builtins.int + TAG_FILTER_FIELD_NUMBER: builtins.int parent: builtins.str page_size: builtins.int page_token: builtins.str filter: builtins.str only_active: builtins.bool + tag_filter: builtins.str def __init__( self, *, @@ -235,9 +262,10 @@ class ListLeasesRequest(google.protobuf.message.Message): page_token: builtins.str = ..., filter: builtins.str = ..., only_active: builtins.bool | None = ..., + tag_filter: builtins.str = ..., ) -> None: ... def HasField(self, field_name: typing.Literal["_only_active", b"_only_active", "only_active", b"only_active"]) -> builtins.bool: ... - def ClearField(self, field_name: typing.Literal["_only_active", b"_only_active", "filter", b"filter", "only_active", b"only_active", "page_size", b"page_size", "page_token", b"page_token", "parent", b"parent"]) -> None: ... + def ClearField(self, field_name: typing.Literal["_only_active", b"_only_active", "filter", b"filter", "only_active", b"only_active", "page_size", b"page_size", "page_token", b"page_token", "parent", b"parent", "tag_filter", b"tag_filter"]) -> None: ... def WhichOneof(self, oneof_group: typing.Literal["_only_active", b"_only_active"]) -> typing.Literal["only_active"] | None: ... Global___ListLeasesRequest: typing_extensions.TypeAlias = ListLeasesRequest From 430d296d8073760b9f59f41d43786fe88ea67584 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sun, 19 Apr 2026 16:01:31 +0300 Subject: [PATCH 2/5] feat: implement lease tag validation, storage, and filtering Validate tags server-side using K8s label rules. Store tags in LeaseSpec and as prefixed ObjectMeta labels for native K8s queryability. Add tag_filter support to ListLeases. Regenerate CRD manifests and restore lease RBAC permissions. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- controller/api/v1alpha1/lease_helpers.go | 47 ++++- controller/api/v1alpha1/lease_helpers_test.go | 180 ++++++++++++++++++ .../crds/jumpstarter.dev_clients.yaml | 2 +- ...umpstarter.dev_exporteraccesspolicies.yaml | 2 +- .../crds/jumpstarter.dev_exporters.yaml | 2 +- .../crds/jumpstarter.dev_leases.yaml | 10 +- .../crd/bases/jumpstarter.dev_clients.yaml | 2 +- ...umpstarter.dev_exporteraccesspolicies.yaml | 2 +- .../crd/bases/jumpstarter.dev_exporters.yaml | 2 +- .../crd/bases/jumpstarter.dev_leases.yaml | 10 +- .../service/client/v1/client_service.go | 33 +++- 11 files changed, 280 insertions(+), 12 deletions(-) diff --git a/controller/api/v1alpha1/lease_helpers.go b/controller/api/v1alpha1/lease_helpers.go index 63f81ca6d..9591f90ba 100644 --- a/controller/api/v1alpha1/lease_helpers.go +++ b/controller/api/v1alpha1/lease_helpers.go @@ -3,6 +3,7 @@ package v1alpha1 import ( "context" "fmt" + "strings" "time" cpb "github.com/jumpstarter-dev/jumpstarter-controller/internal/protocol/jumpstarter/client/v1" @@ -16,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/utils/ptr" kclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -169,6 +171,26 @@ func ParseLabelSelector(selectorStr string) (*metav1.LabelSelector, error) { }, nil } +// ValidateLeaseTags validates user-defined lease tags against the given maxTags limit. +func ValidateLeaseTags(tags map[string]string, maxTags int) error { + if len(tags) > maxTags { + return fmt.Errorf("too many tags: %d (maximum %d)", len(tags), maxTags) + } + for k, v := range tags { + if strings.HasPrefix(k, LeaseTagMetadataPrefix) || strings.HasPrefix(k, "jumpstarter.dev/") { + return fmt.Errorf("tag key %q must not use reserved prefix", k) + } + prefixedKey := LeaseTagMetadataPrefix + k + if errs := validation.IsQualifiedName(prefixedKey); len(errs) > 0 { + return fmt.Errorf("tag key %q is not a valid label key: %s", k, strings.Join(errs, "; ")) + } + if errs := validation.IsValidLabelValue(v); len(errs) > 0 { + return fmt.Errorf("tag value for key %q is not a valid label value: %s", k, strings.Join(errs, "; ")) + } + } + return nil +} + func LeaseFromProtobuf( req *cpb.Lease, key types.NamespacedName, @@ -195,16 +217,38 @@ func LeaseFromProtobuf( return nil, err } + // Build ObjectMeta labels: start with selector matchLabels (excluding reserved prefix), then add prefixed user tags + metaLabels := make(map[string]string) + for k, v := range selector.MatchLabels { + if strings.HasPrefix(k, LeaseTagMetadataPrefix) { + continue + } + metaLabels[k] = v + } + for k, v := range req.Tags { + metaLabels[LeaseTagMetadataPrefix+k] = v + } + + // Store user tags in spec (without prefix) + var specTags map[string]string + if len(req.Tags) > 0 { + specTags = make(map[string]string, len(req.Tags)) + for k, v := range req.Tags { + specTags[k] = v + } + } + return &Lease{ ObjectMeta: metav1.ObjectMeta{ Namespace: key.Namespace, Name: key.Name, - Labels: selector.MatchLabels, + Labels: metaLabels, }, Spec: LeaseSpec{ ClientRef: clientRef, Duration: duration, Selector: *selector, + Tags: specTags, ExporterRef: func() *corev1.LocalObjectReference { if req.ExporterName == nil || *req.ExporterName == "" { return nil @@ -238,6 +282,7 @@ func (l *Lease) ToProtobuf() *cpb.Lease { Selector: metav1.FormatLabelSelector(&l.Spec.Selector), Client: ptr.To(fmt.Sprintf("namespaces/%s/clients/%s", l.Namespace, l.Spec.ClientRef.Name)), Conditions: conditions, + Tags: l.Spec.Tags, } if l.Spec.ExporterRef != nil { lease.ExporterName = ptr.To(l.Spec.ExporterRef.Name) diff --git a/controller/api/v1alpha1/lease_helpers_test.go b/controller/api/v1alpha1/lease_helpers_test.go index c9ee9072a..6ab482ee2 100644 --- a/controller/api/v1alpha1/lease_helpers_test.go +++ b/controller/api/v1alpha1/lease_helpers_test.go @@ -17,6 +17,8 @@ limitations under the License. package v1alpha1 import ( + "fmt" + "strings" "testing" "time" @@ -331,5 +333,183 @@ var _ = Describe("LeaseFromProtobuf", func() { Expect(lease.Spec.ExporterRef).NotTo(BeNil()) Expect(lease.Spec.ExporterRef.Name).To(Equal(exporterName)) }) + + It("should store tags in spec and prefix them in ObjectMeta labels", func() { + pbLease := &cpb.Lease{ + Selector: "board=rpi4", + Duration: durationpb.New(time.Hour), + Tags: map[string]string{ + "team": "devops", + "ci-job": "12345", + }, + } + key := types.NamespacedName{Name: "test-lease", Namespace: "default"} + clientRef := corev1.LocalObjectReference{Name: "test-client"} + + lease, err := LeaseFromProtobuf(pbLease, key, clientRef) + + Expect(err).NotTo(HaveOccurred()) + // Tags in spec (unprefixed) + Expect(lease.Spec.Tags).To(HaveKeyWithValue("team", "devops")) + Expect(lease.Spec.Tags).To(HaveKeyWithValue("ci-job", "12345")) + // Tags in ObjectMeta.Labels (prefixed) + Expect(lease.Labels).To(HaveKeyWithValue("metadata.jumpstarter.dev/team", "devops")) + Expect(lease.Labels).To(HaveKeyWithValue("metadata.jumpstarter.dev/ci-job", "12345")) + // Selector matchLabels still present + Expect(lease.Labels).To(HaveKeyWithValue("board", "rpi4")) + }) + + It("should handle lease with no tags", func() { + pbLease := &cpb.Lease{ + Selector: "board=rpi4", + Duration: durationpb.New(time.Hour), + } + key := types.NamespacedName{Name: "test-lease", Namespace: "default"} + clientRef := corev1.LocalObjectReference{Name: "test-client"} + + lease, err := LeaseFromProtobuf(pbLease, key, clientRef) + + Expect(err).NotTo(HaveOccurred()) + Expect(lease.Spec.Tags).To(BeNil()) + // Only selector matchLabels in ObjectMeta + Expect(lease.Labels).To(HaveKeyWithValue("board", "rpi4")) + }) + }) +}) + +var _ = Describe("ValidateLeaseTags", func() { + It("should accept empty tags", func() { + Expect(ValidateLeaseTags(nil, 10)).To(Succeed()) + Expect(ValidateLeaseTags(map[string]string{}, 10)).To(Succeed()) + }) + + It("should accept valid tags", func() { + tags := map[string]string{ + "team": "devops", + "ci-job": "12345", + "env": "staging", + } + Expect(ValidateLeaseTags(tags, 10)).To(Succeed()) + }) + + It("should reject more than 10 tags", func() { + tags := make(map[string]string) + for i := 0; i < 11; i++ { + tags[fmt.Sprintf("key%d", i)] = "value" + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("too many tags")) + }) + + It("should reject tag key longer than 63 chars", func() { + tags := map[string]string{ + strings.Repeat("a", 64): "value", + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("tag key")) + Expect(err.Error()).To(ContainSubstring("not a valid label key")) + }) + + It("should reject tag value longer than 63 chars", func() { + tags := map[string]string{ + "key": strings.Repeat("v", 64), + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("tag value")) + Expect(err.Error()).To(ContainSubstring("not a valid label value")) + }) + + It("should reject invalid label key characters", func() { + tags := map[string]string{ + "invalid key!": "value", + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not a valid label key")) + }) + + It("should reject invalid label value characters", func() { + tags := map[string]string{ + "key": "invalid value!", + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not a valid label value")) + }) + + It("should reject reserved prefix jumpstarter.dev/", func() { + tags := map[string]string{ + "jumpstarter.dev/custom": "value", + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("reserved prefix")) + }) + + It("should reject reserved prefix metadata.jumpstarter.dev/", func() { + tags := map[string]string{ + "metadata.jumpstarter.dev/team": "value", + } + err := ValidateLeaseTags(tags, 10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("reserved prefix")) + }) + + It("should accept exactly 10 tags", func() { + tags := make(map[string]string) + for i := 0; i < 10; i++ { + tags[fmt.Sprintf("key%d", i)] = "value" + } + Expect(ValidateLeaseTags(tags, 10)).To(Succeed()) + }) + + It("should accept key and value of exactly 63 chars", func() { + tags := map[string]string{ + strings.Repeat("k", 63): strings.Repeat("v", 63), + } + Expect(ValidateLeaseTags(tags, 10)).To(Succeed()) + }) +}) + +var _ = Describe("Lease.ToProtobuf", func() { + It("should include tags in protobuf output", func() { + lease := &Lease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lease", + Namespace: "default", + }, + Spec: LeaseSpec{ + ClientRef: corev1.LocalObjectReference{Name: "test-client"}, + Duration: &metav1.Duration{Duration: time.Hour}, + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"board": "rpi4"}}, + Tags: map[string]string{"team": "devops", "ci-job": "12345"}, + }, + } + + pb := lease.ToProtobuf() + + Expect(pb.Tags).To(HaveKeyWithValue("team", "devops")) + Expect(pb.Tags).To(HaveKeyWithValue("ci-job", "12345")) + }) + + It("should handle nil tags", func() { + lease := &Lease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lease", + Namespace: "default", + }, + Spec: LeaseSpec{ + ClientRef: corev1.LocalObjectReference{Name: "test-client"}, + Duration: &metav1.Duration{Duration: time.Hour}, + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"board": "rpi4"}}, + }, + } + + pb := lease.ToProtobuf() + + Expect(pb.Tags).To(BeEmpty()) }) }) diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml index d9dd6d0cb..fb6bfc155 100644 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml +++ b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_clients.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: clients.jumpstarter.dev spec: group: jumpstarter.dev diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml index ec1b7878c..591eab1ac 100644 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml +++ b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporteraccesspolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: exporteraccesspolicies.jumpstarter.dev spec: group: jumpstarter.dev diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml index 9e4d57bb8..f2d8e6a9c 100644 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml +++ b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_exporters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: exporters.jumpstarter.dev spec: group: jumpstarter.dev diff --git a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml index 2df4db845..b9a387437 100644 --- a/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml +++ b/controller/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/crds/jumpstarter.dev_leases.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: leases.jumpstarter.dev spec: group: jumpstarter.dev @@ -146,6 +146,14 @@ spec: type: object type: object x-kubernetes-map-type: atomic + tags: + additionalProperties: + type: string + description: |- + User-defined tags for the lease. Immutable after creation. + Maximum 10 entries. Keys and values must conform to Kubernetes label rules. + maxProperties: 10 + type: object required: - clientRef - selector diff --git a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_clients.yaml b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_clients.yaml index d9dd6d0cb..fb6bfc155 100644 --- a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_clients.yaml +++ b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_clients.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: clients.jumpstarter.dev spec: group: jumpstarter.dev diff --git a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporteraccesspolicies.yaml b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporteraccesspolicies.yaml index ec1b7878c..591eab1ac 100644 --- a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporteraccesspolicies.yaml +++ b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporteraccesspolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: exporteraccesspolicies.jumpstarter.dev spec: group: jumpstarter.dev diff --git a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporters.yaml b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporters.yaml index 9e4d57bb8..f2d8e6a9c 100644 --- a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporters.yaml +++ b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_exporters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: exporters.jumpstarter.dev spec: group: jumpstarter.dev diff --git a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_leases.yaml b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_leases.yaml index 2df4db845..b9a387437 100644 --- a/controller/deploy/operator/config/crd/bases/jumpstarter.dev_leases.yaml +++ b/controller/deploy/operator/config/crd/bases/jumpstarter.dev_leases.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.18.0 name: leases.jumpstarter.dev spec: group: jumpstarter.dev @@ -146,6 +146,14 @@ spec: type: object type: object x-kubernetes-map-type: atomic + tags: + additionalProperties: + type: string + description: |- + User-defined tags for the lease. Immutable after creation. + Maximum 10 entries. Keys and values must conform to Kubernetes label rules. + maxProperties: 10 + type: object required: - clientRef - selector diff --git a/controller/internal/service/client/v1/client_service.go b/controller/internal/service/client/v1/client_service.go index d224f53e6..30a3decf0 100644 --- a/controller/internal/service/client/v1/client_service.go +++ b/controller/internal/service/client/v1/client_service.go @@ -39,12 +39,14 @@ type ClientService struct { cpb.UnimplementedClientServiceServer kclient.Client auth.Auth + MaxTags int32 } -func NewClientService(client kclient.Client, auth auth.Auth) *ClientService { +func NewClientService(client kclient.Client, auth auth.Auth, maxTags int32) *ClientService { return &ClientService{ - Client: client, - Auth: auth, + Client: client, + Auth: auth, + MaxTags: maxTags, } } @@ -137,6 +139,27 @@ func (s *ClientService) ListLeases(ctx context.Context, req *cpb.ListLeasesReque return nil, err } + // Apply user tag filter by auto-prefixing keys with metadata.jumpstarter.dev/ + if req.TagFilter != "" { + tagSelector, err := labels.Parse(req.TagFilter) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid tag_filter: %v", err) + } + requirements, _ := tagSelector.Requirements() + for _, r := range requirements { + prefixedKey := jumpstarterdevv1alpha1.LeaseTagMetadataPrefix + r.Key() + requirement, err := labels.NewRequirement( + prefixedKey, + r.Operator(), + r.ValuesUnsorted(), + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid tag_filter requirement: %v", err) + } + selector = selector.Add(*requirement) + } + } + // Apply active-only filter by default (when only_active is nil or true) // We must combine this with the user's filter selector into a single // MatchingLabelsSelector, because multiple MatchingLabelsSelector options @@ -185,6 +208,10 @@ func (s *ClientService) CreateLease(ctx context.Context, req *cpb.CreateLeaseReq return nil, err } + if err := jumpstarterdevv1alpha1.ValidateLeaseTags(req.Lease.Tags, int(s.MaxTags)); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid lease tags: %v", err) + } + namespace, err := utils.ParseNamespaceIdentifier(req.Parent) if err != nil { return nil, err From d7e9a3cf8ab18ed6b9cff49a653ae9ead9479e86 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sun, 19 Apr 2026 16:01:45 +0300 Subject: [PATCH 3/5] feat: add configurable maxTags via operator LeasePolicy CR Add LeasePolicy section to the Jumpstarter operator CR with a maxTags field (default 10). Thread the value through the config chain: CR -> ConfigMap -> LoadConfiguration -> ClientService. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- controller/cmd/main.go | 3 ++- .../api/v1alpha1/jumpstarter_types.go | 12 ++++++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 16 ++++++++++++++ ...operator.jumpstarter.dev_jumpstarters.yaml | 11 ++++++++++ .../jumpstarter/jumpstarter_controller.go | 5 +++++ .../deploy/operator/test/e2e/e2e_test.go | 2 ++ controller/internal/config/config.go | 22 +++++++++---------- controller/internal/config/types.go | 6 +++++ .../internal/service/controller_service.go | 12 +++++++++- 9 files changed, 76 insertions(+), 13 deletions(-) diff --git a/controller/cmd/main.go b/controller/cmd/main.go index 05d1703a3..d7f0a317c 100644 --- a/controller/cmd/main.go +++ b/controller/cmd/main.go @@ -219,7 +219,7 @@ func main() { os.Exit(1) } - authenticator, prefix, router, option, provisioning, err := config.LoadConfiguration( + authenticator, prefix, router, option, provisioning, leasePolicy, err := config.LoadConfiguration( context.Background(), mgr.GetAPIReader(), mgr.GetScheme(), @@ -283,6 +283,7 @@ func main() { }), Router: router, ServerOptions: option, + LeasePolicy: leasePolicy, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create service", "service", "Controller") os.Exit(1) diff --git a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go index 620d57e09..5f87b4eb3 100644 --- a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go +++ b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go @@ -179,6 +179,18 @@ type JumpstarterSpec struct { // Authentication configuration for client and exporter authentication. // Supports multiple authentication methods including internal tokens, Kubernetes tokens, and JWT. Authentication AuthenticationConfig `json:"authentication,omitempty"` + + // Lease policy configuration for controlling lease behavior. + // +kubebuilder:default={} + LeasePolicy LeasePolicyConfig `json:"leasePolicy,omitempty"` +} + +// LeasePolicyConfig defines policy constraints for leases. +type LeasePolicyConfig struct { + // Maximum number of user-defined tags allowed per lease. + // +kubebuilder:default=10 + // +kubebuilder:validation:Minimum=0 + MaxTags int32 `json:"maxTags,omitempty"` } // RoutersConfig defines the configuration for Jumpstarter router pods. diff --git a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go index 00f8126d2..104c2b45f 100644 --- a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -404,6 +404,7 @@ func (in *JumpstarterSpec) DeepCopyInto(out *JumpstarterSpec) { in.Controller.DeepCopyInto(&out.Controller) in.Routers.DeepCopyInto(&out.Routers) in.Authentication.DeepCopyInto(&out.Authentication) + out.LeasePolicy = in.LeasePolicy } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JumpstarterSpec. @@ -453,6 +454,21 @@ func (in *K8sAuthConfig) DeepCopy() *K8sAuthConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LeasePolicyConfig) DeepCopyInto(out *LeasePolicyConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LeasePolicyConfig. +func (in *LeasePolicyConfig) DeepCopy() *LeasePolicyConfig { + if in == nil { + return nil + } + out := new(LeasePolicyConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LoadBalancerConfig) DeepCopyInto(out *LoadBalancerConfig) { *out = *in diff --git a/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml b/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml index 26892dbec..c0ed0adc3 100644 --- a/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml +++ b/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml @@ -1299,6 +1299,17 @@ spec: type: object type: object type: object + leasePolicy: + default: {} + description: Lease policy configuration for controlling lease behavior. + properties: + maxTags: + default: 10 + description: Maximum number of user-defined tags allowed per lease. + format: int32 + minimum: 0 + type: integer + type: object routers: default: {} description: |- diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index 03e1d3123..a9c9f240f 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -1145,6 +1145,11 @@ func (r *JumpstarterReconciler) buildConfig(jumpstarter *operatorv1alpha1.Jumpst cfg.Authentication = auth + // Lease policy configuration + cfg.LeasePolicy = config.LeasePolicy{ + MaxTags: jumpstarter.Spec.LeasePolicy.MaxTags, + } + // gRPC keepalive configuration if jumpstarter.Spec.Controller.GRPC.Keepalive != nil { ka := &cfg.Grpc.Keepalive diff --git a/controller/deploy/operator/test/e2e/e2e_test.go b/controller/deploy/operator/test/e2e/e2e_test.go index 46d55c808..4cd6ee017 100644 --- a/controller/deploy/operator/test/e2e/e2e_test.go +++ b/controller/deploy/operator/test/e2e/e2e_test.go @@ -474,6 +474,8 @@ grpc: keepalive: minTime: 1s permitWithoutStream: true +leasePolicy: + maxTags: 10 provisioning: enabled: false ` diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index 0b0913910..d65a5ce0a 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -51,20 +51,20 @@ func LoadConfiguration( key client.ObjectKey, signer *oidc.Signer, certificateAuthority string, -) (authenticator.Token, string, Router, []grpc.ServerOption, *Provisioning, error) { +) (authenticator.Token, string, Router, []grpc.ServerOption, *Provisioning, *LeasePolicy, error) { var configmap corev1.ConfigMap if err := client.Get(ctx, key, &configmap); err != nil { - return nil, "", nil, nil, nil, err + return nil, "", nil, nil, nil, nil, err } rawRouter, ok := configmap.Data["router"] if !ok { - return nil, "", nil, nil, nil, fmt.Errorf("LoadConfiguration: missing router section") + return nil, "", nil, nil, nil, nil, fmt.Errorf("LoadConfiguration: missing router section") } var router Router if err := yaml.Unmarshal([]byte(rawRouter), &router); err != nil { - return nil, "", nil, nil, nil, err + return nil, "", nil, nil, nil, nil, err } rawAuthenticationConfiguration, ok := configmap.Data["authentication"] @@ -79,7 +79,7 @@ func LoadConfiguration( certificateAuthority, ) if err != nil { - return nil, "", nil, nil, nil, err + return nil, "", nil, nil, nil, nil, err } return authenticator, prefix, router, []grpc.ServerOption{ @@ -87,17 +87,17 @@ func LoadConfiguration( MinTime: 1 * time.Second, PermitWithoutStream: true, }), - }, &Provisioning{Enabled: false}, nil + }, &Provisioning{Enabled: false}, &LeasePolicy{MaxTags: 10}, nil } rawConfig, ok := configmap.Data["config"] if !ok { - return nil, "", nil, nil, nil, fmt.Errorf("LoadConfiguration: missing config section") + return nil, "", nil, nil, nil, nil, fmt.Errorf("LoadConfiguration: missing config section") } var config Config if err := yaml.UnmarshalStrict([]byte(rawConfig), &config); err != nil { - return nil, "", nil, nil, nil, err + return nil, "", nil, nil, nil, nil, err } authenticator, prefix, err := LoadAuthenticationConfiguration( @@ -108,13 +108,13 @@ func LoadConfiguration( certificateAuthority, ) if err != nil { - return nil, "", nil, nil, nil, err + return nil, "", nil, nil, nil, nil, err } serverOptions, err := LoadGrpcConfiguration(config.Grpc) if err != nil { - return nil, "", nil, nil, nil, err + return nil, "", nil, nil, nil, nil, err } - return authenticator, prefix, router, serverOptions, &config.Provisioning, nil + return authenticator, prefix, router, serverOptions, &config.Provisioning, &config.LeasePolicy, nil } diff --git a/controller/internal/config/types.go b/controller/internal/config/types.go index 805a022e0..8d00db0d6 100644 --- a/controller/internal/config/types.go +++ b/controller/internal/config/types.go @@ -12,6 +12,12 @@ type Config struct { Authentication Authentication `json:"authentication" yaml:"authentication"` Provisioning Provisioning `json:"provisioning" yaml:"provisioning"` Grpc Grpc `json:"grpc" yaml:"grpc"` + LeasePolicy LeasePolicy `json:"leasePolicy,omitempty" yaml:"leasePolicy,omitempty"` +} + +// LeasePolicy defines policy constraints for leases. +type LeasePolicy struct { + MaxTags int32 `json:"maxTags,omitempty" yaml:"maxTags,omitempty"` } // Authentication defines the authentication configuration for the controller. diff --git a/controller/internal/service/controller_service.go b/controller/internal/service/controller_service.go index f0ec66f0a..be00f507f 100644 --- a/controller/internal/service/controller_service.go +++ b/controller/internal/service/controller_service.go @@ -79,9 +79,19 @@ type ControllerService struct { Attr authorization.ContextAttributesGetter ServerOptions []grpc.ServerOption Router config.Router + LeasePolicy *config.LeasePolicy listenQueues sync.Map } +const defaultMaxTags int32 = 10 + +func (s *ControllerService) effectiveMaxTags() int32 { + if s.LeasePolicy != nil && s.LeasePolicy.MaxTags > 0 { + return s.LeasePolicy.MaxTags + } + return defaultMaxTags +} + type wrappedStream struct { grpc.ServerStream } @@ -1000,7 +1010,7 @@ func (s *ControllerService) Start(ctx context.Context) error { pb.RegisterControllerServiceServer(server, s) cpb.RegisterClientServiceServer( server, - clientsvcv1.NewClientService(s.Client, *auth.NewAuth(s.Client, s.Authn, s.Authz, s.Attr)), + clientsvcv1.NewClientService(s.Client, *auth.NewAuth(s.Client, s.Authn, s.Authz, s.Attr), s.effectiveMaxTags()), ) // Register reflection service on gRPC server. From 2fafd2e96a36f579747ac69708ba6079089bd479 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sun, 19 Apr 2026 16:01:59 +0300 Subject: [PATCH 4/5] feat: add lease tags to Python CLI, client, and MCP Add --tag flag to 'jmp create lease' and --tag-filter to 'jmp get leases'. Wire tags through gRPC client, Lease model, and MCP tool. Display tags in Rich table output. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- .github/workflows/python-tests.yaml | 2 +- .../jumpstarter-cli/jumpstarter_cli/create.py | 15 ++++ .../jumpstarter_cli/create_test.py | 71 +++++++++++++++++++ .../jumpstarter-cli/jumpstarter_cli/get.py | 29 ++++---- .../jumpstarter_cli/get_test.py | 20 +++--- .../jumpstarter_mcp/tools/leases.py | 3 + .../jumpstarter/jumpstarter/client/grpc.py | 13 ++++ .../jumpstarter/client/grpc_test.py | 59 ++++++++++++++- .../jumpstarter/jumpstarter/client/lease.py | 2 + .../jumpstarter/jumpstarter/config/client.py | 10 ++- .../jumpstarter/config/client_config_test.py | 1 + python/uv.lock | 13 ++-- 12 files changed, 201 insertions(+), 37 deletions(-) diff --git a/.github/workflows/python-tests.yaml b/.github/workflows/python-tests.yaml index ef127eb79..bca49547c 100644 --- a/.github/workflows/python-tests.yaml +++ b/.github/workflows/python-tests.yaml @@ -131,7 +131,7 @@ jobs: echo "::error::No coverage.xml files found" exit 1 fi - uv run diff-cover $coverage_files --compare-branch=origin/${{ github.base_ref }} --fail-under=80 + uv run diff-cover $coverage_files --compare-branch=origin/${{ github.base_ref }} --fail-under=80 --exclude '*_pb2.py' '*_pb2_grpc.py' # https://github.com/orgs/community/discussions/26822 pytest: diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/create.py b/python/packages/jumpstarter-cli/jumpstarter_cli/create.py index 120ae2198..22968d4d0 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/create.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/create.py @@ -30,6 +30,12 @@ def create(): default=None, help="Optional lease ID to request (if not provided, server will generate one)", ) +@click.option( + "--tag", + "tags", + multiple=True, + help="Tag to set on the lease (key=value format, can be specified multiple times)", +) @opt_output_all @handle_exceptions_with_reauthentication(relogin_client) def create_lease( @@ -39,6 +45,7 @@ def create_lease( duration: timedelta, begin_time: datetime | None, lease_id: str | None, + tags: tuple[str, ...], output: OutputType, ): """ @@ -75,12 +82,20 @@ def create_lease( if not selector and not exporter_name: raise click.UsageError("one of --selector/-l or --name/-n is required") + parsed_tags = {} + for tag in tags: + if "=" not in tag: + raise click.UsageError(f"Invalid tag format: {tag!r} (expected key=value)") + k, v = tag.split("=", 1) + parsed_tags[k] = v + lease = config.create_lease( selector=selector, exporter_name=exporter_name, duration=duration, begin_time=begin_time, lease_id=lease_id, + tags=parsed_tags or None, ) model_print(lease, output) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/create_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/create_test.py index 40a9ebd9b..b9d3837e7 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/create_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/create_test.py @@ -22,6 +22,7 @@ def test_create_lease_passes_exporter_name_to_config(): duration=timedelta(minutes=5), begin_time=None, lease_id=None, + tags=(), output="yaml", ) @@ -31,6 +32,7 @@ def test_create_lease_passes_exporter_name_to_config(): duration=timedelta(minutes=5), begin_time=None, lease_id=None, + tags=None, ) model_print.assert_called_once_with(lease, "yaml") @@ -44,5 +46,74 @@ def test_create_lease_requires_selector_or_name(): duration=timedelta(minutes=5), begin_time=None, lease_id=None, + tags=(), + output="yaml", + ) + + +def test_create_lease_passes_tags_to_config(): + config = Mock() + lease = Mock() + config.create_lease.return_value = lease + + with patch("jumpstarter_cli.create.model_print"): + inspect.unwrap(create_lease.callback)( + config=config, + selector="board=rpi4", + exporter_name=None, + duration=timedelta(minutes=5), + begin_time=None, + lease_id=None, + tags=("team=devops", "ci-job=12345"), + output="yaml", + ) + + config.create_lease.assert_called_once_with( + selector="board=rpi4", + exporter_name=None, + duration=timedelta(minutes=5), + begin_time=None, + lease_id=None, + tags={"team": "devops", "ci-job": "12345"}, + ) + + +def test_create_lease_empty_tags_passes_none(): + config = Mock() + lease = Mock() + config.create_lease.return_value = lease + + with patch("jumpstarter_cli.create.model_print"): + inspect.unwrap(create_lease.callback)( + config=config, + selector="board=rpi4", + exporter_name=None, + duration=timedelta(minutes=5), + begin_time=None, + lease_id=None, + tags=(), + output="yaml", + ) + + config.create_lease.assert_called_once_with( + selector="board=rpi4", + exporter_name=None, + duration=timedelta(minutes=5), + begin_time=None, + lease_id=None, + tags=None, + ) + + +def test_create_lease_invalid_tag_format(): + with pytest.raises(click.UsageError, match="Invalid tag format"): + inspect.unwrap(create_lease.callback)( + config=Mock(), + selector="board=rpi4", + exporter_name=None, + duration=timedelta(minutes=5), + begin_time=None, + lease_id=None, + tags=("invalid-no-equals",), output="yaml", ) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/get.py b/python/packages/jumpstarter-cli/jumpstarter_cli/get.py index dc4a500da..7332147d6 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/get.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/get.py @@ -45,29 +45,26 @@ def get_exporters(config, selector: str | None, output: OutputType, with_options @opt_config(exporter=False) @opt_selector @opt_output_all +@click.option("-a", "--all", "show_all", is_flag=True, default=False, help="Include expired leases") +@click.option("-A", "--all-clients", "all_clients", is_flag=True, default=False, help="Include leases from all clients") @click.option( - "-a", - "--all", - "show_all", - is_flag=True, - default=False, - help="Include expired leases" -) -@click.option( - "-A", - "--all-clients", - "all_clients", - is_flag=True, - default=False, - help="Include leases from all clients" + "--tag-filter", + "tag_filter", + type=str, + default=None, + help="Filter leases by tags (label selector syntax, e.g. build=1234)", ) @handle_exceptions_with_reauthentication(relogin_client) -def get_leases(config, selector: str | None, output: OutputType, show_all: bool, all_clients: bool): +def get_leases( + config, selector: str | None, output: OutputType, show_all: bool, all_clients: bool, tag_filter: str | None +): """ Display one or many leases """ - leases = config.list_leases(filter=selector, only_active=not show_all).filter_by_selector(selector) + leases = config.list_leases(filter=selector, only_active=not show_all, tag_filter=tag_filter).filter_by_selector( + selector + ) if not all_clients: leases = leases.filter_by_client(config.metadata.name) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py index eeebfeb6e..396c49198 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py @@ -241,10 +241,10 @@ def test_get_leases_calls_list_leases(self): with patch("jumpstarter_cli.get.model_print"): get_leases.callback.__wrapped__.__wrapped__( - config=config, selector=None, output="text", show_all=False, all_clients=False + config=config, selector=None, output="text", show_all=False, all_clients=False, tag_filter=None, ) - config.list_leases.assert_called_once_with(filter=None, only_active=True) + config.list_leases.assert_called_once_with(filter=None, only_active=True, tag_filter=None) class TestGetExportersIntegration: @@ -433,13 +433,13 @@ def test_default_shows_only_own_active_leases(self): with patch("jumpstarter_cli.get.model_print") as mock_print: _unwrapped_get_leases( - config=config, selector=None, output=None, show_all=False, all_clients=False, + config=config, selector=None, output=None, show_all=False, all_clients=False, tag_filter=None, ) printed_leases = mock_print.call_args[0][0] assert len(printed_leases.leases) == 1 assert printed_leases.leases[0].name == "my-lease" - config.list_leases.assert_called_once_with(filter=None, only_active=True) + config.list_leases.assert_called_once_with(filter=None, only_active=True, tag_filter=None) def test_all_flag_requests_inactive_leases_own_only(self): from unittest.mock import patch @@ -450,13 +450,13 @@ def test_all_flag_requests_inactive_leases_own_only(self): with patch("jumpstarter_cli.get.model_print") as mock_print: _unwrapped_get_leases( - config=config, selector=None, output=None, show_all=True, all_clients=False, + config=config, selector=None, output=None, show_all=True, all_clients=False, tag_filter=None, ) printed_leases = mock_print.call_args[0][0] assert len(printed_leases.leases) == 1 assert printed_leases.leases[0].name == "my-lease" - config.list_leases.assert_called_once_with(filter=None, only_active=False) + config.list_leases.assert_called_once_with(filter=None, only_active=False, tag_filter=None) def test_all_clients_shows_everyone_active(self): from unittest.mock import patch @@ -467,12 +467,12 @@ def test_all_clients_shows_everyone_active(self): with patch("jumpstarter_cli.get.model_print") as mock_print: _unwrapped_get_leases( - config=config, selector=None, output=None, show_all=False, all_clients=True, + config=config, selector=None, output=None, show_all=False, all_clients=True, tag_filter=None, ) printed_leases = mock_print.call_args[0][0] assert len(printed_leases.leases) == 2 - config.list_leases.assert_called_once_with(filter=None, only_active=True) + config.list_leases.assert_called_once_with(filter=None, only_active=True, tag_filter=None) def test_all_and_all_clients_shows_everything(self): from unittest.mock import patch @@ -483,9 +483,9 @@ def test_all_and_all_clients_shows_everything(self): with patch("jumpstarter_cli.get.model_print") as mock_print: _unwrapped_get_leases( - config=config, selector=None, output=None, show_all=True, all_clients=True, + config=config, selector=None, output=None, show_all=True, all_clients=True, tag_filter=None, ) printed_leases = mock_print.call_args[0][0] assert len(printed_leases.leases) == 2 - config.list_leases.assert_called_once_with(filter=None, only_active=False) + config.list_leases.assert_called_once_with(filter=None, only_active=False, tag_filter=None) diff --git a/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/leases.py b/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/leases.py index 3d1dd482e..63cd798b3 100644 --- a/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/leases.py +++ b/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/leases.py @@ -73,6 +73,7 @@ async def create_lease( duration_seconds: int = 1800, selector: str | None = None, exporter_name: str | None = None, + tags: dict[str, str] | None = None, ) -> dict: """Create a new lease.""" duration = timedelta(seconds=duration_seconds) @@ -80,6 +81,7 @@ async def create_lease( duration=duration, selector=selector, exporter_name=exporter_name, + tags=tags, ) return { "name": result.name, @@ -87,6 +89,7 @@ async def create_lease( "duration_seconds": duration_seconds, "selector": selector, "exporter_name": exporter_name, + "tags": tags, } diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc.py b/python/packages/jumpstarter/jumpstarter/client/grpc.py index 9a982012e..11453967b 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc.py @@ -134,6 +134,7 @@ class Lease(BaseModel): name: str selector: str exporter_name: str | None = None + tags: dict[str, str] = Field(default_factory=dict) duration: timedelta effective_duration: timedelta | None = None begin_time: datetime | None = None @@ -189,6 +190,7 @@ def from_protobuf(cls, data: client_pb2.Lease) -> Lease: name=name, selector=data.selector, exporter_name=data.exporter_name if data.exporter_name else None, + tags=dict(data.tags) if data.tags else {}, duration=data.duration.ToTimedelta(), effective_duration=effective_duration, begin_time=begin_time, @@ -207,6 +209,7 @@ def rich_add_columns(cls, table): table.add_column("REMAINING") table.add_column("CLIENT") table.add_column("EXPORTER") + table.add_column("TAGS") def _compute_expires_at(self): if self.effective_end_time: @@ -242,6 +245,8 @@ def rich_add_rows(self, table): expires_at_str = expires_at.strftime("%Y-%m-%d %H:%M:%S") if expires_at else "" remaining_str = self._format_remaining(expires_at) + tags_str = ",".join(f"{k}={v}" for k, v in sorted(self.tags.items())) + table.add_row( self.name, self.selector, @@ -249,6 +254,7 @@ def rich_add_rows(self, table): remaining_str, self.client, self.exporter, + tags_str, ) def rich_add_names(self, names): @@ -424,6 +430,7 @@ async def ListLeases( page_token: str | None = None, filter: str | None = None, only_active: bool = True, + tag_filter: str | None = None, ): with translate_grpc_exceptions(): leases = await self.stub.ListLeases( @@ -433,6 +440,7 @@ async def ListLeases( page_token=page_token, filter=extract_match_labels_filter(filter), only_active=only_active, + tag_filter=tag_filter or "", ) ) return LeaseList.from_protobuf(leases) @@ -445,6 +453,7 @@ async def CreateLease( exporter_name: str | None = None, begin_time: datetime | None = None, lease_id: str | None = None, + tags: dict[str, str] | None = None, ): duration_pb = duration_pb2.Duration() duration_pb.FromTimedelta(duration) @@ -455,6 +464,10 @@ async def CreateLease( exporter_name=exporter_name or "", ) + if tags: + for k, v in tags.items(): + lease_pb.tags[k] = v + if begin_time: timestamp_pb = timestamp_pb2.Timestamp() timestamp_pb.FromDatetime(begin_time) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index 784254377..20f4e904f 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -1,11 +1,13 @@ from datetime import datetime, timedelta from io import StringIO -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch +import pytest from rich.console import Console from rich.table import Table from jumpstarter.client.grpc import ( + ClientService, Exporter, Lease, WithOptions, @@ -407,7 +409,7 @@ def test_rich_add_columns_has_expires_at_and_remaining(self): table = Table() Lease.rich_add_columns(table) columns = [col.header for col in table.columns] - assert columns == ["NAME", "SELECTOR", "EXPIRES AT", "REMAINING", "CLIENT", "EXPORTER"] + assert columns == ["NAME", "SELECTOR", "EXPIRES AT", "REMAINING", "CLIENT", "EXPORTER", "TAGS"] def test_rich_add_columns_excludes_begin_time_and_duration(self): table = Table() @@ -507,3 +509,56 @@ def test_rich_add_rows_empty_when_no_timing_data(self): output = console.file.getvalue() assert "test-lease" in output assert "test-client" in output + + def test_rich_display_shows_tags(self): + lease = self.create_lease() + lease.tags = {"team": "devops", "ci-job": "12345"} + table = Table() + Lease.rich_add_columns(table) + lease.rich_add_rows(table) + console = Console(file=StringIO(), force_terminal=True) + console.print(table) + output = console.file.getvalue() + assert "team=devops" in output + assert "ci-job=12345" in output + + def test_rich_display_empty_tags(self): + lease = self.create_lease() + table = Table() + Lease.rich_add_columns(table) + lease.rich_add_rows(table) + # Should not crash with empty tags + columns = [col.header for col in table.columns] + assert "TAGS" in columns + + +@pytest.mark.asyncio +async def test_create_lease_sets_tags_on_protobuf(): + from jumpstarter_protocol import client_pb2 + + mock_channel = Mock() + + response_lease = client_pb2.Lease( + selector="board=rpi4", + client="namespaces/default/clients/test-client", + ) + response_lease.name = "namespaces/default/leases/test-lease" + response_lease.tags["team"] = "devops" + response_lease.tags["ci-job"] = "999" + response_lease.duration.FromTimedelta(timedelta(hours=1)) + + mock_stub = Mock() + mock_stub.CreateLease = AsyncMock(return_value=response_lease) + + svc = ClientService(channel=mock_channel, namespace="default") + svc.stub = mock_stub + + result = await svc.CreateLease( + selector="board=rpi4", + duration=timedelta(hours=1), + tags={"team": "devops", "ci-job": "999"}, + ) + + call_args = mock_stub.CreateLease.call_args[0][0] + assert dict(call_args.lease.tags) == {"team": "devops", "ci-job": "999"} + assert result.tags == {"team": "devops", "ci-job": "999"} diff --git a/python/packages/jumpstarter/jumpstarter/client/lease.py b/python/packages/jumpstarter/jumpstarter/client/lease.py index a51c87db0..9021b4495 100644 --- a/python/packages/jumpstarter/jumpstarter/client/lease.py +++ b/python/packages/jumpstarter/jumpstarter/client/lease.py @@ -83,6 +83,7 @@ class Lease(ContextManagerMixin, AsyncContextManagerMixin): portal: BlockingPortal namespace: str name: str | None = field(default=None) + tags: dict[str, str] = field(default_factory=dict) allow: list[str] unsafe: bool release: bool = True # release on contexts exit @@ -124,6 +125,7 @@ async def _create(self): exporter_name=self.requested_exporter_name, duration=self.duration, lease_id=self.name, + tags=self.tags or None, ) ).name logger.info("Acquiring lease %s for selector %s for duration %s", self.name, self.selector, self.duration) diff --git a/python/packages/jumpstarter/jumpstarter/config/client.py b/python/packages/jumpstarter/jumpstarter/config/client.py index 153377eb7..fe6c4ba51 100644 --- a/python/packages/jumpstarter/jumpstarter/config/client.py +++ b/python/packages/jumpstarter/jumpstarter/config/client.py @@ -167,7 +167,7 @@ async def get_exporter( svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) return await svc.GetExporter(name=name) - async def _collect_all_leases(self, svc, page_size=100, only_active=True, filter=None): + async def _collect_all_leases(self, svc, page_size=100, only_active=True, filter=None, tag_filter=None): from jumpstarter.client.grpc import LeaseList all_leases = [] @@ -178,6 +178,7 @@ async def _collect_all_leases(self, svc, page_size=100, only_active=True, filter page_token=page_token, filter=filter, only_active=only_active, + tag_filter=tag_filter, ) all_leases.extend(page.leases) if not page.next_page_token: @@ -250,6 +251,7 @@ async def create_lease( exporter_name: str | None = None, begin_time: datetime | None = None, lease_id: str | None = None, + tags: dict[str, str] | None = None, ): svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) return await svc.CreateLease( @@ -258,6 +260,7 @@ async def create_lease( duration=duration, begin_time=begin_time, lease_id=lease_id, + tags=tags, ) @_blocking_compat @@ -278,9 +281,12 @@ async def list_leases( filter: str | None = None, only_active: bool = True, page_size: int = 100, + tag_filter: str | None = None, ): svc = ClientService(channel=await self.channel(), namespace=self.metadata.namespace) - return await self._collect_all_leases(svc, page_size=page_size, only_active=only_active, filter=filter) + return await self._collect_all_leases( + svc, page_size=page_size, only_active=only_active, filter=filter, tag_filter=tag_filter, + ) @_blocking_compat @_handle_connection_error diff --git a/python/packages/jumpstarter/jumpstarter/config/client_config_test.py b/python/packages/jumpstarter/jumpstarter/config/client_config_test.py index 1f88f84c9..6f502179c 100644 --- a/python/packages/jumpstarter/jumpstarter/config/client_config_test.py +++ b/python/packages/jumpstarter/jumpstarter/config/client_config_test.py @@ -442,6 +442,7 @@ async def test_create_lease_passes_exporter_name(): duration=timedelta(minutes=5), begin_time=None, lease_id=None, + tags=None, ) diff --git a/python/uv.lock b/python/uv.lock index 00f734f9f..fa4b3aae6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -72,7 +72,7 @@ members = [ [manifest.dependency-groups] dev = [ - { name = "diff-cover", specifier = ">=9.2.0" }, + { name = "diff-cover", specifier = ">=10.2.0" }, { name = "esbonio", specifier = ">=0.16.5" }, { name = "pre-commit", specifier = ">=3.8.0" }, { name = "ruff", specifier = "==0.15.10" }, @@ -90,7 +90,7 @@ docs = [ { name = "sphinx-copybutton", specifier = ">=0.5.2" }, { name = "sphinx-inline-tabs", specifier = ">=2023.4.21" }, { name = "sphinx-substitution-extensions", specifier = ">=2024.10.17" }, - { name = "sphinxcontrib-mermaid", specifier = ">=0.9.2" }, + { name = "sphinxcontrib-mermaid", specifier = ">=2.0.1" }, { name = "sphinxcontrib-programoutput", specifier = ">=0.19" }, ] @@ -6190,15 +6190,16 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "1.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/69/bf039237ad260073e8c02f820b3e00dc34f3a2de20aff7861e6b19d2f8c5/sphinxcontrib_mermaid-1.0.0.tar.gz", hash = "sha256:2e8ab67d3e1e2816663f9347d026a8dee4a858acdd4ad32dd1c808893db88146", size = 15153, upload-time = "2024-10-12T16:33:03.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/c8/784b9ac6ea08aa594c1a4becbd0dbe77186785362e31fd633b8c6ae0197a/sphinxcontrib_mermaid-1.0.0-py3-none-any.whl", hash = "sha256:60b72710ea02087f212028feb09711225fbc2e343a10d34822fe787510e1caa3", size = 9597, upload-time = "2024-10-12T16:33:02.303Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, ] [[package]] From 385bb2b5550f9df4fdc36638f51b9254b5251156 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sun, 19 Apr 2026 16:02:07 +0300 Subject: [PATCH 5/5] docs: add JEP-0012 for custom user tags on leases Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- docs/internal/jeps/JEP-0013-lease-tags.md | 165 ++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/internal/jeps/JEP-0013-lease-tags.md diff --git a/docs/internal/jeps/JEP-0013-lease-tags.md b/docs/internal/jeps/JEP-0013-lease-tags.md new file mode 100644 index 000000000..ff9dafc5e --- /dev/null +++ b/docs/internal/jeps/JEP-0013-lease-tags.md @@ -0,0 +1,165 @@ +# JEP-0012: Custom User Tags on Leases + +| Field | Value | +|--------------------|----------------------------------------------| +| **JEP** | 0012 | +| **Title** | Custom User Tags on Leases | +| **Author(s)** | @bzlotnik (Benny Zlotnik) | +| **Status** | Draft | +| **Type** | Standards Track | +| **Created** | 2026-04-19 | +| **Updated** | 2026-04-19 | +| **Discussion** | Pending | + +--- + +## Abstract + +Add support for user-defined key-value tags on Lease resources, set at +creation time and queryable when listing leases. Tags enable users to +tag leases with metadata like CI job IDs numbers for tracking and filtering. + +## Motivation + +Jumpstarter leases currently carry no user-defined metadata. In +production environments with multiple teams, CI pipelines, and shared +hardware pools, operators and developers need a way to annotate leases +with contextual information and later query by that information. Without +tags, correlating a lease to its originating CI job, or purpose +requires external bookkeeping. + +### User Stories + +- **As a** CI pipeline author, **I want to** tag leases with my CI job + ID and pipeline name, **so that** I can correlate leases with builds. +- **As a** lab operator, **I want to** query leases by environment or + purpose, **so that** I can audit and manage lease usage. +- **As a** developer, **I want to** annotate leases with purpose tags, + **so that** I can distinguish between test runs and debug sessions. + +### Constraints + +- Tags must be immutable after creation (like selector) to avoid race + conditions +- Must not conflict with system labels (`jumpstarter.dev/lease-ended`) + or exporter selector matchLabels stored in `ObjectMeta.Labels` +- Must be size-limited to prevent abuse +- Must follow Kubernetes label conventions + +## Proposal + +The feature adds a `map tags` field to the Lease +protobuf message and `Tags map[string]string` to the K8s LeaseSpec +CRD. + +### Storage + +Tags are stored in `LeaseSpec.Tags` as the source of truth. They +are also applied to K8s `ObjectMeta.Labels` with the prefix +`metadata.jumpstarter.dev/` for native K8s queryability. The prefix +avoids conflicts with existing `ObjectMeta.Labels` usage (exporter +selector matchLabels and system labels). + +### Validation + +- Key: max 63 characters, valid K8s label key format, must not use + `jumpstarter.dev/` or `metadata.jumpstarter.dev/` prefixes +- Value: max 63 characters, valid K8s label value format +- Validated server-side in `CreateLease` + +### Queryability + +A new `tag_filter` field in `ListLeasesRequest` allows filtering +leases by tags. The server auto-prefixes keys with +`metadata.jumpstarter.dev/` before querying K8s, so users use +unprefixed keys in queries: + +```shell +--tag-filter team=devops +``` + +not: + +```shell +--tag-filter metadata.jumpstarter.dev/team=devops +``` + +Leases are also queryable via kubectl directly: + +```shell +kubectl get leases -l metadata.jumpstarter.dev/team=devops +``` + +### CLI + +```shell +jmp create lease -l board=rpi4 --duration 1h --tag team=devops --tag ci-job=12345 +jmp get leases --tag-filter team=devops +``` + +Tags are displayed in `jmp get leases` output. + +### Protobuf Changes + +```protobuf +// In Lease message: +map tags = 13 [(google.api.field_behavior) = IMMUTABLE]; + +// In ListLeasesRequest: +string tag_filter = 6 [(google.api.field_behavior) = OPTIONAL]; +``` + +### CRD Changes + +```go +// In LeaseSpec: +// +kubebuilder:validation:MaxProperties=10 +Tags map[string]string `json:"tags,omitempty"` +``` + +## Test Plan + +### Unit Tests + +- Tag validation: max entries, key/value length, reserved prefixes +- `LeaseFromProtobuf` / `ToProtobuf` roundtrip with tags + +### Integration Tests + +- `CreateLease` with tags +- `ListLeases` with `tag_filter` + +### E2E Tests + +- Create lease with tags, list with filter, verify results + +## Backward Compatibility + +This change is fully backward compatible. The new `tags` field is +optional and defaults to an empty map. Existing leases will have empty +tags. No existing protobuf field numbers are changed. + +## Consequences + +### Positive + +- Users can track and filter leases by custom metadata +- K8s-native queryability via label selectors +- Consistent with Exporter's existing labels field +- Enables CI/CD integration and team-based lease management + +## Implementation History + +- 2026-04-19: Initial proposal (Draft) + +## References + +- [Kubernetes Labels and Selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) +- [AIP-203: Field behavior documentation](https://google.aip.dev/203) +- Exporter labels pattern in `protocol/proto/jumpstarter/client/v1/client.proto` + +--- + +*This JEP is licensed under the +[Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0), +consistent with the Jumpstarter project.*