From 81f30aac2646e0c6cc3b8a9cb0ed8a71348710ee Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Mon, 2 Nov 2026 14:57:33 +1000 Subject: [PATCH] feat(labels): support service and container labels, update labels and cpu/memory in-place --- api/pb/docker.pb.go | 571 ++++++++++++------ api/pb/docker.proto | 19 + api/pb/docker_grpc.pb.go | 40 ++ internal/machine/docker/controller.go | 5 + internal/machine/docker/server.go | 63 +- internal/machine/docker/service.go | 105 ++++ pkg/api/client.go | 1 + pkg/api/container.go | 33 + pkg/api/resources.go | 33 + pkg/api/resources_test.go | 72 +++ pkg/api/service.go | 29 + pkg/api/service_test.go | 48 +- pkg/client/compose/plan.go | 9 +- pkg/client/compose/plan_test.go | 30 + pkg/client/compose/project_test.go | 14 - pkg/client/compose/service.go | 14 +- pkg/client/compose/service_test.go | 75 +++ pkg/client/container.go | 33 + pkg/client/deploy/container.go | 71 ++- pkg/client/deploy/container_test.go | 239 +++++++- pkg/client/deploy/deploy.go | 9 +- pkg/client/deploy/deploy_test.go | 51 ++ pkg/client/deploy/strategy.go | 74 ++- pkg/client/deploy/strategy_test.go | 136 ++++- pkg/client/operation/container.go | 33 + pkg/client/operation/container_test.go | 7 + test/e2e/assert.go | 5 +- test/e2e/compose_deploy_test.go | 82 +++ test/e2e/service_test.go | 84 +++ .../1-support-matrix.md | 28 +- 30 files changed, 1727 insertions(+), 286 deletions(-) create mode 100644 pkg/api/resources_test.go create mode 100644 pkg/client/compose/plan_test.go create mode 100644 pkg/client/deploy/deploy_test.go diff --git a/api/pb/docker.pb.go b/api/pb/docker.pb.go index 5f535ab6..4b083dde 100644 --- a/api/pb/docker.pb.go +++ b/api/pb/docker.pb.go @@ -2259,6 +2259,137 @@ func (x *ServiceContainer) GetServiceSpec() []byte { return nil } +type UpdateServiceContainerRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Fields that are not set are left unchanged. CPU and memory limits can be changed but not removed. + // CPU limit in nanocores. + Cpu *int64 `protobuf:"varint,2,opt,name=cpu,proto3,oneof" json:"cpu,omitempty"` + // Memory limit in bytes. + Memory *int64 `protobuf:"varint,3,opt,name=memory,proto3,oneof" json:"memory,omitempty"` + // Memory reservation in bytes. + MemoryReservation *int64 `protobuf:"varint,4,opt,name=memory_reservation,json=memoryReservation,proto3,oneof" json:"memory_reservation,omitempty"` + // Service labels that replace all current service labels. + ServiceLabels *Labels `protobuf:"bytes,5,opt,name=service_labels,json=serviceLabels,proto3" json:"service_labels,omitempty"` +} + +func (x *UpdateServiceContainerRequest) Reset() { + *x = UpdateServiceContainerRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_api_pb_docker_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateServiceContainerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateServiceContainerRequest) ProtoMessage() {} + +func (x *UpdateServiceContainerRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_pb_docker_proto_msgTypes[38] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateServiceContainerRequest.ProtoReflect.Descriptor instead. +func (*UpdateServiceContainerRequest) Descriptor() ([]byte, []int) { + return file_api_pb_docker_proto_rawDescGZIP(), []int{38} +} + +func (x *UpdateServiceContainerRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateServiceContainerRequest) GetCpu() int64 { + if x != nil && x.Cpu != nil { + return *x.Cpu + } + return 0 +} + +func (x *UpdateServiceContainerRequest) GetMemory() int64 { + if x != nil && x.Memory != nil { + return *x.Memory + } + return 0 +} + +func (x *UpdateServiceContainerRequest) GetMemoryReservation() int64 { + if x != nil && x.MemoryReservation != nil { + return *x.MemoryReservation + } + return 0 +} + +func (x *UpdateServiceContainerRequest) GetServiceLabels() *Labels { + if x != nil { + return x.ServiceLabels + } + return nil +} + +type Labels struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Labels map[string]string `protobuf:"bytes,1,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *Labels) Reset() { + *x = Labels{} + if protoimpl.UnsafeEnabled { + mi := &file_api_pb_docker_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Labels) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Labels) ProtoMessage() {} + +func (x *Labels) ProtoReflect() protoreflect.Message { + mi := &file_api_pb_docker_proto_msgTypes[39] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Labels.ProtoReflect.Descriptor instead. +func (*Labels) Descriptor() ([]byte, []int) { + return file_api_pb_docker_proto_rawDescGZIP(), []int{39} +} + +func (x *Labels) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + type ListServiceContainersRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2272,7 +2403,7 @@ type ListServiceContainersRequest struct { func (x *ListServiceContainersRequest) Reset() { *x = ListServiceContainersRequest{} if protoimpl.UnsafeEnabled { - mi := &file_api_pb_docker_proto_msgTypes[38] + mi := &file_api_pb_docker_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2285,7 +2416,7 @@ func (x *ListServiceContainersRequest) String() string { func (*ListServiceContainersRequest) ProtoMessage() {} func (x *ListServiceContainersRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_pb_docker_proto_msgTypes[38] + mi := &file_api_pb_docker_proto_msgTypes[40] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2298,7 +2429,7 @@ func (x *ListServiceContainersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServiceContainersRequest.ProtoReflect.Descriptor instead. func (*ListServiceContainersRequest) Descriptor() ([]byte, []int) { - return file_api_pb_docker_proto_rawDescGZIP(), []int{38} + return file_api_pb_docker_proto_rawDescGZIP(), []int{40} } func (x *ListServiceContainersRequest) GetServiceId() string { @@ -2328,7 +2459,7 @@ type ListServiceContainersResponse struct { func (x *ListServiceContainersResponse) Reset() { *x = ListServiceContainersResponse{} if protoimpl.UnsafeEnabled { - mi := &file_api_pb_docker_proto_msgTypes[39] + mi := &file_api_pb_docker_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2341,7 +2472,7 @@ func (x *ListServiceContainersResponse) String() string { func (*ListServiceContainersResponse) ProtoMessage() {} func (x *ListServiceContainersResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_pb_docker_proto_msgTypes[39] + mi := &file_api_pb_docker_proto_msgTypes[41] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2354,7 +2485,7 @@ func (x *ListServiceContainersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServiceContainersResponse.ProtoReflect.Descriptor instead. func (*ListServiceContainersResponse) Descriptor() ([]byte, []int) { - return file_api_pb_docker_proto_rawDescGZIP(), []int{39} + return file_api_pb_docker_proto_rawDescGZIP(), []int{41} } func (x *ListServiceContainersResponse) GetMessages() []*MachineServiceContainers { @@ -2378,7 +2509,7 @@ type MachineServiceContainers struct { func (x *MachineServiceContainers) Reset() { *x = MachineServiceContainers{} if protoimpl.UnsafeEnabled { - mi := &file_api_pb_docker_proto_msgTypes[40] + mi := &file_api_pb_docker_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2391,7 +2522,7 @@ func (x *MachineServiceContainers) String() string { func (*MachineServiceContainers) ProtoMessage() {} func (x *MachineServiceContainers) ProtoReflect() protoreflect.Message { - mi := &file_api_pb_docker_proto_msgTypes[40] + mi := &file_api_pb_docker_proto_msgTypes[42] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2404,7 +2535,7 @@ func (x *MachineServiceContainers) ProtoReflect() protoreflect.Message { // Deprecated: Use MachineServiceContainers.ProtoReflect.Descriptor instead. func (*MachineServiceContainers) Descriptor() ([]byte, []int) { - return file_api_pb_docker_proto_rawDescGZIP(), []int{40} + return file_api_pb_docker_proto_rawDescGZIP(), []int{42} } func (x *MachineServiceContainers) GetMetadata() *Metadata { @@ -2638,125 +2769,153 @@ var file_api_pb_docker_proto_rawDesc = []byte{ 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x22, 0x57, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, + 0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x22, 0xf5, 0x01, 0x0a, 0x1d, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, + 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x03, 0x63, 0x70, 0x75, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52, 0x03, 0x63, 0x70, 0x75, 0x88, 0x01, 0x01, 0x12, + 0x1b, 0x0a, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x48, + 0x01, 0x52, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x32, 0x0a, 0x12, + 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x11, 0x6d, 0x65, 0x6d, 0x6f, + 0x72, 0x79, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, + 0x12, 0x32, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x52, 0x0d, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4c, 0x61, + 0x62, 0x65, 0x6c, 0x73, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x63, 0x70, 0x75, 0x42, 0x09, 0x0a, 0x07, + 0x5f, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x6d, 0x65, 0x6d, 0x6f, + 0x72, 0x79, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x74, + 0x0a, 0x06, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2f, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0x57, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5a, 0x0a, + 0x1d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, + 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, + 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0x5a, 0x0a, 0x1d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, - 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x39, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, - 0x73, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xbc, 0x01, 0x0a, 0x18, - 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, - 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x12, 0x35, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, - 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x68, 0x6f, - 0x6f, 0x6b, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0e, 0x68, 0x6f, 0x6f, 0x6b, - 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x32, 0xbd, 0x0b, 0x0a, 0x06, 0x44, - 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, - 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, - 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, - 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, - 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, - 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, 0x0a, 0x0d, 0x53, 0x74, - 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, - 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, - 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, - 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0f, 0x52, 0x65, 0x6d, - 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, - 0x79, 0x12, 0x4a, 0x0a, 0x0d, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, - 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, - 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x32, 0x0a, - 0x0d, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x30, - 0x01, 0x12, 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x15, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x53, 0x4f, 0x4e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x12, 0x43, 0x0a, 0x0c, 0x49, 0x6e, 0x73, - 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, - 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, - 0x0a, 0x12, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, - 0x6d, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, - 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, - 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, - 0x67, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, - 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, - 0x61, 0x67, 0x65, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, - 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, - 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4c, - 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, - 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, - 0x0c, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, + 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xbc, 0x01, 0x0a, 0x18, 0x4d, 0x61, + 0x63, 0x68, 0x69, 0x6e, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, + 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x12, 0x35, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, + 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x68, 0x6f, 0x6f, 0x6b, + 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, + 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0e, 0x68, 0x6f, 0x6f, 0x6b, 0x43, 0x6f, + 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x32, 0x93, 0x0c, 0x0a, 0x06, 0x44, 0x6f, 0x63, + 0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, + 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, + 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, + 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, + 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, 0x0a, 0x0d, 0x53, 0x74, 0x6f, 0x70, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e, + 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x1a, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, + 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0f, 0x52, 0x65, 0x6d, 0x6f, 0x76, + 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, - 0x5a, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, - 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x17, 0x49, - 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, - 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x5e, 0x0a, 0x15, 0x4c, + 0x4a, 0x0a, 0x0d, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, + 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x32, 0x0a, 0x0d, 0x43, + 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x30, 0x01, 0x12, + 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x15, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x53, 0x4f, 0x4e, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x12, 0x43, 0x0a, 0x0c, 0x49, 0x6e, 0x73, 0x70, 0x65, + 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, + 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, + 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x12, + 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, + 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, + 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, + 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, + 0x73, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, 0x61, 0x67, + 0x65, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, + 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4c, 0x69, 0x73, + 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, + 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, + 0x6d, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0c, 0x52, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x5a, 0x0a, + 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, + 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, + 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x17, 0x49, 0x6e, 0x73, + 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, + 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, + 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x54, 0x0a, 0x16, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, + 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, + 0x5e, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, + 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, - 0x6e, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, - 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x16, 0x52, - 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, - 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, - 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x26, 0x5a, 0x24, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x61, 0x70, 0x69, 0x2f, - 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x4d, 0x0a, 0x16, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x26, + 0x5a, 0x24, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2772,7 +2931,7 @@ func file_api_pb_docker_proto_rawDescGZIP() []byte { } var file_api_pb_docker_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 44) var file_api_pb_docker_proto_goTypes = []any{ (CreateServiceContainerRequest_ContainerType)(0), // 0: api.CreateServiceContainerRequest.ContainerType (*CreateContainerRequest)(nil), // 1: api.CreateContainerRequest @@ -2813,81 +2972,88 @@ var file_api_pb_docker_proto_goTypes = []any{ (*RemoveVolumeRequest)(nil), // 36: api.RemoveVolumeRequest (*CreateServiceContainerRequest)(nil), // 37: api.CreateServiceContainerRequest (*ServiceContainer)(nil), // 38: api.ServiceContainer - (*ListServiceContainersRequest)(nil), // 39: api.ListServiceContainersRequest - (*ListServiceContainersResponse)(nil), // 40: api.ListServiceContainersResponse - (*MachineServiceContainers)(nil), // 41: api.MachineServiceContainers - (*Metadata)(nil), // 42: api.Metadata - (*LogsRequest)(nil), // 43: api.LogsRequest - (*emptypb.Empty)(nil), // 44: google.protobuf.Empty - (*LogEntry)(nil), // 45: api.LogEntry + (*UpdateServiceContainerRequest)(nil), // 39: api.UpdateServiceContainerRequest + (*Labels)(nil), // 40: api.Labels + (*ListServiceContainersRequest)(nil), // 41: api.ListServiceContainersRequest + (*ListServiceContainersResponse)(nil), // 42: api.ListServiceContainersResponse + (*MachineServiceContainers)(nil), // 43: api.MachineServiceContainers + nil, // 44: api.Labels.LabelsEntry + (*Metadata)(nil), // 45: api.Metadata + (*LogsRequest)(nil), // 46: api.LogsRequest + (*emptypb.Empty)(nil), // 47: google.protobuf.Empty + (*LogEntry)(nil), // 48: api.LogEntry } var file_api_pb_docker_proto_depIdxs = []int32{ 9, // 0: api.ListContainersResponse.messages:type_name -> api.MachineContainers - 42, // 1: api.MachineContainers.metadata:type_name -> api.Metadata + 45, // 1: api.MachineContainers.metadata:type_name -> api.Metadata 12, // 2: api.ExecContainerRequest.config:type_name -> api.ExecConfig 13, // 3: api.ExecContainerRequest.resize:type_name -> api.ResizeEvent 19, // 4: api.InspectImageResponse.messages:type_name -> api.Image - 42, // 5: api.Image.metadata:type_name -> api.Metadata + 45, // 5: api.Image.metadata:type_name -> api.Metadata 22, // 6: api.InspectRemoteImageResponse.messages:type_name -> api.RemoteImage - 42, // 7: api.RemoteImage.metadata:type_name -> api.Metadata + 45, // 7: api.RemoteImage.metadata:type_name -> api.Metadata 25, // 8: api.RemoveImageRequest.platforms:type_name -> api.ImagePlatform 27, // 9: api.RemoveImageResponse.messages:type_name -> api.MachineImageRemoval - 42, // 10: api.MachineImageRemoval.metadata:type_name -> api.Metadata + 45, // 10: api.MachineImageRemoval.metadata:type_name -> api.Metadata 28, // 11: api.MachineImageRemoval.results:type_name -> api.ImageRemoval 30, // 12: api.ListImagesResponse.messages:type_name -> api.MachineImages - 42, // 13: api.MachineImages.metadata:type_name -> api.Metadata + 45, // 13: api.MachineImages.metadata:type_name -> api.Metadata 35, // 14: api.ListVolumesResponse.messages:type_name -> api.MachineVolumes - 42, // 15: api.MachineVolumes.metadata:type_name -> api.Metadata + 45, // 15: api.MachineVolumes.metadata:type_name -> api.Metadata 0, // 16: api.CreateServiceContainerRequest.container_type:type_name -> api.CreateServiceContainerRequest.ContainerType - 41, // 17: api.ListServiceContainersResponse.messages:type_name -> api.MachineServiceContainers - 42, // 18: api.MachineServiceContainers.metadata:type_name -> api.Metadata - 38, // 19: api.MachineServiceContainers.containers:type_name -> api.ServiceContainer - 38, // 20: api.MachineServiceContainers.hook_containers:type_name -> api.ServiceContainer - 1, // 21: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest - 3, // 22: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest - 5, // 23: api.Docker.StartContainer:input_type -> api.StartContainerRequest - 6, // 24: api.Docker.StopContainer:input_type -> api.StopContainerRequest - 7, // 25: api.Docker.ListContainers:input_type -> api.ListContainersRequest - 10, // 26: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest - 11, // 27: api.Docker.ExecContainer:input_type -> api.ExecContainerRequest - 43, // 28: api.Docker.ContainerLogs:input_type -> api.LogsRequest - 15, // 29: api.Docker.PullImage:input_type -> api.PullImageRequest - 17, // 30: api.Docker.InspectImage:input_type -> api.InspectImageRequest - 20, // 31: api.Docker.InspectRemoteImage:input_type -> api.InspectRemoteImageRequest - 23, // 32: api.Docker.ListImages:input_type -> api.ListImagesRequest - 24, // 33: api.Docker.RemoveImage:input_type -> api.RemoveImageRequest - 31, // 34: api.Docker.CreateVolume:input_type -> api.CreateVolumeRequest - 33, // 35: api.Docker.ListVolumes:input_type -> api.ListVolumesRequest - 36, // 36: api.Docker.RemoveVolume:input_type -> api.RemoveVolumeRequest - 37, // 37: api.Docker.CreateServiceContainer:input_type -> api.CreateServiceContainerRequest - 3, // 38: api.Docker.InspectServiceContainer:input_type -> api.InspectContainerRequest - 39, // 39: api.Docker.ListServiceContainers:input_type -> api.ListServiceContainersRequest - 10, // 40: api.Docker.RemoveServiceContainer:input_type -> api.RemoveContainerRequest - 2, // 41: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse - 4, // 42: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse - 44, // 43: api.Docker.StartContainer:output_type -> google.protobuf.Empty - 44, // 44: api.Docker.StopContainer:output_type -> google.protobuf.Empty - 8, // 45: api.Docker.ListContainers:output_type -> api.ListContainersResponse - 44, // 46: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty - 14, // 47: api.Docker.ExecContainer:output_type -> api.ExecContainerResponse - 45, // 48: api.Docker.ContainerLogs:output_type -> api.LogEntry - 16, // 49: api.Docker.PullImage:output_type -> api.JSONMessage - 18, // 50: api.Docker.InspectImage:output_type -> api.InspectImageResponse - 21, // 51: api.Docker.InspectRemoteImage:output_type -> api.InspectRemoteImageResponse - 29, // 52: api.Docker.ListImages:output_type -> api.ListImagesResponse - 26, // 53: api.Docker.RemoveImage:output_type -> api.RemoveImageResponse - 32, // 54: api.Docker.CreateVolume:output_type -> api.CreateVolumeResponse - 34, // 55: api.Docker.ListVolumes:output_type -> api.ListVolumesResponse - 44, // 56: api.Docker.RemoveVolume:output_type -> google.protobuf.Empty - 2, // 57: api.Docker.CreateServiceContainer:output_type -> api.CreateContainerResponse - 38, // 58: api.Docker.InspectServiceContainer:output_type -> api.ServiceContainer - 40, // 59: api.Docker.ListServiceContainers:output_type -> api.ListServiceContainersResponse - 44, // 60: api.Docker.RemoveServiceContainer:output_type -> google.protobuf.Empty - 41, // [41:61] is the sub-list for method output_type - 21, // [21:41] is the sub-list for method input_type - 21, // [21:21] is the sub-list for extension type_name - 21, // [21:21] is the sub-list for extension extendee - 0, // [0:21] is the sub-list for field type_name + 40, // 17: api.UpdateServiceContainerRequest.service_labels:type_name -> api.Labels + 44, // 18: api.Labels.labels:type_name -> api.Labels.LabelsEntry + 43, // 19: api.ListServiceContainersResponse.messages:type_name -> api.MachineServiceContainers + 45, // 20: api.MachineServiceContainers.metadata:type_name -> api.Metadata + 38, // 21: api.MachineServiceContainers.containers:type_name -> api.ServiceContainer + 38, // 22: api.MachineServiceContainers.hook_containers:type_name -> api.ServiceContainer + 1, // 23: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest + 3, // 24: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest + 5, // 25: api.Docker.StartContainer:input_type -> api.StartContainerRequest + 6, // 26: api.Docker.StopContainer:input_type -> api.StopContainerRequest + 7, // 27: api.Docker.ListContainers:input_type -> api.ListContainersRequest + 10, // 28: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest + 11, // 29: api.Docker.ExecContainer:input_type -> api.ExecContainerRequest + 46, // 30: api.Docker.ContainerLogs:input_type -> api.LogsRequest + 15, // 31: api.Docker.PullImage:input_type -> api.PullImageRequest + 17, // 32: api.Docker.InspectImage:input_type -> api.InspectImageRequest + 20, // 33: api.Docker.InspectRemoteImage:input_type -> api.InspectRemoteImageRequest + 23, // 34: api.Docker.ListImages:input_type -> api.ListImagesRequest + 24, // 35: api.Docker.RemoveImage:input_type -> api.RemoveImageRequest + 31, // 36: api.Docker.CreateVolume:input_type -> api.CreateVolumeRequest + 33, // 37: api.Docker.ListVolumes:input_type -> api.ListVolumesRequest + 36, // 38: api.Docker.RemoveVolume:input_type -> api.RemoveVolumeRequest + 37, // 39: api.Docker.CreateServiceContainer:input_type -> api.CreateServiceContainerRequest + 3, // 40: api.Docker.InspectServiceContainer:input_type -> api.InspectContainerRequest + 39, // 41: api.Docker.UpdateServiceContainer:input_type -> api.UpdateServiceContainerRequest + 41, // 42: api.Docker.ListServiceContainers:input_type -> api.ListServiceContainersRequest + 10, // 43: api.Docker.RemoveServiceContainer:input_type -> api.RemoveContainerRequest + 2, // 44: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse + 4, // 45: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse + 47, // 46: api.Docker.StartContainer:output_type -> google.protobuf.Empty + 47, // 47: api.Docker.StopContainer:output_type -> google.protobuf.Empty + 8, // 48: api.Docker.ListContainers:output_type -> api.ListContainersResponse + 47, // 49: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty + 14, // 50: api.Docker.ExecContainer:output_type -> api.ExecContainerResponse + 48, // 51: api.Docker.ContainerLogs:output_type -> api.LogEntry + 16, // 52: api.Docker.PullImage:output_type -> api.JSONMessage + 18, // 53: api.Docker.InspectImage:output_type -> api.InspectImageResponse + 21, // 54: api.Docker.InspectRemoteImage:output_type -> api.InspectRemoteImageResponse + 29, // 55: api.Docker.ListImages:output_type -> api.ListImagesResponse + 26, // 56: api.Docker.RemoveImage:output_type -> api.RemoveImageResponse + 32, // 57: api.Docker.CreateVolume:output_type -> api.CreateVolumeResponse + 34, // 58: api.Docker.ListVolumes:output_type -> api.ListVolumesResponse + 47, // 59: api.Docker.RemoveVolume:output_type -> google.protobuf.Empty + 2, // 60: api.Docker.CreateServiceContainer:output_type -> api.CreateContainerResponse + 38, // 61: api.Docker.InspectServiceContainer:output_type -> api.ServiceContainer + 47, // 62: api.Docker.UpdateServiceContainer:output_type -> google.protobuf.Empty + 42, // 63: api.Docker.ListServiceContainers:output_type -> api.ListServiceContainersResponse + 47, // 64: api.Docker.RemoveServiceContainer:output_type -> google.protobuf.Empty + 44, // [44:65] is the sub-list for method output_type + 23, // [23:44] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_api_pb_docker_proto_init() } @@ -3354,7 +3520,7 @@ func file_api_pb_docker_proto_init() { } } file_api_pb_docker_proto_msgTypes[38].Exporter = func(v any, i int) any { - switch v := v.(*ListServiceContainersRequest); i { + switch v := v.(*UpdateServiceContainerRequest); i { case 0: return &v.state case 1: @@ -3366,7 +3532,7 @@ func file_api_pb_docker_proto_init() { } } file_api_pb_docker_proto_msgTypes[39].Exporter = func(v any, i int) any { - switch v := v.(*ListServiceContainersResponse); i { + switch v := v.(*Labels); i { case 0: return &v.state case 1: @@ -3378,6 +3544,30 @@ func file_api_pb_docker_proto_init() { } } file_api_pb_docker_proto_msgTypes[40].Exporter = func(v any, i int) any { + switch v := v.(*ListServiceContainersRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_api_pb_docker_proto_msgTypes[41].Exporter = func(v any, i int) any { + switch v := v.(*ListServiceContainersResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_api_pb_docker_proto_msgTypes[42].Exporter = func(v any, i int) any { switch v := v.(*MachineServiceContainers); i { case 0: return &v.state @@ -3401,13 +3591,14 @@ func file_api_pb_docker_proto_init() { (*ExecContainerResponse_Stderr)(nil), (*ExecContainerResponse_ExitCode)(nil), } + file_api_pb_docker_proto_msgTypes[38].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_api_pb_docker_proto_rawDesc, NumEnums: 1, - NumMessages: 41, + NumMessages: 44, NumExtensions: 0, NumServices: 1, }, diff --git a/api/pb/docker.proto b/api/pb/docker.proto index 21878df9..f0df308b 100644 --- a/api/pb/docker.proto +++ b/api/pb/docker.proto @@ -32,6 +32,8 @@ service Docker { rpc CreateServiceContainer(CreateServiceContainerRequest) returns (CreateContainerResponse); rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer); + // UpdateServiceContainer updates the requested properties of a service container in place without recreating it. + rpc UpdateServiceContainer(UpdateServiceContainerRequest) returns (google.protobuf.Empty); rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse); rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty); } @@ -281,6 +283,23 @@ message ServiceContainer { bytes service_spec = 2; } +message UpdateServiceContainerRequest { + string id = 1; + // Fields that are not set are left unchanged. CPU and memory limits can be changed but not removed. + // CPU limit in nanocores. + optional int64 cpu = 2; + // Memory limit in bytes. + optional int64 memory = 3; + // Memory reservation in bytes. + optional int64 memory_reservation = 4; + // Service labels that replace all current service labels. + Labels service_labels = 5; +} + +message Labels { + map labels = 1; +} + message ListServiceContainersRequest { string service_id = 1; // JSON serialised container.ListOptions. diff --git a/api/pb/docker_grpc.pb.go b/api/pb/docker_grpc.pb.go index 0d70cb67..4199b4b8 100644 --- a/api/pb/docker_grpc.pb.go +++ b/api/pb/docker_grpc.pb.go @@ -38,6 +38,7 @@ const ( Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume" Docker_CreateServiceContainer_FullMethodName = "/api.Docker/CreateServiceContainer" Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer" + Docker_UpdateServiceContainer_FullMethodName = "/api.Docker/UpdateServiceContainer" Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers" Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer" ) @@ -66,6 +67,8 @@ type DockerClient interface { RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) CreateServiceContainer(ctx context.Context, in *CreateServiceContainerRequest, opts ...grpc.CallOption) (*CreateContainerResponse, error) InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error) + // UpdateServiceContainer updates the requested properties of a service container in place without recreating it. + UpdateServiceContainer(ctx context.Context, in *UpdateServiceContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) } @@ -279,6 +282,16 @@ func (c *dockerClient) InspectServiceContainer(ctx context.Context, in *InspectC return out, nil } +func (c *dockerClient) UpdateServiceContainer(ctx context.Context, in *UpdateServiceContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, Docker_UpdateServiceContainer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *dockerClient) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListServiceContainersResponse) @@ -323,6 +336,8 @@ type DockerServer interface { RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error) CreateServiceContainer(context.Context, *CreateServiceContainerRequest) (*CreateContainerResponse, error) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) + // UpdateServiceContainer updates the requested properties of a service container in place without recreating it. + UpdateServiceContainer(context.Context, *UpdateServiceContainerRequest) (*emptypb.Empty, error) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) mustEmbedUnimplementedDockerServer() @@ -389,6 +404,9 @@ func (UnimplementedDockerServer) CreateServiceContainer(context.Context, *Create func (UnimplementedDockerServer) InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) { return nil, status.Errorf(codes.Unimplemented, "method InspectServiceContainer not implemented") } +func (UnimplementedDockerServer) UpdateServiceContainer(context.Context, *UpdateServiceContainerRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateServiceContainer not implemented") +} func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListServiceContainers not implemented") } @@ -715,6 +733,24 @@ func _Docker_InspectServiceContainer_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _Docker_UpdateServiceContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateServiceContainerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DockerServer).UpdateServiceContainer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Docker_UpdateServiceContainer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DockerServer).UpdateServiceContainer(ctx, req.(*UpdateServiceContainerRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Docker_ListServiceContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListServiceContainersRequest) if err := dec(in); err != nil { @@ -818,6 +854,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{ MethodName: "InspectServiceContainer", Handler: _Docker_InspectServiceContainer_Handler, }, + { + MethodName: "UpdateServiceContainer", + Handler: _Docker_UpdateServiceContainer_Handler, + }, { MethodName: "ListServiceContainers", Handler: _Docker_ListServiceContainers_Handler, diff --git a/internal/machine/docker/controller.go b/internal/machine/docker/controller.go index df99aa21..b6e81be0 100644 --- a/internal/machine/docker/controller.go +++ b/internal/machine/docker/controller.go @@ -129,6 +129,11 @@ func (c *Controller) WatchAndSyncContainers(ctx context.Context) error { "container_name", e.Actor.Attributes["name"], "action", e.Action) + if err := c.syncContainersToStore(ctx); err != nil { + return fmt.Errorf("sync containers to cluster store: %w", err) + } + case <-c.service.ContainersChanged(): + slog.Debug("Syncing containers to cluster store triggered by a container change in machine database.") if err := c.syncContainersToStore(ctx); err != nil { return fmt.Errorf("sync containers to cluster store: %w", err) } diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 6108528f..b5b10ff9 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -594,21 +594,26 @@ func (s *Server) CreateServiceContainer( } } + // Apply user-defined labels first so that the system labels always take precedence. + labels := maps.Clone(spec.Container.Labels) + if labels == nil { + labels = make(map[string]string) + } + labels[api.LabelServiceID] = req.ServiceId + labels[api.LabelServiceName] = spec.Name + labels[api.LabelServiceMode] = spec.Mode + labels[api.LabelManaged] = "" + config := &container.Config{ Cmd: spec.Container.Command, Env: envVars.ToSlice(), Entrypoint: spec.Container.Entrypoint, Hostname: containerName, Image: spec.Container.Image, - Labels: map[string]string{ - api.LabelServiceID: req.ServiceId, - api.LabelServiceName: spec.Name, - api.LabelServiceMode: spec.Mode, - api.LabelManaged: "", - }, - User: spec.Container.User, - Tty: spec.Container.Tty, - OpenStdin: spec.Container.OpenStdin, + Labels: labels, + User: spec.Container.User, + Tty: spec.Container.Tty, + OpenStdin: spec.Container.OpenStdin, } if spec.Mode == "" { config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated @@ -729,12 +734,10 @@ func (s *Server) CreateServiceContainer( // Strip the container configuration that doesn't make sense for the pre-deploy hook and apply its overrides. if spec.PreDeploy != nil && req.ContainerType == pb.CreateServiceContainerRequest_PRE_DEPLOY { - config.Labels = map[string]string{ - api.LabelServiceID: req.ServiceId, - api.LabelServiceName: spec.Name, - api.LabelHook: api.LabelHookPreDeploy, - api.LabelManaged: "", - } + // Keep the user-defined labels but replace the service mode and ports labels with the hook label. + delete(config.Labels, api.LabelServiceMode) + delete(config.Labels, api.LabelServicePorts) + config.Labels[api.LabelHook] = api.LabelHookPreDeploy config.Healthcheck = &container.HealthConfig{ Test: []string{"NONE"}, } @@ -1179,6 +1182,36 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta return resp, nil } +// UpdateServiceContainer updates the requested properties of a service container in place without recreating it. +func (s *Server) UpdateServiceContainer( + ctx context.Context, req *pb.UpdateServiceContainerRequest, +) (*emptypb.Empty, error) { + opts := api.UpdateContainerOptions{ + CPU: req.Cpu, + Memory: req.Memory, + MemoryReservation: req.MemoryReservation, + } + if req.ServiceLabels != nil { + labels := req.ServiceLabels.Labels + opts.ServiceLabels = &labels + } + + if err := s.service.UpdateServiceContainer(ctx, req.Id, opts); err != nil { + switch { + case errdefs.IsNotFound(err): + return nil, status.Error(codes.NotFound, err.Error()) + case errdefs.IsInvalidArgument(err): + return nil, status.Error(codes.InvalidArgument, err.Error()) + case errdefs.IsFailedPrecondition(err): + return nil, status.Error(codes.FailedPrecondition, err.Error()) + default: + return nil, status.Error(codes.Internal, err.Error()) + } + } + + return &emptypb.Empty{}, nil +} + // logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream. const logsHeartbeatInterval = 200 * time.Millisecond diff --git a/internal/machine/docker/service.go b/internal/machine/docker/service.go index d4e5cd94..1f169235 100644 --- a/internal/machine/docker/service.go +++ b/internal/machine/docker/service.go @@ -15,6 +15,7 @@ import ( "time" "github.com/cenkalti/backoff/v4" + "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/filters" @@ -34,6 +35,9 @@ type Service struct { Client client.APIClient // db is a connection to the machine database. db *sqlx.DB + // containersChanged signals that service containers changed in the machine database without a corresponding + // Docker event, for example, when a container spec is updated in place. + containersChanged chan struct{} } // NewService creates a new Docker service instance. @@ -41,9 +45,26 @@ func NewService(client client.APIClient, db *sqlx.DB) *Service { return &Service{ Client: client, db: db, + // Buffering as a pending signal is enough for the consumer to pick up all changes made before it reacts. + containersChanged: make(chan struct{}, 1), } } +// NotifyContainersChanged signals that service containers changed in the machine database. It doesn't block +// if a signal is already pending. +func (s *Service) NotifyContainersChanged() { + select { + case s.containersChanged <- struct{}{}: + default: + } +} + +// ContainersChanged returns a channel that receives a signal when service containers change in the machine database +// without a corresponding Docker event. +func (s *Service) ContainersChanged() <-chan struct{} { + return s.containersChanged +} + // WatchDaemonRestart watches the Docker engine and signals on the returned channel whenever the daemon // becomes reachable again after a disconnect, which typically indicates a restart. func (s *Service) WatchDaemonRestart(ctx context.Context) <-chan struct{} { @@ -141,6 +162,90 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string) return serviceCtr, nil } +// UpdateServiceContainer updates the requested properties of a service container in place without recreating it. +// It applies the CPU and memory limits to the container and updates the service spec stored for the container. +func (s *Service) UpdateServiceContainer(ctx context.Context, id string, opts api.UpdateContainerOptions) error { + if opts.ServiceLabels != nil { + if err := api.ValidateLabels(*opts.ServiceLabels); err != nil { + return errdefs.ErrInvalidArgument.WithMessage(fmt.Sprintf("invalid service labels: %v", err)) + } + } + + ctr, err := s.InspectServiceContainer(ctx, id) + if err != nil { + return err + } + spec := ctr.ServiceSpec + + resources := spec.Container.Resources + if opts.CPU != nil { + resources.CPU = *opts.CPU + } + if opts.Memory != nil { + resources.Memory = *opts.Memory + } + if opts.MemoryReservation != nil { + resources.MemoryReservation = *opts.MemoryReservation + } + if resources.CPU < 0 || resources.Memory < 0 || resources.MemoryReservation < 0 { + return errdefs.ErrInvalidArgument.WithMessage("CPU and memory limits must not be negative") + } + // Compare with the limits Docker applies to the container rather than the stored spec. They may differ if + // a previous update applied the limits but failed to store the spec, or the container was updated outside + // of Uncloud. + applied := ctr.AppliedResources() + if err = applied.ValidateUpdateInPlace(resources); err != nil { + return errdefs.ErrFailedPrecondition.WithMessage( + fmt.Sprintf("container '%s' can't be updated without recreating it: %v", ctr.ID, err)) + } + + // Only CPU and memory limits can differ after the in-place update check above. + if !applied.Equals(resources) { + updateResources := container.Resources{ + NanoCPUs: resources.CPU, + Memory: resources.Memory, + MemoryReservation: resources.MemoryReservation, + } + // Docker defaults the swap limit to twice the memory limit when creating a container and rejects raising + // the memory limit above the current swap limit, so update both to keep the same semantics. + if resources.Memory > 0 { + updateResources.MemorySwap = 2 * resources.Memory + } + if _, err = s.Client.ContainerUpdate(ctx, ctr.ID, container.UpdateConfig{ + Resources: updateResources, + }); err != nil { + return fmt.Errorf("update container resources: %w", err) + } + } + spec.Container.Resources = resources + + if opts.ServiceLabels != nil { + spec.Labels = nil + if len(*opts.ServiceLabels) > 0 { + spec.Labels = *opts.ServiceLabels + } + } + + specBytes, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("marshal service spec: %w", err) + } + res, err := s.db.ExecContext(ctx, + `UPDATE containers SET service_spec = $1, updated_at = datetime('subsecond') WHERE id = $2`, + string(specBytes), ctr.ID) + if err != nil { + return fmt.Errorf("update container in database: %w", err) + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return errdefs.ErrNotFound.WithMessage(fmt.Sprintf("container '%s' not found in machine database", ctr.ID)) + } + + // Propagate the updated spec to the cluster store without waiting for the next regular sync. + s.NotifyContainersChanged() + + return nil +} + // ListServiceContainersResult holds the result of listing service containers, split into regular // service containers and one-off hook containers. type ListServiceContainersResult struct { diff --git a/pkg/api/client.go b/pkg/api/client.go index 7f439396..736199d2 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -30,6 +30,7 @@ type ContainerClient interface { StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error + UpdateContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts UpdateContainerOptions) error WaitContainerHealthy( ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions, ) error diff --git a/pkg/api/container.go b/pkg/api/container.go index 7fb83f09..22129f8a 100644 --- a/pkg/api/container.go +++ b/pkg/api/container.go @@ -19,6 +19,11 @@ const ( // in internal/machine/docker/manager.go. DockerNetworkName = "uncloud" + // LabelReservedPrefix is the prefix of labels reserved for Uncloud that users can't set on services and containers. + LabelReservedPrefix = "uncloud." + // LabelDaemonReservedPrefix is the prefix of labels reserved for the Uncloud daemon system containers. + LabelDaemonReservedPrefix = "uncloudd." + // LabelDaemonManaged marks system containers as managed by the Uncloud daemon. LabelDaemonManaged = "uncloudd.managed" @@ -175,6 +180,19 @@ type CreateContainerResponse struct { Name string } +// UpdateContainerOptions defines the properties of a service container to update in place without recreating it. +// Nil fields are left unchanged. +type UpdateContainerOptions struct { + // CPU is the new CPU limit in nanocores. The limit can be changed but not removed. + CPU *int64 + // Memory is the new memory limit in bytes. The limit can be changed but not removed. + Memory *int64 + // MemoryReservation is the new memory reservation in bytes. The reservation can be changed but not removed. + MemoryReservation *int64 + // ServiceLabels replaces all service labels. A pointer to an empty map removes all service labels. + ServiceLabels *map[string]string +} + type ServiceContainer struct { Container ServiceSpec ServiceSpec @@ -195,6 +213,21 @@ func (c *ServiceContainer) ServiceName() string { return c.Config.Labels[LabelServiceName] } +// AppliedResources returns the resources from the service spec with the CPU and memory limits that Docker currently +// applies to the container. The applied limits may differ from the spec if an in-place update was interrupted or +// the container was updated outside of Uncloud. +func (c *ServiceContainer) AppliedResources() ContainerResources { + resources := c.ServiceSpec.Container.Resources + if c.ContainerJSONBase == nil || c.HostConfig == nil { + return resources + } + + resources.CPU = c.HostConfig.NanoCPUs + resources.Memory = c.HostConfig.Memory + resources.MemoryReservation = c.HostConfig.MemoryReservation + return resources +} + // ServiceMode returns the replication mode of the service this container belongs to. func (c *ServiceContainer) ServiceMode() string { return c.ServiceSpec.Mode diff --git a/pkg/api/resources.go b/pkg/api/resources.go index 12744a67..bf334364 100644 --- a/pkg/api/resources.go +++ b/pkg/api/resources.go @@ -1,7 +1,11 @@ package api import ( + "errors" + "github.com/docker/docker/api/types/container" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" ) const ( @@ -27,6 +31,35 @@ type ContainerResources struct { Ulimits map[string]Ulimit } +// Equals returns true if the resources are equal, treating nil and empty slices and maps as equal. +func (r ContainerResources) Equals(other ContainerResources) bool { + return cmp.Equal(r, other, cmpopts.EquateEmpty()) +} + +// ValidateUpdateInPlace returns an error explaining why a container created with these resources can't be updated +// to the new resources without recreating it. It returns nil if the container can be updated in place. +// Docker can only change the CPU and memory limits of an existing container and can't remove a limit once it's set. +func (r ContainerResources) ValidateUpdateInPlace(new ContainerResources) error { + // Docker treats zero values as unchanged when updating a container. + if r.CPU != 0 && new.CPU == 0 { + return errors.New("CPU limit can't be removed") + } + if r.Memory != 0 && new.Memory == 0 { + return errors.New("memory limit can't be removed") + } + if r.MemoryReservation != 0 && new.MemoryReservation == 0 { + return errors.New("memory reservation can't be removed") + } + + r.CPU = new.CPU + r.Memory = new.Memory + r.MemoryReservation = new.MemoryReservation + if !r.Equals(new) { + return errors.New("only CPU and memory limits can be changed") + } + return nil +} + // DeviceMapping represents a device mapping between host and container. type DeviceMapping struct { // HostPath is the path to the device on the host. diff --git a/pkg/api/resources_test.go b/pkg/api/resources_test.go new file mode 100644 index 00000000..cf1ec2cb --- /dev/null +++ b/pkg/api/resources_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainerResources_ValidateUpdateInPlace(t *testing.T) { + t.Parallel() + + current := ContainerResources{ + CPU: Core, + Memory: 512 * 1024 * 1024, + MemoryReservation: 256 * 1024 * 1024, + } + + tests := []struct { + name string + new func(r *ContainerResources) + wantErr string + }{ + { + name: "same resources", + new: func(*ContainerResources) {}, + }, + { + name: "change CPU and memory limits", + new: func(r *ContainerResources) { + r.CPU = 2 * Core + r.Memory = 1024 * 1024 * 1024 + r.MemoryReservation = 128 * 1024 * 1024 + }, + }, + { + name: "remove CPU limit", + new: func(r *ContainerResources) { r.CPU = 0 }, + wantErr: "CPU limit can't be removed", + }, + { + name: "remove memory limit", + new: func(r *ContainerResources) { r.Memory = 0 }, + wantErr: "memory limit can't be removed", + }, + { + name: "remove memory reservation", + new: func(r *ContainerResources) { r.MemoryReservation = 0 }, + wantErr: "memory reservation can't be removed", + }, + { + name: "change shared memory", + new: func(r *ContainerResources) { r.SharedMemory = 64 * 1024 * 1024 }, + wantErr: "only CPU and memory limits can be changed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + newResources := current + tt.new(&newResources) + + err := current.ValidateUpdateInPlace(newResources) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.wantErr) + } + }) + } +} diff --git a/pkg/api/service.go b/pkg/api/service.go index 5d9ff21f..4bd8ac0d 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -77,6 +77,9 @@ type ServiceSpec struct { Configs []ConfigSpec // Container defines the desired state of each container in the service. Container ContainerSpec + // Labels are user-defined key-value metadata attached to the service. Unlike container labels, they're not + // applied to Docker containers. Keys must not use the reserved "uncloud." prefix. + Labels map[string]string // Mode is the replication mode of the service. Default is ServiceModeReplicated if empty. Mode string Name string @@ -157,6 +160,9 @@ func (s *ServiceSpec) Validate() error { if err := s.Container.Validate(); err != nil { return err } + if err := ValidateLabels(s.Labels); err != nil { + return fmt.Errorf("invalid service labels: %w", err) + } switch s.Mode { case "", ServiceModeGlobal, ServiceModeReplicated: @@ -243,6 +249,7 @@ func (s *ServiceSpec) Clone() ServiceSpec { spec.Caddy = &caddyCopy } spec.Container = s.Container.Clone() + spec.Labels = maps.Clone(s.Labels) spec.PreDeploy = s.PreDeploy.Clone() if s.Ports != nil { @@ -279,6 +286,10 @@ type ContainerSpec struct { Image string // Run a custom init inside the container. If nil, use the daemon's configured settings. Init *bool + // Labels are user-defined key-value metadata applied as Docker labels to each container in the service. + // Docker labels are immutable so changing them requires recreating the containers. + // Keys must not use the reserved "uncloud." prefix. + Labels map[string]string // LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default. LogDriver *LogDriver // PidMode sets the PID namespace mode for the container. Currently only "" or "host" is supported. @@ -333,6 +344,9 @@ func (s *ContainerSpec) Validate() error { if _, err := reference.ParseDockerRef(s.Image); err != nil { return fmt.Errorf("invalid image '%s': %w", s.Image, err) } + if err := ValidateLabels(s.Labels); err != nil { + return fmt.Errorf("invalid container labels: %w", err) + } for _, m := range s.VolumeMounts { if err := m.Validate(); err != nil { @@ -390,6 +404,7 @@ func (s *ContainerSpec) Clone() ContainerSpec { hc.Test = slices.Clone(s.Healthcheck.Test) spec.Healthcheck = &hc } + spec.Labels = maps.Clone(s.Labels) if s.LogDriver != nil { logDriver := *s.LogDriver if s.LogDriver.Options != nil { @@ -428,6 +443,20 @@ func (s *ContainerSpec) Clone() ContainerSpec { return spec } +// ValidateLabels checks that label keys are non-empty and don't use the reserved Uncloud prefixes. +func ValidateLabels(labels map[string]string) error { + for k := range labels { + if strings.TrimSpace(k) == "" { + return fmt.Errorf("label key must not be empty") + } + if strings.HasPrefix(k, LabelReservedPrefix) || strings.HasPrefix(k, LabelDaemonReservedPrefix) { + return fmt.Errorf("label '%s' cannot use the reserved prefix '%s' or '%s'", + k, LabelReservedPrefix, LabelDaemonReservedPrefix) + } + } + return nil +} + type EnvVars map[string]string // ToSlice converts the environment variables to a slice of strings in the format "key=value". diff --git a/pkg/api/service_test.go b/pkg/api/service_test.go index 637eb770..0870393a 100644 --- a/pkg/api/service_test.go +++ b/pkg/api/service_test.go @@ -63,6 +63,47 @@ func TestServiceSpec_Validate_Name(t *testing.T) { } } +func TestServiceSpec_Validate_Labels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labels map[string]string + wantErr string + }{ + {name: "nil labels"}, + {name: "valid labels", labels: map[string]string{"com.example.team": "web", "tier": ""}}, + {name: "prefix without dot", labels: map[string]string{"uncloudfoo": "bar"}}, + {name: "empty key", labels: map[string]string{"": "bar"}, wantErr: "must not be empty"}, + {name: "reserved prefix", labels: map[string]string{LabelManaged: ""}, wantErr: "reserved prefix"}, + {name: "reserved daemon prefix", labels: map[string]string{"uncloudd.foo": "bar"}, wantErr: "reserved prefix"}, + } + for _, tt := range tests { + t.Run(tt.name+"/service", func(t *testing.T) { + t.Parallel() + spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest"}, Labels: tt.labels} + err := spec.Validate() + if tt.wantErr != "" { + require.ErrorContains(t, err, "invalid service labels") + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + t.Run(tt.name+"/container", func(t *testing.T) { + t.Parallel() + spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest", Labels: tt.labels}} + err := spec.Validate() + if tt.wantErr != "" { + require.ErrorContains(t, err, "invalid container labels") + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) { tests := []struct { name string @@ -267,8 +308,9 @@ func TestContainerSpec_Clone(t *testing.T) { "FOO": "bar", "BAZ": "qux", }, - Image: "nginx:latest", - Init: new(true), + Image: "nginx:latest", + Init: new(true), + Labels: map[string]string{"com.example.team": "web"}, LogDriver: &LogDriver{ Name: "json-file", Options: map[string]string{ @@ -313,6 +355,7 @@ func TestContainerSpec_Clone(t *testing.T) { original.Command[0] = stringModified original.Entrypoint[0] = stringModified original.Env["FOO"] = stringModified + original.Labels["com.example.team"] = stringModified original.LogDriver.Options["max-size"] = stringModified original.Volumes[0] = stringModified original.VolumeMounts[0].ContainerPath = stringModified @@ -328,6 +371,7 @@ func TestContainerSpec_Clone(t *testing.T) { assert.Equal(t, "NET_ADMIN", cloned.CapAdd[0]) assert.Equal(t, "ALL", cloned.CapDrop[0]) assert.Equal(t, "sh", cloned.Command[0]) + assert.Equal(t, "web", cloned.Labels["com.example.team"]) assert.Equal(t, "/bin/bash", cloned.Entrypoint[0]) assert.Equal(t, "bar", cloned.Env["FOO"]) assert.Equal(t, "qux", cloned.Env["BAZ"]) diff --git a/pkg/client/compose/plan.go b/pkg/client/compose/plan.go index 27b18827..5e8cbd44 100644 --- a/pkg/client/compose/plan.go +++ b/pkg/client/compose/plan.go @@ -55,7 +55,7 @@ func (p *Plan) Format() string { // formatSummary counts all operations across the plan and renders the summary footer. func (p *Plan) formatSummary() string { - var createCount, startFirstCount, stopFirstCount, removeCount int + var createCount, startFirstCount, stopFirstCount, updateCount, removeCount int machines := make(map[string]struct{}) for _, op := range p.Volumes { @@ -76,6 +76,9 @@ func (p *Plan) formatSummary() string { } else { startFirstCount++ } + case *operation.UpdateContainerOperation: + machines[o.MachineID] = struct{}{} + updateCount++ case *operation.RemoveContainerOperation: machines[o.MachineID] = struct{}{} removeCount++ @@ -99,6 +102,10 @@ func (p *Plan) formatSummary() string { parts = append(parts, tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)")) } + if updateCount > 0 { + parts = append(parts, + tui.BoldYellow.Render(strconv.Itoa(updateCount))+" "+tui.Yellow.Render("update (in place)")) + } if removeCount > 0 { parts = append(parts, tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove")) diff --git a/pkg/client/compose/plan_test.go b/pkg/client/compose/plan_test.go new file mode 100644 index 00000000..1bfc58ea --- /dev/null +++ b/pkg/client/compose/plan_test.go @@ -0,0 +1,30 @@ +package compose + +import ( + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/psviderski/uncloud/pkg/client/deploy" + "github.com/psviderski/uncloud/pkg/client/operation" + "github.com/stretchr/testify/assert" +) + +func TestPlan_FormatSummary(t *testing.T) { + t.Parallel() + + web := &deploy.ServicePlan{} + web.Operations = []operation.Operation{ + &operation.UpdateContainerOperation{MachineID: "m-1"}, + &operation.RemoveContainerOperation{MachineID: "m-2"}, + } + db := &deploy.ServicePlan{} + db.Operations = []operation.Operation{ + &operation.UpdateContainerOperation{MachineID: "m-2"}, + } + plan := &Plan{ + Volumes: []*operation.CreateVolumeOperation{{MachineID: "m-3"}}, + Services: []*deploy.ServicePlan{web, db}, + } + + assert.Equal(t, "1 create · 2 update (in place) · 1 remove · across 3 machines", ansi.Strip(plan.formatSummary())) +} diff --git a/pkg/client/compose/project_test.go b/pkg/client/compose/project_test.go index bf8cc188..33ad087e 100644 --- a/pkg/client/compose/project_test.go +++ b/pkg/client/compose/project_test.go @@ -344,20 +344,6 @@ configs: warnCount: 1, warnContains: []string{"container_name"}, }, - { - name: "deploy labels", - composeYAML: `services: - app: - image: myapp:latest - deploy: - mode: replicated - labels: - - foo - replicas: 2 -`, - warnCount: 1, - warnContains: []string{"deploy labels"}, - }, { name: "deploy restart_policy", composeYAML: `services: diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index b631b012..7838a79d 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -66,6 +66,10 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser Mode: api.ServiceModeReplicated, } + if labels := mergeLabels(service.Labels, service.CustomLabels); len(labels) > 0 { + spec.Container.Labels = labels + } + // Map x-caddy extension to spec.Caddy if specified. if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" { spec.Caddy = &api.CaddySpec{ @@ -124,6 +128,10 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser d := time.Duration(cfg.Monitor) spec.UpdateConfig.MonitorPeriod = &d } + + if len(service.Deploy.Labels) > 0 { + spec.Labels = maps.Clone(service.Deploy.Labels) + } } // TODO: can service.tmpfs be handled as tmpfs volume mounts as well? @@ -480,9 +488,6 @@ func validateServicesFeatures(project *types.Project) []error { if service.DNSSearch != nil { errs = append(errs, err(service.Name, "dns_search")) } - if service.Labels != nil { - errs = append(errs, err(service.Name, "labels")) - } if service.Links != nil { errs = append(errs, err(service.Name, "links")) } @@ -517,9 +522,6 @@ func validateServicesFeatures(project *types.Project) []error { if service.Deploy.RollbackConfig != nil { errs = append(errs, err(service.Name, "deploy rollback_config")) } - if len(service.Deploy.Labels) != 0 { - errs = append(errs, err(service.Name, "deploy labels")) - } } // we only allow the 'default' network, nothing else. if x := service.Networks; x != nil { diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index ff863283..a8d4b681 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -875,6 +875,81 @@ services: } } +func TestServiceSpecFromCompose_Labels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + composeYAML string + expectedContainerLabels map[string]string + expectedServiceLabels map[string]string + }{ + { + name: "no labels", + composeYAML: ` +services: + web: + image: nginx +`, + }, + { + name: "container labels as list", + composeYAML: ` +services: + web: + image: nginx + labels: + - com.example.team=web + - com.example.empty +`, + expectedContainerLabels: map[string]string{ + "com.example.team": "web", + "com.example.empty": "", + }, + }, + { + name: "container and service labels as maps", + composeYAML: ` +services: + web: + image: nginx + labels: + com.example.team: web + deploy: + labels: + tier: frontend +`, + expectedContainerLabels: map[string]string{"com.example.team": "web"}, + expectedServiceLabels: map[string]string{"tier": "frontend"}, + }, + { + name: "service labels only", + composeYAML: ` +services: + web: + image: nginx + deploy: + labels: + - tier=frontend +`, + expectedServiceLabels: map[string]string{"tier": "frontend"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project, err := LoadProjectFromContent(context.Background(), tt.composeYAML) + require.NoError(t, err) + + spec, err := ServiceSpecFromCompose(project, "web") + require.NoError(t, err) + + assert.Equal(t, tt.expectedContainerLabels, spec.Container.Labels) + assert.Equal(t, tt.expectedServiceLabels, spec.Labels) + }) + } +} + func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) { t.Parallel() diff --git a/pkg/client/container.go b/pkg/client/container.go index 023a1683..7080882c 100644 --- a/pkg/client/container.go +++ b/pkg/client/container.go @@ -343,6 +343,39 @@ func (cli *Client) RemoveContainer( return nil } +// UpdateContainer updates the requested properties of the container within the service in place without recreating it. +func (cli *Client) UpdateContainer( + ctx context.Context, serviceNameOrID, containerNameOrID string, opts api.UpdateContainerOptions, +) error { + op, err := cli.resolveContainerOperation(ctx, serviceNameOrID, containerNameOrID) + if err != nil { + return err + } + + req := &pb.UpdateServiceContainerRequest{ + Id: op.containerID, + Cpu: opts.CPU, + Memory: opts.Memory, + MemoryReservation: opts.MemoryReservation, + } + if opts.ServiceLabels != nil { + req.ServiceLabels = &pb.Labels{Labels: *opts.ServiceLabels} + } + + pw := progress.ContextWriter(op.ctx) + pw.Event(progress.NewEvent(op.eventID, progress.Working, "Updating")) + if _, err = cli.Docker.GRPCClient.UpdateServiceContainer(op.ctx, req); err != nil { + pw.Event(progress.ErrorEvent(op.eventID)) + if status.Code(err) == codes.NotFound { + return api.ErrNotFound + } + return err + } + pw.Event(progress.NewEvent(op.eventID, progress.Done, "Updated")) + + return nil +} + // ExecContainer executes a command in a container within the service. // If containerNameOrID is empty, the first container in the service will be used. func (cli *Client) ExecContainer( diff --git a/pkg/client/deploy/container.go b/pkg/client/deploy/container.go index bbde5d70..862514b5 100644 --- a/pkg/client/deploy/container.go +++ b/pkg/client/deploy/container.go @@ -1,7 +1,7 @@ package deploy import ( - "reflect" + "maps" "sort" "github.com/google/go-cmp/cmp" @@ -12,7 +12,8 @@ import ( type ContainerSpecStatus string const ( - ContainerUpToDate ContainerSpecStatus = "up-to-date" + ContainerUpToDate ContainerSpecStatus = "up-to-date" + // ContainerNeedsUpdate means the container can be updated in place without recreating it. ContainerNeedsUpdate ContainerSpecStatus = "needs-update" ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate" ) @@ -83,15 +84,7 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta } } - // Device reservations and mappings are immutable, so we'll need to recreate if any have changed - if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) { - return ContainerNeedsRecreate - } - if !reflect.DeepEqual(current.Container.Resources.Devices, newResources.Devices) { - return ContainerNeedsRecreate - } - // Ulimits are immutable, so we'll need to recreate if any have changed. - if !reflect.DeepEqual(current.Container.Resources.Ulimits, newResources.Ulimits) { + if current.Container.Resources.ValidateUpdateInPlace(newResources) != nil { return ContainerNeedsRecreate } @@ -100,8 +93,10 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta return ContainerNeedsRecreate } - // Remaining resources are mutable. - if !reflect.DeepEqual(current.Container.Resources, newResources) { + // CPU and memory limits are updated on the running container. Service labels are only stored in the service spec + // and not applied to Docker containers. + if !current.Container.Resources.Equals(newResources) || + !cmp.Equal(current.Labels, new.Labels, cmpopts.EquateEmpty()) { return ContainerNeedsUpdate } @@ -119,3 +114,53 @@ func sortConfigs(configs []api.ConfigSpec) { return configs[i].Name < configs[j].Name }) } + +// EvalContainerChange evaluates whether the running container matches the new spec, can be updated in place to +// match it, or needs to be recreated. Unlike EvalContainerSpecChange, it also compares the new spec with the CPU and +// memory limits that Docker applies to the container, so the container is updated when they drifted from its spec. +func EvalContainerChange(current api.ServiceContainer, new api.ServiceSpec) ContainerSpecStatus { + status := EvalContainerSpecChange(current.ServiceSpec, new) + if status == ContainerNeedsRecreate { + return status + } + + applied := current.AppliedResources() + newResources := new.Container.Resources + if applied.ValidateUpdateInPlace(newResources) != nil { + return ContainerNeedsRecreate + } + // Only CPU and memory limits can differ after the in-place update check above. + if !applied.Equals(newResources) { + return ContainerNeedsUpdate + } + return status +} + +// ContainerUpdateOptions returns the container properties that can be updated in place and differ between the new +// spec and either the container spec or the limits Docker applies to the container. It should only be used when +// EvalContainerChange returns ContainerNeedsUpdate. +func ContainerUpdateOptions(current api.ServiceContainer, new api.ServiceSpec) api.UpdateContainerOptions { + var opts api.UpdateContainerOptions + + stored, applied := current.ServiceSpec.Container.Resources, current.AppliedResources() + res := new.Container.Resources + if res.CPU != stored.CPU || res.CPU != applied.CPU { + opts.CPU = &res.CPU + } + if res.Memory != stored.Memory || res.Memory != applied.Memory { + opts.Memory = &res.Memory + } + if res.MemoryReservation != stored.MemoryReservation || res.MemoryReservation != applied.MemoryReservation { + opts.MemoryReservation = &res.MemoryReservation + } + + if !cmp.Equal(current.ServiceSpec.Labels, new.Labels, cmpopts.EquateEmpty()) { + labels := maps.Clone(new.Labels) + if labels == nil { + labels = map[string]string{} + } + opts.ServiceLabels = &labels + } + + return opts +} diff --git a/pkg/client/deploy/container_test.go b/pkg/client/deploy/container_test.go index 664aebdd..8413eb22 100644 --- a/pkg/client/deploy/container_test.go +++ b/pkg/client/deploy/container_test.go @@ -86,6 +86,223 @@ func TestEvalContainerSpecChange_ContainerTty(t *testing.T) { assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec)) } +func TestEvalContainerSpecChange_ContainerLabels(t *testing.T) { + t.Parallel() + + currentSpec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + }, + } + newSpec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + Labels: map[string]string{"com.example.team": "web"}, + }, + } + emptySpec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + Labels: map[string]string{}, + }, + } + + assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpec)) + assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec)) + assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(newSpec, newSpec)) + assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(currentSpec, emptySpec)) +} + +func TestEvalContainerSpecChange_ServiceLabels(t *testing.T) { + t.Parallel() + + currentSpec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + }, + } + newSpec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + }, + Labels: map[string]string{"tier": "frontend"}, + } + emptySpec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + }, + Labels: map[string]string{}, + } + + assert.Equal(t, ContainerNeedsUpdate, EvalContainerSpecChange(currentSpec, newSpec)) + assert.Equal(t, ContainerNeedsUpdate, EvalContainerSpecChange(newSpec, currentSpec)) + assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(newSpec, newSpec)) + assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(currentSpec, emptySpec)) + + // Changes that require recreating the container take precedence over the in-place update. + newSpecWithImage := newSpec.Clone() + newSpecWithImage.Container.Image = "nginx:alpine" + assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpecWithImage)) +} + +func TestEvalContainerChange(t *testing.T) { + t.Parallel() + + spec := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + Resources: api.ContainerResources{CPU: api.Core, Memory: 256 * 1024 * 1024}, + }, + } + appliedSpec := container.Resources{NanoCPUs: api.Core, Memory: 256 * 1024 * 1024} + + tests := []struct { + name string + hostConfig *container.HostConfig + change func(spec *api.ServiceSpec) + expected ContainerSpecStatus + }{ + { + name: "applied limits match spec", + hostConfig: &container.HostConfig{Resources: appliedSpec}, + change: func(*api.ServiceSpec) {}, + expected: ContainerUpToDate, + }, + { + name: "unknown host config falls back to spec limits", + change: func(*api.ServiceSpec) {}, + expected: ContainerUpToDate, + }, + { + name: "applied memory limit drifted from spec", + hostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 512 * 1024 * 1024}}, + change: func(*api.ServiceSpec) {}, + expected: ContainerNeedsUpdate, + }, + { + name: "applied limits already match new spec but stored spec doesn't", + hostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 512 * 1024 * 1024}}, + change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 512 * 1024 * 1024 }, + expected: ContainerNeedsUpdate, + }, + { + name: "applied limit not in spec can't be removed in place", + hostConfig: &container.HostConfig{Resources: container.Resources{ + NanoCPUs: api.Core, Memory: 256 * 1024 * 1024, MemoryReservation: 128 * 1024 * 1024, + }}, + change: func(*api.ServiceSpec) {}, + expected: ContainerNeedsRecreate, + }, + { + name: "spec change that requires recreating", + hostConfig: &container.HostConfig{Resources: appliedSpec}, + change: func(spec *api.ServiceSpec) { spec.Container.Image = "nginx:alpine" }, + expected: ContainerNeedsRecreate, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + newSpec := spec.Clone() + tt.change(&newSpec) + assert.Equal(t, tt.expected, EvalContainerChange(serviceContainer(spec, tt.hostConfig), newSpec)) + }) + } +} + +func TestContainerUpdateOptions(t *testing.T) { + t.Parallel() + + current := api.ServiceSpec{ + Container: api.ContainerSpec{ + Image: "nginx:latest", + Resources: api.ContainerResources{CPU: api.Core, Memory: 256 * 1024 * 1024}, + }, + Labels: map[string]string{"tier": "frontend"}, + } + applied := &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 256 * 1024 * 1024}} + + tests := []struct { + name string + hostConfig *container.HostConfig + change func(spec *api.ServiceSpec) + expected api.UpdateContainerOptions + }{ + { + name: "no changes", + hostConfig: applied, + change: func(*api.ServiceSpec) {}, + }, + { + name: "CPU only", + hostConfig: applied, + change: func(spec *api.ServiceSpec) { spec.Container.Resources.CPU = 2 * api.Core }, + expected: api.UpdateContainerOptions{CPU: new(int64(2 * api.Core))}, + }, + { + name: "memory and reservation", + hostConfig: applied, + change: func(spec *api.ServiceSpec) { + spec.Container.Resources.Memory = 512 * 1024 * 1024 + spec.Container.Resources.MemoryReservation = 128 * 1024 * 1024 + }, + expected: api.UpdateContainerOptions{ + Memory: new(int64(512 * 1024 * 1024)), + MemoryReservation: new(int64(128 * 1024 * 1024)), + }, + }, + { + name: "applied memory limit drifted from spec", + hostConfig: &container.HostConfig{Resources: container.Resources{ + NanoCPUs: api.Core, Memory: 512 * 1024 * 1024, + }}, + change: func(*api.ServiceSpec) {}, + expected: api.UpdateContainerOptions{Memory: new(int64(256 * 1024 * 1024))}, + }, + { + name: "applied memory limit already matches new spec", + hostConfig: &container.HostConfig{Resources: container.Resources{ + NanoCPUs: api.Core, Memory: 512 * 1024 * 1024, + }}, + change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 512 * 1024 * 1024 }, + expected: api.UpdateContainerOptions{Memory: new(int64(512 * 1024 * 1024))}, + }, + { + name: "change service labels", + hostConfig: applied, + change: func(spec *api.ServiceSpec) { spec.Labels = map[string]string{"tier": "backend"} }, + expected: api.UpdateContainerOptions{ServiceLabels: &map[string]string{"tier": "backend"}}, + }, + { + name: "remove all service labels", + hostConfig: applied, + change: func(spec *api.ServiceSpec) { spec.Labels = nil }, + expected: api.UpdateContainerOptions{ServiceLabels: &map[string]string{}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + newSpec := current.Clone() + tt.change(&newSpec) + assert.Equal(t, tt.expected, ContainerUpdateOptions(serviceContainer(current, tt.hostConfig), newSpec)) + }) + } +} + +func serviceContainer(spec api.ServiceSpec, hostConfig *container.HostConfig) api.ServiceContainer { + return api.ServiceContainer{ + Container: api.Container{InspectResponse: container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{HostConfig: hostConfig}, + }}, + ServiceSpec: spec, + } +} + func TestEvalContainerSpecChange_ContainerResources(t *testing.T) { t.Parallel() @@ -127,7 +344,7 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) { CPU: 1000000000, }, new: api.ContainerResources{}, - want: ContainerNeedsUpdate, + want: ContainerNeedsRecreate, }, // Memory @@ -189,19 +406,33 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) { name: "unset memory", current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024}, new: api.ContainerResources{MemoryReservation: 50 * 1024 * 1024}, - want: ContainerNeedsUpdate, + want: ContainerNeedsRecreate, }, { name: "unset reservation", current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024}, new: api.ContainerResources{Memory: 100 * 1024 * 1024}, - want: ContainerNeedsUpdate, + want: ContainerNeedsRecreate, }, { name: "unset memory and reservation", current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024}, new: api.ContainerResources{}, - want: ContainerNeedsUpdate, + want: ContainerNeedsRecreate, + }, + + // Shared memory + { + name: "change shared memory", + current: api.ContainerResources{SharedMemory: 64 * 1024 * 1024}, + new: api.ContainerResources{SharedMemory: 128 * 1024 * 1024}, + want: ContainerNeedsRecreate, + }, + { + name: "change memory and shared memory", + current: api.ContainerResources{Memory: 100 * 1024 * 1024}, + new: api.ContainerResources{Memory: 200 * 1024 * 1024, SharedMemory: 64 * 1024 * 1024}, + want: ContainerNeedsRecreate, }, // Memory and CPU diff --git a/pkg/client/deploy/deploy.go b/pkg/client/deploy/deploy.go index 614cdccf..30cf2bd2 100644 --- a/pkg/client/deploy/deploy.go +++ b/pkg/client/deploy/deploy.go @@ -173,7 +173,7 @@ func (sp *ServicePlan) Format() string { // FormatSummary counts operations in the service plan and renders a styled summary line. func (sp *ServicePlan) FormatSummary() string { - var createCount, startFirstCount, stopFirstCount, removeCount int + var createCount, startFirstCount, stopFirstCount, updateCount, removeCount int machines := make(map[string]struct{}) for _, op := range sp.Operations { @@ -188,6 +188,9 @@ func (sp *ServicePlan) FormatSummary() string { } else { startFirstCount++ } + case *operation.UpdateContainerOperation: + machines[o.MachineID] = struct{}{} + updateCount++ case *operation.RemoveContainerOperation: machines[o.MachineID] = struct{}{} removeCount++ @@ -210,6 +213,10 @@ func (sp *ServicePlan) FormatSummary() string { parts = append(parts, tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)")) } + if updateCount > 0 { + parts = append(parts, + tui.BoldYellow.Render(strconv.Itoa(updateCount))+" "+tui.Yellow.Render("update (in place)")) + } if removeCount > 0 { parts = append(parts, tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove")) diff --git a/pkg/client/deploy/deploy_test.go b/pkg/client/deploy/deploy_test.go new file mode 100644 index 00000000..cb8d8a0c --- /dev/null +++ b/pkg/client/deploy/deploy_test.go @@ -0,0 +1,51 @@ +package deploy + +import ( + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client/operation" + "github.com/stretchr/testify/assert" +) + +func TestServicePlan_FormatSummary(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ops []operation.Operation + expected string + }{ + { + name: "in-place updates only", + ops: []operation.Operation{ + &operation.UpdateContainerOperation{MachineID: "m-1"}, + &operation.UpdateContainerOperation{MachineID: "m-2"}, + }, + expected: "2 update (in place) · across 2 machines", + }, + { + name: "all operation types", + ops: []operation.Operation{ + &operation.RunContainerOperation{MachineID: "m-1"}, + &operation.ReplaceContainerOperation{MachineID: "m-1", Order: api.UpdateOrderStartFirst}, + &operation.ReplaceContainerOperation{MachineID: "m-2", Order: api.UpdateOrderStopFirst}, + &operation.UpdateContainerOperation{MachineID: "m-2"}, + &operation.RemoveContainerOperation{MachineID: "m-3"}, + }, + expected: "1 create · 1 replace (start-first) · 1 replace (stop-first) · 1 update (in place) · 1 remove · " + + "across 3 machines", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + plan := ServicePlan{} + plan.Operations = tt.ops + assert.Equal(t, tt.expected, ansi.Strip(plan.FormatSummary())) + }) + } +} diff --git a/pkg/client/deploy/strategy.go b/pkg/client/deploy/strategy.go index 45eaa251..063f17de 100644 --- a/pkg/client/deploy/strategy.go +++ b/pkg/client/deploy/strategy.go @@ -92,7 +92,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) // Organise existing containers by machine. containersOnMachine := make(map[string][]api.ServiceContainer) - upToDateContainersOnMachine := make(map[string]int) + reusableContainersOnMachine := make(map[string]int) containerSpecStatuses := make(map[string]ContainerSpecStatus) if svc != nil { for _, c := range svc.Containers { @@ -105,40 +105,50 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) if s.ForceRecreate { status = ContainerNeedsRecreate } else { - status = EvalContainerSpecChange(c.Container.ServiceSpec, spec) + status = EvalContainerChange(c.Container, spec) } containerSpecStatuses[c.Container.ID] = status - if status == ContainerUpToDate { - upToDateContainersOnMachine[c.MachineID] += 1 + if status != ContainerNeedsRecreate { + reusableContainersOnMachine[c.MachineID] += 1 } } - // Sort containers such that running containers with the desired spec are first. + // Sort containers such that running containers with the desired spec or that can be updated in place to + // the desired spec are first. + // TODO: consider ranking up-to-date containers above ones that need an in-place update (here, when sorting + // machines, and in reconcileGlobalContainer) to avoid updating a container while removing an up-to-date one. + isReusable := func(c api.MachineServiceContainer) bool { + // Not running containers have no status. + status, ok := containerSpecStatuses[c.Container.ID] + return ok && status != ContainerNeedsRecreate + } slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int { - if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate { + r1, r2 := isReusable(c1), isReusable(c2) + switch { + case r1 == r2: + return 0 + case r1: return -1 - } - if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate { + default: return 1 } - return 0 }) for _, c := range svc.Containers { containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c.Container) } - // Sort machines such that machines with the most up-to-date containers are first, followed by machines with - // existing containers, and finally machines without containers. + // Sort machines such that machines with the most containers that are up to date or can be updated in place + // are first, followed by machines with existing containers, and finally machines without containers. slices.SortFunc(matchedMachines, func(m1, m2 *pb.MachineInfo) int { - if upToDateContainersOnMachine[m1.Id] > 0 && upToDateContainersOnMachine[m2.Id] > 0 { - return upToDateContainersOnMachine[m2.Id] - upToDateContainersOnMachine[m1.Id] + if reusableContainersOnMachine[m1.Id] > 0 && reusableContainersOnMachine[m2.Id] > 0 { + return reusableContainersOnMachine[m2.Id] - reusableContainersOnMachine[m1.Id] } - if upToDateContainersOnMachine[m1.Id] > 0 { + if reusableContainersOnMachine[m1.Id] > 0 { return -1 } - if upToDateContainersOnMachine[m2.Id] > 0 { + if reusableContainersOnMachine[m2.Id] > 0 { return 1 } return len(containersOnMachine[m2.Id]) - len(containersOnMachine[m1.Id]) @@ -170,7 +180,16 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) if status == ContainerUpToDate { continue } - // TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported. + if status == ContainerNeedsUpdate { + plan.Operations = append(plan.Operations, &operation.UpdateContainerOperation{ + ServiceID: plan.ServiceID, + MachineID: m.Id, + MachineName: m.Name, + Container: ctr, + Options: ContainerUpdateOptions(ctr, spec), + }) + continue + } } // Replace the old container with a new one. @@ -301,7 +320,7 @@ func reconcileGlobalContainer( } // Check if there is a container with the same spec already running. If so, remove the rest. - upToDate := false + reused := false for i, c := range containers { if !c.Container.State.Running || c.Container.State.Paused { // Skip containers that are not running. @@ -312,12 +331,21 @@ func reconcileGlobalContainer( if forceRecreate { status = ContainerNeedsRecreate } else { - status = EvalContainerSpecChange(c.Container.ServiceSpec, spec) + status = EvalContainerChange(c.Container, spec) } - if status == ContainerUpToDate { - // The container is already running with the same spec. - upToDate = true + if status != ContainerNeedsRecreate { + // The container is already running with the desired spec or can be updated in place to it. + reused = true + if status == ContainerNeedsUpdate { + ops = append(ops, &operation.UpdateContainerOperation{ + ServiceID: serviceID, + MachineID: machine.Id, + MachineName: machine.Name, + Container: c.Container, + Options: ContainerUpdateOptions(c.Container, spec), + }) + } for j, old := range containers { if i == j { continue @@ -331,10 +359,8 @@ func reconcileGlobalContainer( } break } - // TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported. - // Make sure to update preDeployOperation accordingly. } - if upToDate { + if reused { return ops, nil } diff --git a/pkg/client/deploy/strategy_test.go b/pkg/client/deploy/strategy_test.go index 2974f619..b8279295 100644 --- a/pkg/client/deploy/strategy_test.go +++ b/pkg/client/deploy/strategy_test.go @@ -1,6 +1,8 @@ package deploy import ( + "slices" + "strings" "testing" "github.com/docker/docker/api/types/container" @@ -11,6 +13,7 @@ import ( "github.com/psviderski/uncloud/pkg/client/deploy/scheduler" "github.com/psviderski/uncloud/pkg/client/operation" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDetermineUpdateOrder(t *testing.T) { @@ -424,6 +427,121 @@ func TestReconcileGlobalContainer(t *testing.T) { } } +func TestRollingStrategy_Plan_UpdateInPlace(t *testing.T) { + t.Parallel() + + state := &scheduler.ClusterState{ + Machines: []*scheduler.Machine{ + {Info: &pb.MachineInfo{Id: "m-1", Name: "machine-1"}}, + {Info: &pb.MachineInfo{Id: "m-2", Name: "machine-2"}}, + }, + } + newContainer := func(id string, spec api.ServiceSpec) api.ServiceContainer { + ctr := newServiceContainer(id, container.State{Running: true, Status: "running"}) + ctr.ServiceSpec = spec + return ctr + } + + tests := []struct { + name string + change func(spec *api.ServiceSpec) + // options are the expected in-place update options. Nil means the containers must be recreated. + options *api.UpdateContainerOptions + }{ + { + name: "service labels", + change: func(spec *api.ServiceSpec) { spec.Labels = map[string]string{"tier": "backend"} }, + options: &api.UpdateContainerOptions{ServiceLabels: &map[string]string{"tier": "backend"}}, + }, + { + name: "CPU and memory limits", + change: func(spec *api.ServiceSpec) { + spec.Container.Resources.CPU = 2 * api.Core + spec.Container.Resources.Memory = 512 * 1024 * 1024 + }, + options: &api.UpdateContainerOptions{ + CPU: new(int64(2 * api.Core)), + Memory: new(int64(512 * 1024 * 1024)), + }, + }, + { + name: "remove memory limit", + change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 0 }, + }, + } + + for _, tt := range tests { + for _, mode := range []string{api.ServiceModeReplicated, api.ServiceModeGlobal} { + t.Run(tt.name+"/"+mode, func(t *testing.T) { + currentSpec := api.ServiceSpec{ + Name: "app", + Mode: mode, + Container: api.ContainerSpec{ + Image: "nginx:latest", + Resources: api.ContainerResources{ + CPU: api.Core, + Memory: 256 * 1024 * 1024, + }, + }, + Labels: map[string]string{"tier": "frontend"}, + PreDeploy: &api.PreDeployHook{Command: []string{"db", "migrate"}}, + } + if mode == api.ServiceModeReplicated { + currentSpec.Replicas = 2 + } + newSpec := currentSpec.Clone() + tt.change(&newSpec) + + ctr1 := newContainer("container-1", currentSpec) + ctr2 := newContainer("container-2", currentSpec) + svc := &api.Service{ + ID: "svc-1", + Name: "app", + Mode: mode, + Containers: []api.MachineServiceContainer{ + {MachineID: "m-1", MachineName: "machine-1", Container: ctr1}, + {MachineID: "m-2", MachineName: "machine-2", Container: ctr2}, + }, + } + + plan, err := (&RollingStrategy{}).Plan(state, svc, newSpec) + require.NoError(t, err) + + if tt.options == nil { + require.NotEmpty(t, plan.Operations) + for _, op := range plan.Operations { + _, isUpdate := op.(*operation.UpdateContainerOperation) + assert.False(t, isUpdate, "Expected containers to be recreated, got %s", op) + } + return + } + // The replicated strategy shuffles machines so sort the operations to compare them deterministically. + ops := slices.Clone(plan.Operations) + slices.SortFunc(ops, func(a, b operation.Operation) int { + return strings.Compare(a.String(), b.String()) + }) + // The containers are updated in place and the pre-deploy hook is not run. + assertOperationsEqual(t, []operation.Operation{ + &operation.UpdateContainerOperation{ + ServiceID: "svc-1", + MachineID: "m-1", + MachineName: "machine-1", + Container: ctr1, + Options: *tt.options, + }, + &operation.UpdateContainerOperation{ + ServiceID: "svc-1", + MachineID: "m-2", + MachineName: "machine-2", + Container: ctr2, + Options: *tt.options, + }, + }, ops) + }) + } + } +} + func TestPreDeployOperations(t *testing.T) { hook := &api.PreDeployHook{ Command: []string{"db", "migrate"}, @@ -502,6 +620,20 @@ func TestPreDeployOperations(t *testing.T) { }, }, }, + { + name: "plan has only UpdateContainerOperation", + plan: ServicePlan{ + ServiceID: "svc-1", + ServiceName: "app", + Spec: api.ServiceSpec{PreDeploy: hook}, + SequenceOperation: operation.SequenceOperation{ + Operations: []operation.Operation{ + &operation.UpdateContainerOperation{MachineID: "m-1", MachineName: "machine-1"}, + }, + }, + }, + expected: nil, + }, { name: "plan has only RemoveContainerOperation", plan: ServicePlan{ @@ -614,7 +746,8 @@ func TestPreDeployOperations(t *testing.T) { }, svc: &api.Service{ HookContainers: []api.MachineServiceContainer{ - {MachineID: "m-1", Container: newServiceContainer("stopped-hook", container.State{Status: "exited"})}, + {MachineID: "m-1", Container: newServiceContainer("stopped-hook", + container.State{Status: "exited"})}, {MachineID: "m-1", Container: runningHook1}, {MachineID: "m-3", Container: runningHook2}, }, @@ -660,6 +793,7 @@ func newServiceContainer(id string, state container.State) api.ServiceContainer ID: id, State: &state, }, + Config: &container.Config{}, }, }} } diff --git a/pkg/client/operation/container.go b/pkg/client/operation/container.go index 2c177c07..2278231f 100644 --- a/pkg/client/operation/container.go +++ b/pkg/client/operation/container.go @@ -285,6 +285,39 @@ func (o *ReplaceContainerOperation) String() string { o.MachineID, o.ServiceID, o.OldContainer.ID, o.Order) } +// UpdateContainerOperation updates the requested properties of a running container in place without recreating it. +type UpdateContainerOperation struct { + ServiceID string + MachineID string + // MachineName is used for formatting the operation as part of the deployment plan. + MachineName string + Container api.ServiceContainer + Options api.UpdateContainerOptions +} + +func (o *UpdateContainerOperation) Execute(ctx context.Context, cli Client) error { + if err := cli.UpdateContainer(ctx, o.ServiceID, o.Container.ID, o.Options); err != nil { + return fmt.Errorf("update container: %w", err) + } + return nil +} + +func (o *UpdateContainerOperation) Format() string { + displayName := o.Container.ServiceSpec.Name + tui.Faint.Render("/") + o.Container.ShortID() + + return tui.BoldYellow.Render("~") + " " + + tui.Faint.Render("update container") + " " + + displayName + " " + + tui.Faint.Render("on") + " " + + o.MachineName + " " + + tui.Faint.Render("(in place)") +} + +func (o *UpdateContainerOperation) String() string { + return fmt.Sprintf("UpdateContainerOperation[machine_id=%s service_id=%s container_id=%s]", + o.MachineID, o.ServiceID, o.Container.ID) +} + // stopOptions converts a stop grace period duration to Docker container stop options. func stopOptions(gracePeriod *time.Duration) container.StopOptions { if gracePeriod == nil { diff --git a/pkg/client/operation/container_test.go b/pkg/client/operation/container_test.go index 892d86e6..7da49d74 100644 --- a/pkg/client/operation/container_test.go +++ b/pkg/client/operation/container_test.go @@ -123,6 +123,13 @@ func (c *recordingClient) RemoveContainer( return nil } +func (c *recordingClient) UpdateContainer( + _ context.Context, _, containerID string, _ api.UpdateContainerOptions, +) error { + c.calls = append(c.calls, "update:"+containerID) + return nil +} + func (c *recordingClient) CreatePreDeployHookContainer( context.Context, string, api.ServiceSpec, string, ) (api.CreateContainerResponse, error) { diff --git a/test/e2e/assert.go b/test/e2e/assert.go index be5e8e6b..315004b7 100644 --- a/test/e2e/assert.go +++ b/test/e2e/assert.go @@ -39,7 +39,7 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) { desiredSpec := spec.SetDefaults() - status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, desiredSpec) + status := deploy.EvalContainerChange(ctr, desiredSpec) assert.Equal(t, deploy.ContainerUpToDate, status) spec = renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name) @@ -48,6 +48,9 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName]) assert.Equal(t, spec.Mode, ctr.Config.Labels[api.LabelServiceMode]) assert.Contains(t, ctr.Config.Labels, api.LabelManaged) + for k, v := range spec.Container.Labels { + assert.Equal(t, v, ctr.Config.Labels[k], "Expected container label '%s'", k) + } // Command and Entrypoint can only be compared if they are set in the spec. // Otherwise, the container takes them from the image. diff --git a/test/e2e/compose_deploy_test.go b/test/e2e/compose_deploy_test.go index bd767e39..aa5bcfd8 100644 --- a/test/e2e/compose_deploy_test.go +++ b/test/e2e/compose_deploy_test.go @@ -2,7 +2,9 @@ package e2e import ( "context" + "fmt" "testing" + "time" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/volume" @@ -10,6 +12,7 @@ import ( "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/deploy" + "github.com/psviderski/uncloud/pkg/client/operation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -548,6 +551,85 @@ func TestComposeDeployment(t *testing.T) { "Service containers should be on machines 1 and 3 from comma-separated list") }) + t.Run("labels and in-place update of service labels and resources", func(t *testing.T) { + t.Parallel() + + name := "test-compose-labels" + t.Cleanup(func() { + removeServices(t, cli, name) + }) + + composeYAML := func(tier, memLimit string) string { + return fmt.Sprintf(` +services: + %s: + image: portainer/pause:3.9 + mem_limit: %s + labels: + com.example.team: web + deploy: + replicas: 2 + labels: + tier: %s +`, name, memLimit, tier) + } + + project, err := compose.LoadProjectFromContent(ctx, composeYAML("frontend", "64m")) + require.NoError(t, err) + deployment, err := compose.NewDeployment(ctx, cli, project) + require.NoError(t, err) + require.NoError(t, deployment.Run(ctx)) + + svc, err := cli.InspectService(ctx, name) + require.NoError(t, err) + expectedSpec := api.ServiceSpec{ + Name: name, + Mode: api.ServiceModeReplicated, + Container: api.ContainerSpec{ + Image: "portainer/pause:3.9", + Labels: map[string]string{"com.example.team": "web"}, + Resources: api.ContainerResources{Memory: 64 * 1024 * 1024}, + }, + Labels: map[string]string{"tier": "frontend"}, + Replicas: 2, + } + assertServiceMatchesSpec(t, svc, expectedSpec) + for _, mc := range svc.Containers { + assert.NotContains(t, mc.Container.Config.Labels, "tier", + "Service labels must not be applied to containers") + } + containers := serviceContainerIDs(svc) + + // Changing the service labels and memory limit should update the containers in place. + project, err = compose.LoadProjectFromContent(ctx, composeYAML("backend", "128m")) + require.NoError(t, err) + deployment, err = compose.NewDeployment(ctx, cli, project) + require.NoError(t, err) + + plan, err := deployment.Plan(ctx) + require.NoError(t, err) + require.Len(t, plan.Services, 1) + require.Len(t, plan.Services[0].Operations, 2) + for _, op := range plan.Services[0].Operations { + assert.IsType(t, &operation.UpdateContainerOperation{}, op) + } + require.NoError(t, deployment.Run(ctx)) + + // The updated spec propagates to the cluster store asynchronously. + expectedSpec.Labels = map[string]string{"tier": "backend"} + expectedSpec.Container.Resources.Memory = 128 * 1024 * 1024 + assert.EventuallyWithT(t, func(c *assert.CollectT) { + svc, err = cli.InspectService(ctx, name) + require.NoError(c, err) + for _, mc := range svc.Containers { + assert.Equal(c, expectedSpec.Labels, mc.Container.ServiceSpec.Labels) + assert.Equal(c, expectedSpec.Container.Resources.Memory, mc.Container.HostConfig.Memory) + } + }, 10*time.Second, 200*time.Millisecond) + assertServiceMatchesSpec(t, svc, expectedSpec) + assert.Equal(t, containers, serviceContainerIDs(svc), "Expected containers to be updated in place") + }) + // Catches regression: https://github.com/psviderski/uncloud/issues/176 t.Run("plan new deployment with volumes and recreate strategy", func(t *testing.T) { t.Parallel() diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 4f4c4993..a86b3b16 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -1761,6 +1761,90 @@ func TestServiceLifecycle(t *testing.T) { require.ErrorIs(t, err, api.ErrNotFound) }) + t.Run("update container in place", func(t *testing.T) { + t.Parallel() + + serviceID := newServiceID() + spec := api.ServiceSpec{ + Name: "container-update", + Container: api.ContainerSpec{ + Image: "portainer/pause:latest", + Resources: api.ContainerResources{ + CPU: 100 * api.MilliCore, + Memory: 20 * units.MiB, + }, + }, + Labels: map[string]string{"tier": "frontend"}, + } + + ctr, err := cli.CreateContainer(ctx, serviceID, spec, c.Machines[0].Name) + require.NoError(t, err) + + t.Cleanup(func() { + err := cli.RemoveContainer(ctx, serviceID, ctr.ID, container.RemoveOptions{Force: true}) + if !errors.Is(err, api.ErrNotFound) { + require.NoError(t, err) + } + }) + + require.NoError(t, cli.StartContainer(ctx, serviceID, ctr.ID)) + + // Update the CPU and memory limits and replace the service labels. + err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{ + CPU: new(int64(200 * api.MilliCore)), + Memory: new(int64(40 * units.MiB)), + MemoryReservation: new(int64(10 * units.MiB)), + ServiceLabels: &map[string]string{"tier": "backend"}, + }) + require.NoError(t, err) + + spec.Container.Resources.CPU = 200 * api.MilliCore + spec.Container.Resources.Memory = 40 * units.MiB + spec.Container.Resources.MemoryReservation = 10 * units.MiB + spec.Labels = map[string]string{"tier": "backend"} + + mc, err := cli.InspectContainer(ctx, serviceID, ctr.ID) + require.NoError(t, err) + assert.Equal(t, ctr.ID, mc.Container.ID, "Expected container to be updated in place") + assert.True(t, mc.Container.State.Running, "Expected container to keep running") + assertContainerMatchesSpec(t, mc.Container, spec) + assert.Equal(t, spec.Labels, mc.Container.ServiceSpec.Labels) + assert.EqualValues(t, 80*units.MiB, mc.Container.HostConfig.MemorySwap, + "Expected swap limit to be twice the memory limit") + + // Remove all service labels and leave the limits unchanged. + err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{ + ServiceLabels: &map[string]string{}, + }) + require.NoError(t, err) + spec.Labels = nil + + mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID) + require.NoError(t, err) + assertContainerMatchesSpec(t, mc.Container, spec) + assert.Nil(t, mc.Container.ServiceSpec.Labels) + + // A limit can't be removed without recreating the container. + err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{Memory: new(int64(0))}) + require.ErrorContains(t, err, "memory limit can't be removed") + + mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID) + require.NoError(t, err) + assertContainerMatchesSpec(t, mc.Container, spec) + + // Reserved service labels are rejected. + err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{ + ServiceLabels: &map[string]string{api.LabelManaged: ""}, + }) + require.ErrorContains(t, err, "reserved prefix") + + require.NoError(t, cli.RemoveContainer(ctx, serviceID, ctr.ID, container.RemoveOptions{Force: true})) + err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{ + ServiceLabels: &map[string]string{"tier": "frontend"}, + }) + require.ErrorIs(t, err, api.ErrNotFound) + }) + t.Run("1 replica", func(t *testing.T) { t.Parallel() diff --git a/website/docs/8-compose-file-reference/1-support-matrix.md b/website/docs/8-compose-file-reference/1-support-matrix.md index e9c584aa..62fdeb12 100644 --- a/website/docs/8-compose-file-reference/1-support-matrix.md +++ b/website/docs/8-compose-file-reference/1-support-matrix.md @@ -10,16 +10,16 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea ::: -| Feature | Support Status | Notes | -|----------------------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------| -| **Services** | | | +| Feature | Support Status | Notes | +|----------------------------------|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| **Services** | | | | `build` | ✅ Supported | Build context and Dockerfile | | `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) | | `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop | | `command` | ✅ Supported | Override container command | | `configs` | ✅ Supported | File-based and inline configs | | `cpus` | ✅ Supported | CPU limit | -| `depends_on` | ⚠️ Limited | Deployed in dependency order. Use [pre-deploy hooks](../4-guides/1-deployments/5-pre-deploy-hooks.md) for `service_completed_successfully` | +| `depends_on` | ⚠️ Limited | Deployed in dependency order. Use [pre-deploy hooks](../4-guides/1-deployments/5-pre-deploy-hooks.md) for `service_completed_successfully` | | `devices` | ✅ Supported | Device mappings | | `dns` | ❌ Not supported | Built-in service discovery | | `dns_search` | ❌ Not supported | Built-in service discovery | @@ -30,7 +30,7 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea | `healthcheck` | ✅ Supported | Health check configuration | | `image` | ✅ Supported | Container image specification | | `init` | ✅ Supported | Run init process in container | -| `labels` | ❌ Not supported | | +| `labels` | ✅ Supported | Docker labels on containers | | `links` | ❌ Not supported | Use service names for communication | | `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver | | `mem_limit` | ✅ Supported | Memory limit | @@ -39,10 +39,10 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea | `memswap_limit` | ❌ Not supported | | | `networks` | ❌ Not supported | All containers share cluster network | | `pid` | ✅ Supported | Set the PID namespace mode, `pid: host` only | -| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](2-extensions.md#x-ports) for HTTP/HTTPS | +| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](2-extensions.md#x-ports) for HTTP/HTTPS | | `privileged` | ✅ Supported | Run containers in privileged mode | | `pull_policy` | ✅ Supported | `always`, `missing`, `never` | -| `secrets` | ⚠️ Limited | Reference secrets in `environment`, see [Secrets](../3-concepts/8-secrets.md). File mounts not supported | +| `secrets` | ⚠️ Limited | Reference secrets in `environment`, see [Secrets](../3-concepts/8-secrets.md). File mounts not supported | | `security_opt` | ❌ Not supported | | | `shm_size` | ✅ Supported | Shared memory size | | `stdin_open` | ✅ Supported | Allocate standard input and keep it open | @@ -53,28 +53,28 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea | `ulimits` | ✅ Supported | Resource limits | | `user` | ✅ Supported | Set container user | | `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs | -| **Deploy** | | | -| `labels` | ❌ Not supported | | +| **Deploy** | | | +| `labels` | ✅ Supported | Service labels, not applied to containers. Use `services..labels` to set Docker labels on containers | | `mode` | ✅ Supported | Either `global` or `replicated` | | `placement` | ❌ Not supported | Use [`x-machines`](2-extensions.md#x-machines) extension | | `replicas` | ✅ Supported | Number of container replicas | -| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | +| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | | `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` | | `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) | -| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) | -| **Volumes** | | | +| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) | +| **Volumes** | | | | Named volumes | ✅ Supported | Docker volumes | | Bind mounts | ✅ Supported | Host path binding with optional [runtime templates](../3-concepts/6-services/3-runtime-templates.md) | | Tmpfs mounts | ✅ Supported | In-memory filesystems | | Volume labels | ✅ Supported | Custom labels | | External volumes | ✅ Supported | Must exist before deployment | | [Volume drivers][volume-drivers] | ✅ Supported | `local` (supports [NFS][volume-nfs], [CIFS/Samba][volume-cifs]) and manually installed third-party drivers | -| **Configs** | | | +| **Configs** | | | | File-based configs | ✅ Supported | Read from file | | Inline configs | ✅ Supported | Defined in compose file | | External configs | ❌ Not supported | Not supported | | Short syntax | ❌ Not supported | Use long syntax only | -| **Extensions** | | | +| **Extensions** | | | | `x-context` | ✅ Uncloud-specific | Cluster context override | | `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration | | `x-machines` | ✅ Uncloud-specific | Machine placement constraints |