mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-11 05:04:16 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dac194752a | ||
|
|
df493d0b60 | ||
|
|
7f68e1f838 | ||
|
|
fab2a20242 | ||
|
|
bb8fb3a380 | ||
|
|
e50c7a0cf6 | ||
|
|
c16e452439 | ||
|
|
ba224bb5be | ||
|
|
8ddbe3c606 | ||
|
|
ce1c6bd05a | ||
|
|
11e1200fe8 | ||
|
|
0cdf721a6a | ||
|
|
5e26cfbead | ||
|
|
409dc3bfe3 | ||
|
|
5fa236871f | ||
|
|
e3479409b1 | ||
|
|
a1b6b31e9c | ||
|
|
d04772dacd | ||
|
|
87691ec029 | ||
|
|
7ecf63169b | ||
|
|
c58108aa82 | ||
|
|
5d7952952a | ||
|
|
87e99ae369 | ||
|
|
f78a8ca8b5 | ||
|
|
e4bd1ad443 | ||
|
|
0e53f505c0 | ||
|
|
9c21ae6b08 | ||
|
|
70de7b1b8f | ||
|
|
af277639dd | ||
|
|
25ca0a57e8 | ||
|
|
4dae187aa5 | ||
|
|
930e7ea637 | ||
|
|
e50c5fbe57 | ||
|
|
ebecf334d7 | ||
|
|
9ee3ca15eb | ||
|
|
b28c458029 | ||
|
|
3fc1cddf01 | ||
|
|
54a7328a34 | ||
|
|
47a4a9158e | ||
|
|
ad2e1a06d5 | ||
|
|
81d36edc66 | ||
|
|
5fb80e2071 | ||
|
|
d1fe23dec0 | ||
|
|
cec3c75a10 |
@@ -18,3 +18,6 @@ indent_size = 4
|
|||||||
[*.sh]
|
[*.sh]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
|
[release-notes-*.md]
|
||||||
|
max_line_length = off
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
packages:
|
packages:
|
||||||
|
github.com/docker/docker/client:
|
||||||
|
config:
|
||||||
|
dir: internal/machine/docker
|
||||||
|
pkgname: docker
|
||||||
|
interfaces:
|
||||||
|
APIClient:
|
||||||
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
||||||
interfaces:
|
interfaces:
|
||||||
CaddyfileValidator:
|
CaddyfileValidator:
|
||||||
|
|||||||
+1
-1
@@ -116,7 +116,7 @@ mise run proto
|
|||||||
Regenerate mocks:
|
Regenerate mocks:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
make mocks
|
mise run mocks
|
||||||
```
|
```
|
||||||
|
|
||||||
## Uncloud in Docker (UCinD)
|
## Uncloud in Docker (UCinD)
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ corrosion-multiarch-image-push:
|
|||||||
ucind-multiarch-image-push:
|
ucind-multiarch-image-push:
|
||||||
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
.PHONY: mocks
|
|
||||||
mocks:
|
|
||||||
@mockery
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
ifeq ($(TEST_NAME),)
|
ifeq ($(TEST_NAME),)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ complexity of Kubernetes.
|
|||||||
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
Encrypt.
|
Encrypt.
|
||||||
|
* **Cluster storage for TLS certificates**: Native cluster storage for Caddy shares certificates and ACME challenge
|
||||||
|
tokens across machines and coordinates issuance.
|
||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
|
|||||||
+205
-46
@@ -446,6 +446,120 @@ func (x *StatCaddyStorageResponse) GetIsTerminal() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListCertificatesResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Certificates []*IssuedCertificate `protobuf:"bytes,1,rep,name=certificates,proto3" json:"certificates,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListCertificatesResponse) Reset() {
|
||||||
|
*x = ListCertificatesResponse{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_api_pb_caddy_storage_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListCertificatesResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ListCertificatesResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ListCertificatesResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_api_pb_caddy_storage_proto_msgTypes[8]
|
||||||
|
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 ListCertificatesResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ListCertificatesResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ListCertificatesResponse) GetCertificates() []*IssuedCertificate {
|
||||||
|
if x != nil {
|
||||||
|
return x.Certificates
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IssuedCertificate struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
// Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs.
|
||||||
|
San string `protobuf:"bytes,1,opt,name=san,proto3" json:"san,omitempty"`
|
||||||
|
// The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first.
|
||||||
|
Chain []byte `protobuf:"bytes,2,opt,name=chain,proto3" json:"chain,omitempty"`
|
||||||
|
// Any extra information associated with the certificate, usually provided by the issuer implementation.
|
||||||
|
// JSON-encoded, may be absent or null.
|
||||||
|
IssuerData []byte `protobuf:"bytes,3,opt,name=issuer_data,json=issuerData,proto3" json:"issuer_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssuedCertificate) Reset() {
|
||||||
|
*x = IssuedCertificate{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_api_pb_caddy_storage_proto_msgTypes[9]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssuedCertificate) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*IssuedCertificate) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *IssuedCertificate) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_api_pb_caddy_storage_proto_msgTypes[9]
|
||||||
|
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 IssuedCertificate.ProtoReflect.Descriptor instead.
|
||||||
|
func (*IssuedCertificate) Descriptor() ([]byte, []int) {
|
||||||
|
return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{9}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssuedCertificate) GetSan() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.San
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssuedCertificate) GetChain() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Chain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IssuedCertificate) GetIssuerData() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.IssuerData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var File_api_pb_caddy_storage_proto protoreflect.FileDescriptor
|
var File_api_pb_caddy_storage_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
var file_api_pb_caddy_storage_proto_rawDesc = []byte{
|
var file_api_pb_caddy_storage_proto_rawDesc = []byte{
|
||||||
@@ -492,32 +606,48 @@ var file_api_pb_caddy_storage_proto_rawDesc = []byte{
|
|||||||
0x65, 0x64, 0x41, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01,
|
0x65, 0x64, 0x41, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01,
|
||||||
0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x74,
|
0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x74,
|
||||||
0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69,
|
0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69,
|
||||||
0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x32, 0xdf, 0x02, 0x0a, 0x0c, 0x43, 0x61,
|
0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x22, 0x56, 0x0a, 0x18, 0x4c, 0x69, 0x73,
|
||||||
0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74,
|
0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73,
|
||||||
0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x43,
|
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
|
||||||
|
0x63, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70,
|
||||||
|
0x69, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63,
|
||||||
|
0x61, 0x74, 0x65, 0x52, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65,
|
||||||
|
0x73, 0x22, 0x5c, 0x0a, 0x11, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69,
|
||||||
|
0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x61, 0x6e, 0x18, 0x01, 0x20,
|
||||||
|
0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x61, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x61, 0x69,
|
||||||
|
0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x1f,
|
||||||
|
0x0a, 0x0b, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20,
|
||||||
|
0x01, 0x28, 0x0c, 0x52, 0x0a, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x32,
|
||||||
|
0xaa, 0x03, 0x0a, 0x0c, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
|
||||||
|
0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
||||||
|
0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67,
|
||||||
|
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, 0x43, 0x0a, 0x04, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c,
|
||||||
|
0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52,
|
||||||
|
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61,
|
||||||
|
0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
|
||||||
|
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12,
|
||||||
|
0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64,
|
||||||
|
0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 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, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12,
|
||||||
|
0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53,
|
||||||
|
0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e,
|
||||||
|
0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f,
|
||||||
|
0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04,
|
||||||
|
0x53, 0x74, 0x61, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43,
|
||||||
0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||||
0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64,
|
||||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x6f,
|
0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||||
0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64,
|
0x65, 0x12, 0x49, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
|
||||||
0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
0x63, 0x61, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||||
0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79,
|
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e,
|
||||||
0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63,
|
||||||
0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24,
|
||||||
0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61,
|
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64,
|
||||||
0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x61, 0x70,
|
||||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e,
|
|
||||||
0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
|
|
||||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69,
|
|
||||||
0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65,
|
|
||||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04, 0x53, 0x74, 0x61, 0x74, 0x12, 0x1c,
|
|
||||||
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74,
|
|
||||||
0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61,
|
|
||||||
0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72,
|
|
||||||
0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 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 (
|
var (
|
||||||
@@ -532,7 +662,7 @@ func file_api_pb_caddy_storage_proto_rawDescGZIP() []byte {
|
|||||||
return file_api_pb_caddy_storage_proto_rawDescData
|
return file_api_pb_caddy_storage_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
|
var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||||
var file_api_pb_caddy_storage_proto_goTypes = []any{
|
var file_api_pb_caddy_storage_proto_goTypes = []any{
|
||||||
(*StoreCaddyStorageRequest)(nil), // 0: api.StoreCaddyStorageRequest
|
(*StoreCaddyStorageRequest)(nil), // 0: api.StoreCaddyStorageRequest
|
||||||
(*LoadCaddyStorageRequest)(nil), // 1: api.LoadCaddyStorageRequest
|
(*LoadCaddyStorageRequest)(nil), // 1: api.LoadCaddyStorageRequest
|
||||||
@@ -542,27 +672,32 @@ var file_api_pb_caddy_storage_proto_goTypes = []any{
|
|||||||
(*ListCaddyStorageResponse)(nil), // 5: api.ListCaddyStorageResponse
|
(*ListCaddyStorageResponse)(nil), // 5: api.ListCaddyStorageResponse
|
||||||
(*StatCaddyStorageRequest)(nil), // 6: api.StatCaddyStorageRequest
|
(*StatCaddyStorageRequest)(nil), // 6: api.StatCaddyStorageRequest
|
||||||
(*StatCaddyStorageResponse)(nil), // 7: api.StatCaddyStorageResponse
|
(*StatCaddyStorageResponse)(nil), // 7: api.StatCaddyStorageResponse
|
||||||
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
|
(*ListCertificatesResponse)(nil), // 8: api.ListCertificatesResponse
|
||||||
(*emptypb.Empty)(nil), // 9: google.protobuf.Empty
|
(*IssuedCertificate)(nil), // 9: api.IssuedCertificate
|
||||||
|
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||||
|
(*emptypb.Empty)(nil), // 11: google.protobuf.Empty
|
||||||
}
|
}
|
||||||
var file_api_pb_caddy_storage_proto_depIdxs = []int32{
|
var file_api_pb_caddy_storage_proto_depIdxs = []int32{
|
||||||
8, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
|
10, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||||
8, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
|
10, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||||
0, // 2: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest
|
9, // 2: api.ListCertificatesResponse.certificates:type_name -> api.IssuedCertificate
|
||||||
1, // 3: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest
|
0, // 3: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest
|
||||||
3, // 4: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest
|
1, // 4: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest
|
||||||
4, // 5: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest
|
3, // 5: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest
|
||||||
6, // 6: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest
|
4, // 6: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest
|
||||||
9, // 7: api.CaddyStorage.Store:output_type -> google.protobuf.Empty
|
6, // 7: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest
|
||||||
2, // 8: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse
|
11, // 8: api.CaddyStorage.ListCertificates:input_type -> google.protobuf.Empty
|
||||||
9, // 9: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty
|
11, // 9: api.CaddyStorage.Store:output_type -> google.protobuf.Empty
|
||||||
5, // 10: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse
|
2, // 10: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse
|
||||||
7, // 11: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse
|
11, // 11: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty
|
||||||
7, // [7:12] is the sub-list for method output_type
|
5, // 12: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse
|
||||||
2, // [2:7] is the sub-list for method input_type
|
7, // 13: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse
|
||||||
2, // [2:2] is the sub-list for extension type_name
|
8, // 14: api.CaddyStorage.ListCertificates:output_type -> api.ListCertificatesResponse
|
||||||
2, // [2:2] is the sub-list for extension extendee
|
9, // [9:15] is the sub-list for method output_type
|
||||||
0, // [0:2] is the sub-list for field type_name
|
3, // [3:9] is the sub-list for method input_type
|
||||||
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_api_pb_caddy_storage_proto_init() }
|
func init() { file_api_pb_caddy_storage_proto_init() }
|
||||||
@@ -667,6 +802,30 @@ func file_api_pb_caddy_storage_proto_init() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
file_api_pb_caddy_storage_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*ListCertificatesResponse); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_api_pb_caddy_storage_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*IssuedCertificate); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
@@ -674,7 +833,7 @@ func file_api_pb_caddy_storage_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: file_api_pb_caddy_storage_proto_rawDesc,
|
RawDescriptor: file_api_pb_caddy_storage_proto_rawDesc,
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 8,
|
NumMessages: 10,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ service CaddyStorage {
|
|||||||
rpc Delete(DeleteCaddyStorageRequest) returns (google.protobuf.Empty);
|
rpc Delete(DeleteCaddyStorageRequest) returns (google.protobuf.Empty);
|
||||||
rpc List(ListCaddyStorageRequest) returns (ListCaddyStorageResponse);
|
rpc List(ListCaddyStorageRequest) returns (ListCaddyStorageResponse);
|
||||||
rpc Stat(StatCaddyStorageRequest) returns (StatCaddyStorageResponse);
|
rpc Stat(StatCaddyStorageRequest) returns (StatCaddyStorageResponse);
|
||||||
|
|
||||||
|
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
|
||||||
|
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
|
||||||
|
rpc ListCertificates(google.protobuf.Empty) returns (ListCertificatesResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message StoreCaddyStorageRequest {
|
message StoreCaddyStorageRequest {
|
||||||
@@ -57,3 +61,17 @@ message StatCaddyStorageResponse {
|
|||||||
int64 size = 3;
|
int64 size = 3;
|
||||||
bool is_terminal = 4;
|
bool is_terminal = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ListCertificatesResponse {
|
||||||
|
repeated IssuedCertificate certificates = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message IssuedCertificate {
|
||||||
|
// Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs.
|
||||||
|
string san = 1;
|
||||||
|
// The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first.
|
||||||
|
bytes chain = 2;
|
||||||
|
// Any extra information associated with the certificate, usually provided by the issuer implementation.
|
||||||
|
// JSON-encoded, may be absent or null.
|
||||||
|
bytes issuer_data = 3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store"
|
CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store"
|
||||||
CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load"
|
CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load"
|
||||||
CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete"
|
CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete"
|
||||||
CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List"
|
CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List"
|
||||||
CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat"
|
CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat"
|
||||||
|
CaddyStorage_ListCertificates_FullMethodName = "/api.CaddyStorage/ListCertificates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CaddyStorageClient is the client API for CaddyStorage service.
|
// CaddyStorageClient is the client API for CaddyStorage service.
|
||||||
@@ -42,6 +43,9 @@ type CaddyStorageClient interface {
|
|||||||
Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error)
|
List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error)
|
||||||
Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error)
|
Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error)
|
||||||
|
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
|
||||||
|
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
|
||||||
|
ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type caddyStorageClient struct {
|
type caddyStorageClient struct {
|
||||||
@@ -102,6 +106,16 @@ func (c *caddyStorageClient) Stat(ctx context.Context, in *StatCaddyStorageReque
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *caddyStorageClient) ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListCertificatesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, CaddyStorage_ListCertificates_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CaddyStorageServer is the server API for CaddyStorage service.
|
// CaddyStorageServer is the server API for CaddyStorage service.
|
||||||
// All implementations must embed UnimplementedCaddyStorageServer
|
// All implementations must embed UnimplementedCaddyStorageServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -117,6 +131,9 @@ type CaddyStorageServer interface {
|
|||||||
Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error)
|
Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error)
|
||||||
List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error)
|
List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error)
|
||||||
Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error)
|
Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error)
|
||||||
|
// ListCertificates lists certificates issued by Caddy and stored in the cluster store.
|
||||||
|
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
|
||||||
|
ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error)
|
||||||
mustEmbedUnimplementedCaddyStorageServer()
|
mustEmbedUnimplementedCaddyStorageServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +159,9 @@ func (UnimplementedCaddyStorageServer) List(context.Context, *ListCaddyStorageRe
|
|||||||
func (UnimplementedCaddyStorageServer) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) {
|
func (UnimplementedCaddyStorageServer) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Stat not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method Stat not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCaddyStorageServer) ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListCertificates not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedCaddyStorageServer) mustEmbedUnimplementedCaddyStorageServer() {}
|
func (UnimplementedCaddyStorageServer) mustEmbedUnimplementedCaddyStorageServer() {}
|
||||||
func (UnimplementedCaddyStorageServer) testEmbeddedByValue() {}
|
func (UnimplementedCaddyStorageServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -253,6 +273,24 @@ func _CaddyStorage_Stat_Handler(srv interface{}, ctx context.Context, dec func(i
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _CaddyStorage_ListCertificates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(emptypb.Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CaddyStorageServer).ListCertificates(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CaddyStorage_ListCertificates_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CaddyStorageServer).ListCertificates(ctx, req.(*emptypb.Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// CaddyStorage_ServiceDesc is the grpc.ServiceDesc for CaddyStorage service.
|
// CaddyStorage_ServiceDesc is the grpc.ServiceDesc for CaddyStorage service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -280,6 +318,10 @@ var CaddyStorage_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "Stat",
|
MethodName: "Stat",
|
||||||
Handler: _CaddyStorage_Stat_Handler,
|
Handler: _CaddyStorage_Stat_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListCertificates",
|
||||||
|
Handler: _CaddyStorage_ListCertificates_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "api/pb/caddy_storage.proto",
|
Metadata: "api/pb/caddy_storage.proto",
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Err returns the upstream error, preserving its gRPC status code.
|
||||||
|
// It returns nil for nil metadata or an absent or successful status.
|
||||||
|
func (m *Metadata) Err() error {
|
||||||
|
return status.ErrorProto(m.GetStatus())
|
||||||
|
}
|
||||||
|
|
||||||
func NewIP(addr netip.Addr) *IP {
|
func NewIP(addr netip.Addr) *IP {
|
||||||
// MarshalBinary always returns a nil error.
|
// MarshalBinary always returns a nil error.
|
||||||
ip, _ := addr.MarshalBinary()
|
ip, _ := addr.MarshalBinary()
|
||||||
|
|||||||
+2
-1
@@ -87,7 +87,8 @@ type Metadata struct {
|
|||||||
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
|
MachineName string `protobuf:"bytes,5,opt,name=machine_name,json=machineName,proto3" json:"machine_name,omitempty"`
|
||||||
// Address of the machine the response came from.
|
// Address of the machine the response came from.
|
||||||
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
|
MachineAddr string `protobuf:"bytes,1,opt,name=machine_addr,json=machineAddr,proto3" json:"machine_addr,omitempty"`
|
||||||
// error is set if the request to upstream failed. The rest of the response is undefined.
|
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
|
||||||
|
// is undefined.
|
||||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||||
// error as a gRPC Status message.
|
// error as a gRPC Status message.
|
||||||
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
|
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,8 @@ message Metadata {
|
|||||||
// Address of the machine the response came from.
|
// Address of the machine the response came from.
|
||||||
string machine_addr = 1;
|
string machine_addr = 1;
|
||||||
|
|
||||||
// error is set if the request to upstream failed. The rest of the response is undefined.
|
// error is set if the request to upstream failed. The rest of the response apart from this metadata message
|
||||||
|
// is undefined.
|
||||||
string error = 2;
|
string error = 2;
|
||||||
// error as a gRPC Status message.
|
// error as a gRPC Status message.
|
||||||
google.rpc.Status status = 3;
|
google.rpc.Status status = 3;
|
||||||
|
|||||||
+719
-296
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ service Docker {
|
|||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||||
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
||||||
|
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
|
||||||
|
|
||||||
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||||
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||||
@@ -179,6 +180,40 @@ message ListImagesRequest {
|
|||||||
bytes options = 1;
|
bytes options = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message RemoveImageRequest {
|
||||||
|
string id = 1;
|
||||||
|
bool force = 2;
|
||||||
|
bool prune_children = 3;
|
||||||
|
repeated ImagePlatform platforms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImagePlatform describes an OCI image platform.
|
||||||
|
message ImagePlatform {
|
||||||
|
string architecture = 1;
|
||||||
|
string os = 2;
|
||||||
|
string os_version = 3;
|
||||||
|
repeated string os_features = 4;
|
||||||
|
string variant = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveImageResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting RemoveImage requests to multiple machines.
|
||||||
|
repeated MachineImageRemoval messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MachineImageRemoval {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
repeated ImageRemoval results = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageRemoval reports an image deletion or an untagged image reference.
|
||||||
|
message ImageRemoval {
|
||||||
|
// The image ID of an image that was deleted.
|
||||||
|
string deleted = 1;
|
||||||
|
// The image reference of an image that was untagged.
|
||||||
|
string untagged = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message ListImagesResponse {
|
message ListImagesResponse {
|
||||||
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
|
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
|
||||||
repeated MachineImages messages = 1;
|
repeated MachineImages messages = 1;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||||
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
||||||
|
Docker_RemoveImage_FullMethodName = "/api.Docker/RemoveImage"
|
||||||
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||||
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||||
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||||
@@ -59,6 +60,7 @@ type DockerClient interface {
|
|||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||||
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
|
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
|
||||||
|
RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error)
|
||||||
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||||
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||||
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
@@ -217,6 +219,16 @@ func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, op
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) RemoveImage(ctx context.Context, in *RemoveImageRequest, opts ...grpc.CallOption) (*RemoveImageResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(RemoveImageResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_RemoveImage_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CreateVolumeResponse)
|
out := new(CreateVolumeResponse)
|
||||||
@@ -305,6 +317,7 @@ type DockerServer interface {
|
|||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||||
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
||||||
|
RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error)
|
||||||
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||||
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||||
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||||
@@ -358,6 +371,9 @@ func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRem
|
|||||||
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
|
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedDockerServer) RemoveImage(context.Context, *RemoveImageRequest) (*RemoveImageResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveImage not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||||
}
|
}
|
||||||
@@ -591,6 +607,24 @@ func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(i
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _Docker_RemoveImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveImageRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).RemoveImage(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_RemoveImage_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).RemoveImage(ctx, req.(*RemoveImageRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(CreateVolumeRequest)
|
in := new(CreateVolumeRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -760,6 +794,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListImages",
|
MethodName: "ListImages",
|
||||||
Handler: _Docker_ListImages_Handler,
|
Handler: _Docker_ListImages_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveImage",
|
||||||
|
Handler: _Docker_RemoveImage_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "CreateVolume",
|
MethodName: "CreateVolume",
|
||||||
Handler: _Docker_CreateVolume_Handler,
|
Handler: _Docker_CreateVolume_Handler,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ service Machine {
|
|||||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||||
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
|
rpc InspectMachine(google.protobuf.Empty) returns (InspectMachineResponse);
|
||||||
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
||||||
// no known missing or pending transactions through those versions.
|
// no known missing or pending transactions through those versions from active members.
|
||||||
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
||||||
// have been superseded.
|
// have been superseded.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ type MachineClient interface {
|
|||||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||||
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
|
InspectMachine(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*InspectMachineResponse, error)
|
||||||
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
||||||
// no known missing or pending transactions through those versions.
|
// no known missing or pending transactions through those versions from active members.
|
||||||
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
||||||
// have been superseded.
|
// have been superseded.
|
||||||
//
|
//
|
||||||
@@ -224,7 +224,7 @@ type MachineServer interface {
|
|||||||
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
// InspectMachine retrieves detailed information about the machine. Supports broadcasting to multiple machines.
|
||||||
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
|
InspectMachine(context.Context, *emptypb.Empty) (*InspectMachineResponse, error)
|
||||||
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
// WaitForStoreVersion waits until the cluster store on this machine has reached each requested actor version, with
|
||||||
// no known missing or pending transactions through those versions.
|
// no known missing or pending transactions through those versions from active members.
|
||||||
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
// Corrosion may satisfy a version by applying its surviving changes or by marking it complete because its changes
|
||||||
// have been superseded.
|
// have been superseded.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package caddystorage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
|
||||||
"github.com/psviderski/uncloud/pkg/distlock"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
lockPrefix = "caddy_storage:"
|
|
||||||
storeReplicationTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// Lock acquires an automatically renewed distributed lock and waits for the local store to catch up with versions
|
|
||||||
// observed on responding machines. Writers using the same lock can then read locally. Unavailable machines may have
|
|
||||||
// writes that this wait does not cover, and reads outside a lock remain eventually consistent.
|
|
||||||
func (s *Storage) Lock(ctx context.Context, name string) (err error) {
|
|
||||||
if name == "" {
|
|
||||||
return errors.New("lock name is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count the call first. If Cleanup has already observed zero, Caddy has cancelled s.ctx and the check below rejects
|
|
||||||
// this call before it uses the client.
|
|
||||||
s.lockOps.Add(1)
|
|
||||||
// A failed Lock owns its lifecycle through any lease rollback. A successful Lock transfers that responsibility to
|
|
||||||
// Unlock, which keeps the client open until its release attempt finishes.
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
s.lockOps.Add(-1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if s.ctx.Err() != nil {
|
|
||||||
return errors.New("storage is closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop acquisition retries when Caddy unloads the module (cancels s.ctx), even if the caller's context
|
|
||||||
// is still active.
|
|
||||||
ctx, cancel := context.WithCancelCause(ctx)
|
|
||||||
defer cancel(nil)
|
|
||||||
stopOnCleanup := context.AfterFunc(s.ctx, func() {
|
|
||||||
cancel(context.Cause(s.ctx))
|
|
||||||
})
|
|
||||||
defer stopOnCleanup()
|
|
||||||
|
|
||||||
log := s.log.With("lock", name)
|
|
||||||
started := time.Now()
|
|
||||||
log.Debug("acquiring lock", "ttl", time.Duration(s.LockTTL))
|
|
||||||
lease, err := s.locker.Acquire(ctx, lockPrefix+name)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("failed to acquire lock", "duration", time.Since(started), "error", err)
|
|
||||||
return fmt.Errorf("acquire lock '%s': %w", name, err)
|
|
||||||
}
|
|
||||||
log.Debug("lock lease acquired", "duration", time.Since(started))
|
|
||||||
// Release the lease if the lock acquisition fails after this point.
|
|
||||||
// Unlock will take care of releasing the lease on success.
|
|
||||||
defer func() {
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if releaseErr := s.releaseLock(ctx, name, lease, "cancelled acquisition"); releaseErr != nil {
|
|
||||||
err = errors.Join(err, releaseErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Catch up with the latest store versions observed on responding machines to increase the chance of reading
|
|
||||||
// the latest writes on them locally.
|
|
||||||
version, machines, err := s.clusterStoreVersion(ctx, log)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
waitStarted := time.Now()
|
|
||||||
log.Debug("waiting for local store replication", "machine_names", machines, "store_version", version)
|
|
||||||
waitCtx, cancelWait := context.WithTimeout(ctx, storeReplicationTimeout)
|
|
||||||
err = s.client.WaitForStoreVersion(waitCtx, version)
|
|
||||||
cancelWait()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wait for local store replication: %w", err)
|
|
||||||
}
|
|
||||||
log.Debug("local store replication complete", "duration", time.Since(waitStarted))
|
|
||||||
|
|
||||||
s.locksMu.Lock()
|
|
||||||
defer s.locksMu.Unlock()
|
|
||||||
if s.ctx.Err() != nil {
|
|
||||||
return errors.New("storage is closed")
|
|
||||||
}
|
|
||||||
if lost := context.Cause(lease.Context()); lost != nil {
|
|
||||||
return lost
|
|
||||||
}
|
|
||||||
if _, exists := s.locks[name]; exists {
|
|
||||||
return errors.New("lock is already tracked by this storage instance")
|
|
||||||
}
|
|
||||||
s.locks[name] = lease
|
|
||||||
|
|
||||||
log.Debug("lock acquired", "duration", time.Since(started))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// clusterStoreVersion returns the per-actor maximum store versions from responding machines and their names.
|
|
||||||
func (s *Storage) clusterStoreVersion(ctx context.Context, log *slog.Logger) (map[string]uint64, []string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, distlock.DefaultMaxNodeCallTimeout)
|
|
||||||
defer cancel()
|
|
||||||
resp, err := s.client.MachineClient.InspectMachine(client.ProxyMachinesContext(ctx, nil), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("inspect machines for store versions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxVersion := make(map[string]uint64)
|
|
||||||
machines := make([]string, 0, len(resp.Machines))
|
|
||||||
for _, m := range resp.Machines {
|
|
||||||
if m.Metadata.Error != "" {
|
|
||||||
log.Warn("skipping machine when collecting store versions",
|
|
||||||
"id", m.Metadata.MachineId, "name", m.Metadata.MachineName, "error", m.Metadata.Error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
machines = append(machines, m.Metadata.MachineName)
|
|
||||||
for actor, v := range m.StoreVersion {
|
|
||||||
maxVersion[actor] = max(maxVersion[actor], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(machines)
|
|
||||||
return maxVersion, machines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock releases a previously acquired distributed lock.
|
|
||||||
func (s *Storage) Unlock(ctx context.Context, name string) error {
|
|
||||||
s.locksMu.Lock()
|
|
||||||
lease, exists := s.locks[name]
|
|
||||||
if exists {
|
|
||||||
delete(s.locks, name)
|
|
||||||
}
|
|
||||||
s.locksMu.Unlock()
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("lock '%s' is not held by this storage instance", name)
|
|
||||||
}
|
|
||||||
defer s.lockOps.Add(-1)
|
|
||||||
|
|
||||||
// Release stops renewal even on error. Any nodes that cannot be reached will let the lease expire.
|
|
||||||
if err := s.releaseLock(ctx, name, lease, "unlock"); err != nil {
|
|
||||||
return fmt.Errorf("release lock '%s': %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseLock releases the lease with a context that ignores cancellation and logs the release attempt.
|
|
||||||
func (s *Storage) releaseLock(ctx context.Context, name string, lease *distlock.Lease, reason string) error {
|
|
||||||
// Unlock and rollback must attempt node cleanup even if the caller or module has already been cancelled.
|
|
||||||
ctx = context.WithoutCancel(ctx)
|
|
||||||
|
|
||||||
log := s.log.With("lock", name, "reason", reason)
|
|
||||||
started := time.Now()
|
|
||||||
log.Debug("releasing lock")
|
|
||||||
if err := lease.Release(ctx); err != nil {
|
|
||||||
log.Debug("failed to release lock", "duration", time.Since(started), "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Debug("lock released", "duration", time.Since(started))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
// Package caddystorage provides Caddy storage backed by an Uncloud cluster.
|
|
||||||
package caddystorage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/caddyserver/caddy/v2"
|
|
||||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
|
||||||
"github.com/caddyserver/certmagic"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
|
||||||
"github.com/psviderski/uncloud/pkg/distlock"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ModuleID is the Caddy module ID for Uncloud storage.
|
|
||||||
ModuleID = "caddy.storage.uncloud"
|
|
||||||
// DefaultSocketPath is the default path to the Uncloud API socket.
|
|
||||||
DefaultSocketPath = "/run/uncloud/api/uncloud.sock"
|
|
||||||
// DefaultLockTTL is the default duration of a distributed lock lease.
|
|
||||||
DefaultLockTTL = 20 * time.Second
|
|
||||||
// lockCleanupTimeout bounds how long an unloaded module waits for active lock operations when cleaning up.
|
|
||||||
lockCleanupTimeout = 5 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
//nolint:gochecknoinits // Caddy modules must register during package initialisation.
|
|
||||||
func init() {
|
|
||||||
caddy.RegisterModule(new(Storage))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storage implements a Caddy storage backend that uses an Uncloud cluster to store assets such as TLS certificates.
|
|
||||||
type Storage struct {
|
|
||||||
// Socket is the path to the Uncloud API socket.
|
|
||||||
// Defaults to /run/uncloud/api/uncloud.sock when not set.
|
|
||||||
Socket string `json:"socket,omitempty"`
|
|
||||||
// LockTTL is the duration of a distributed lock after which it expires if not renewed. Locks renew automatically
|
|
||||||
// until unlocked. If an instance crashes or cannot renew, expiry allows another instance to acquire the stale lock.
|
|
||||||
// Longer durations tolerate longer interruptions but delay recovery after a crash. Normal unlocks release the lock
|
|
||||||
// immediately.
|
|
||||||
// Defaults to 20 seconds when not set.
|
|
||||||
LockTTL caddy.Duration `json:"lock_ttl,omitempty"`
|
|
||||||
|
|
||||||
client *client.Client
|
|
||||||
locker *distlock.Locker
|
|
||||||
log *slog.Logger
|
|
||||||
|
|
||||||
// ctx is the module context from Provision. Caddy cancels it when it unloads the module and calls Cleanup.
|
|
||||||
ctx context.Context
|
|
||||||
|
|
||||||
// locksMu protects locks.
|
|
||||||
locksMu sync.Mutex
|
|
||||||
// locks maps successfully acquired lock names to held leases.
|
|
||||||
locks map[string]*distlock.Lease
|
|
||||||
// lockOps counts Lock calls until they fail or their locks are released.
|
|
||||||
lockOps atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaddyModule returns the Caddy module information.
|
|
||||||
func (*Storage) CaddyModule() caddy.ModuleInfo {
|
|
||||||
return caddy.ModuleInfo{
|
|
||||||
ID: ModuleID,
|
|
||||||
New: func() caddy.Module { return new(Storage) },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision connects the storage to the local Uncloud API and initialises the distributed locker.
|
|
||||||
func (s *Storage) Provision(ctx caddy.Context) error {
|
|
||||||
s.ctx = ctx
|
|
||||||
s.log = ctx.Slogger()
|
|
||||||
|
|
||||||
if s.Socket == "" {
|
|
||||||
s.Socket = DefaultSocketPath
|
|
||||||
}
|
|
||||||
if s.LockTTL == 0 {
|
|
||||||
s.LockTTL = caddy.Duration(DefaultLockTTL)
|
|
||||||
}
|
|
||||||
if s.LockTTL < 0 {
|
|
||||||
return errors.New("lock_ttl must be positive")
|
|
||||||
}
|
|
||||||
|
|
||||||
cli, err := client.New(ctx, connector.NewUnixConnector(s.Socket))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to Uncloud API: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
locker, err := cli.NewLocker(distlock.Config{
|
|
||||||
LeaseDuration: time.Duration(s.LockTTL),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
_ = cli.Close()
|
|
||||||
return fmt.Errorf("create distributed locker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.client = cli
|
|
||||||
s.locker = locker
|
|
||||||
s.locks = make(map[string]*distlock.Lease)
|
|
||||||
|
|
||||||
s.log.Info("module provisioned", "socket", s.Socket, "lock_ttl", time.Duration(s.LockTTL))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup keeps the Uncloud API connection open so acquired lock leases can renew until Caddy releases them. It closes
|
|
||||||
// the connection after all lock operations finish or the cleanup timeout expires.
|
|
||||||
func (s *Storage) Cleanup() error {
|
|
||||||
if s.client == nil {
|
|
||||||
// Provision failed before opening the storage.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
started := time.Now()
|
|
||||||
s.log.Debug("cleaning up module", "locks", s.lockOps.Load())
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
timer := time.NewTimer(lockCleanupTimeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
timedOut := false
|
|
||||||
for !timedOut && s.lockOps.Load() > 0 {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
case <-timer.C:
|
|
||||||
timedOut = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if remaining := s.lockOps.Load(); timedOut && remaining > 0 {
|
|
||||||
s.log.Warn("timed out waiting for active locks to be unlocked",
|
|
||||||
"locks", remaining, "timeout", lockCleanupTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.client.Close(); err != nil {
|
|
||||||
s.log.Warn("failed to clean up module", "duration", time.Since(started), "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.log.Debug("module cleanup complete", "duration", time.Since(started))
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CertMagicStorage returns the provisioned CertMagic storage implementation.
|
|
||||||
func (s *Storage) CertMagicStorage() (certmagic.Storage, error) {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalCaddyfile configures Uncloud storage from the Caddyfile global storage block.
|
|
||||||
//
|
|
||||||
// {
|
|
||||||
// storage uncloud {
|
|
||||||
// socket /run/uncloud/api/uncloud.sock
|
|
||||||
// lock_ttl 20s
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
func (s *Storage) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|
||||||
d.Next() // Skip the module name 'uncloud'.
|
|
||||||
// Reject inline arguments. NextArg leaves an opening brace for NextBlock.
|
|
||||||
if d.NextArg() {
|
|
||||||
return d.ArgErr()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the optional options block, skipping its surrounding braces.
|
|
||||||
for d.NextBlock(0) {
|
|
||||||
switch d.Val() {
|
|
||||||
case "socket":
|
|
||||||
// Require a socket path on the same line as 'socket' option.
|
|
||||||
if !d.NextArg() {
|
|
||||||
return d.ArgErr()
|
|
||||||
}
|
|
||||||
s.Socket = d.Val()
|
|
||||||
// Reject extra arguments after the socket path.
|
|
||||||
if d.NextArg() {
|
|
||||||
return d.ArgErr()
|
|
||||||
}
|
|
||||||
case "lock_ttl":
|
|
||||||
if !d.NextArg() {
|
|
||||||
return d.ArgErr()
|
|
||||||
}
|
|
||||||
ttl, err := caddy.ParseDuration(d.Val())
|
|
||||||
if err != nil {
|
|
||||||
return d.Errf("invalid lock_ttl '%s': %w", d.Val(), err)
|
|
||||||
}
|
|
||||||
if ttl <= 0 {
|
|
||||||
return d.Err("lock_ttl must be positive")
|
|
||||||
}
|
|
||||||
if d.NextArg() {
|
|
||||||
return d.ArgErr()
|
|
||||||
}
|
|
||||||
s.LockTTL = caddy.Duration(ttl)
|
|
||||||
default:
|
|
||||||
return d.Errf("unknown uncloud storage option: '%s'", d.Val())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ caddy.Module = (*Storage)(nil)
|
|
||||||
_ caddy.Provisioner = (*Storage)(nil)
|
|
||||||
_ caddy.CleanerUpper = (*Storage)(nil)
|
|
||||||
_ caddy.StorageConverter = (*Storage)(nil)
|
|
||||||
_ caddyfile.Unmarshaler = (*Storage)(nil)
|
|
||||||
_ certmagic.Storage = (*Storage)(nil)
|
|
||||||
)
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package caddystorage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
|
|
||||||
"github.com/caddyserver/certmagic"
|
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store writes a value to the cluster store.
|
|
||||||
func (s *Storage) Store(ctx context.Context, key string, value []byte) error {
|
|
||||||
if _, err := s.client.CaddyStorage.Store(ctx, &pb.StoreCaddyStorageRequest{Key: key, Value: value}); err != nil {
|
|
||||||
return storageError("store", key, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads a value from the cluster store.
|
|
||||||
func (s *Storage) Load(ctx context.Context, key string) ([]byte, error) {
|
|
||||||
resp, err := s.client.CaddyStorage.Load(ctx, &pb.LoadCaddyStorageRequest{Key: key})
|
|
||||||
if err != nil {
|
|
||||||
return nil, storageError("load", key, err)
|
|
||||||
}
|
|
||||||
return resp.Value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a key and its descendants from the cluster store.
|
|
||||||
func (s *Storage) Delete(ctx context.Context, key string) error {
|
|
||||||
if _, err := s.client.CaddyStorage.Delete(ctx, &pb.DeleteCaddyStorageRequest{Key: key}); err != nil {
|
|
||||||
return storageError("delete", key, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists reports whether a key exists in the cluster store.
|
|
||||||
func (s *Storage) Exists(ctx context.Context, key string) bool {
|
|
||||||
_, err := s.Stat(ctx, key)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns keys under prefix from the cluster store.
|
|
||||||
func (s *Storage) List(ctx context.Context, prefix string, recursive bool) ([]string, error) {
|
|
||||||
resp, err := s.client.CaddyStorage.List(ctx, &pb.ListCaddyStorageRequest{
|
|
||||||
Prefix: prefix,
|
|
||||||
Recursive: recursive,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, storageError("list", prefix, err)
|
|
||||||
}
|
|
||||||
return resp.Keys, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat returns information about a key in the cluster store.
|
|
||||||
func (s *Storage) Stat(ctx context.Context, key string) (certmagic.KeyInfo, error) {
|
|
||||||
resp, err := s.client.CaddyStorage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: key})
|
|
||||||
if err != nil {
|
|
||||||
return certmagic.KeyInfo{}, storageError("stat", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info := certmagic.KeyInfo{
|
|
||||||
Key: resp.Key,
|
|
||||||
Size: resp.Size,
|
|
||||||
IsTerminal: resp.IsTerminal,
|
|
||||||
}
|
|
||||||
if resp.UpdatedAt != nil {
|
|
||||||
if err := resp.UpdatedAt.CheckValid(); err != nil {
|
|
||||||
return certmagic.KeyInfo{}, storageError("stat", key, fmt.Errorf("invalid updated_at timestamp: %w", err))
|
|
||||||
}
|
|
||||||
info.Modified = resp.UpdatedAt.AsTime()
|
|
||||||
}
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func storageError(operation, key string, err error) error {
|
|
||||||
if status.Code(err) == codes.NotFound {
|
|
||||||
err = fs.ErrNotExist
|
|
||||||
}
|
|
||||||
return fmt.Errorf("uncloud storage: %s key %q: %w", operation, key, err)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listOptions struct {
|
||||||
|
machine string
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
opts := listOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List certificates in cluster storage for Caddy.",
|
||||||
|
Long: `List certificates stored in the Uncloud cluster storage for Caddy.
|
||||||
|
|
||||||
|
Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud)
|
||||||
|
configured with 'storage uncloud' in the global options for its certificates to appear here.
|
||||||
|
|
||||||
|
This inventory does not check whether Caddy currently serves or trusts a certificate.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine to read certificate storage from. (default is connected machine)")
|
||||||
|
cmd.Flags().StringVarP(&opts.output, "output", "o", "",
|
||||||
|
"Output format: 'json' or empty for a human-readable table.")
|
||||||
|
completion.MachinesFlag(cmd)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
|
if opts.output != "" && opts.output != "json" {
|
||||||
|
return fmt.Errorf("unsupported output format '%s' (supported: json)", opts.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
certs, listErr := clusterClient.Caddy.ListCertificates(ctx,
|
||||||
|
client.CaddyListCertificatesOptions{Machine: opts.machine})
|
||||||
|
if len(certs) > 0 || listErr == nil {
|
||||||
|
if err = printCertificates(os.Stdout, certs, opts.output); err != nil {
|
||||||
|
return fmt.Errorf("print Caddy certificates: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if listErr != nil {
|
||||||
|
if len(certs) > 0 {
|
||||||
|
return fmt.Errorf("certificate list is partial: %w", listErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("list Caddy certificates: %w", listErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCertificates(out io.Writer, certs []api.IssuedCertificate, output string) error {
|
||||||
|
type certificate struct {
|
||||||
|
api.IssuedCertificate
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]certificate, 0, len(certs))
|
||||||
|
for _, cert := range certs {
|
||||||
|
fingerprint := cert.Fingerprint()
|
||||||
|
items = append(items, certificate{
|
||||||
|
IssuedCertificate: cert,
|
||||||
|
Fingerprint: hex.EncodeToString(fingerprint[:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(items, func(a, b certificate) int {
|
||||||
|
if cmp := strings.Compare(a.SAN, b.SAN); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
if cmp := a.Chain[0].NotAfter.Compare(b.Chain[0].NotAfter); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
return strings.Compare(a.Fingerprint, b.Fingerprint)
|
||||||
|
})
|
||||||
|
|
||||||
|
if output == "json" {
|
||||||
|
encoder := json.NewEncoder(out)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(items); err != nil {
|
||||||
|
return fmt.Errorf("marshal certificates: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
_, err := fmt.Fprintln(out, "No Caddy certificates found in cluster storage.")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := tui.NewTable()
|
||||||
|
t.Headers("ID", "NAME", "ISSUER", "EXPIRES")
|
||||||
|
for _, item := range items {
|
||||||
|
t.Row(
|
||||||
|
item.Fingerprint[:12],
|
||||||
|
item.SAN,
|
||||||
|
formatIssuer(item.IssuerData),
|
||||||
|
formatExpiry(item.Chain[0].NotAfter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_, err := lipgloss.Fprintln(out, t)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIssuer(data api.CertificateIssuerData) string {
|
||||||
|
if data.ACME == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data.ACME.CA {
|
||||||
|
case certmagic.LetsEncryptProductionCA:
|
||||||
|
return "Let's Encrypt"
|
||||||
|
case certmagic.LetsEncryptStagingCA:
|
||||||
|
return "Let's Encrypt (staging)"
|
||||||
|
case certmagic.ZeroSSLProductionCA:
|
||||||
|
return "ZeroSSL"
|
||||||
|
case certmagic.GoogleTrustProductionCA:
|
||||||
|
return "Google Trust Services"
|
||||||
|
case certmagic.GoogleTrustStagingCA:
|
||||||
|
return "Google Trust Services (staging)"
|
||||||
|
default:
|
||||||
|
return data.ACME.CA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatExpiry(expires time.Time) string {
|
||||||
|
delta := expires.Sub(time.Now())
|
||||||
|
if delta <= 0 {
|
||||||
|
return fmt.Sprintf("%s (expired %s ago)", expires.UTC().Format(time.DateOnly),
|
||||||
|
strings.ToLower(units.HumanDuration(-delta)))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", expires.UTC().Format(time.DateOnly),
|
||||||
|
strings.ToLower(units.HumanDuration(delta)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintCertificates(t *testing.T) {
|
||||||
|
now := time.Now().UTC().Truncate(time.Hour)
|
||||||
|
certs := []api.IssuedCertificate{
|
||||||
|
testIssuedCertificate("z.example.com", "z", now.Add(48*time.Hour), certmagic.LetsEncryptProductionCA),
|
||||||
|
testIssuedCertificate("a.example.com", "old", now.Add(-48*time.Hour), certmagic.LetsEncryptStagingCA),
|
||||||
|
testIssuedCertificate("a.example.com", "new", now.Add(72*time.Hour), "https://ca.example/directory"),
|
||||||
|
testIssuedCertificate("unknown.example.com", "unknown", now.Add(96*time.Hour), ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
var table bytes.Buffer
|
||||||
|
require.NoError(t, printCertificates(&table, certs, ""))
|
||||||
|
output := table.String()
|
||||||
|
assert.Contains(t, output, "ID")
|
||||||
|
assert.Contains(t, output, "NAME")
|
||||||
|
assert.Contains(t, output, "ISSUER")
|
||||||
|
assert.Contains(t, output, "EXPIRES")
|
||||||
|
assert.Contains(t, output, now.Add(-48*time.Hour).Format(time.DateOnly)+" (expired ")
|
||||||
|
assert.Contains(t, output, now.Add(48*time.Hour).Format(time.DateOnly)+" (")
|
||||||
|
assert.Contains(t, output, "Let's Encrypt (staging)")
|
||||||
|
assert.Contains(t, output, "https://ca.example/directory")
|
||||||
|
assert.Less(t, strings.Index(output, now.Add(-48*time.Hour).Format(time.DateOnly)),
|
||||||
|
strings.Index(output, now.Add(72*time.Hour).Format(time.DateOnly)))
|
||||||
|
assert.Less(t, strings.Index(output, "a.example.com"), strings.Index(output, "z.example.com"))
|
||||||
|
assert.Contains(t, output, testFingerprint("old")[:12])
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
require.NoError(t, printCertificates(&encoded, certs, "json"))
|
||||||
|
var items []struct {
|
||||||
|
api.IssuedCertificate
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(encoded.Bytes(), &items))
|
||||||
|
require.Len(t, items, 4)
|
||||||
|
assert.Equal(t, testFingerprint("old"), items[0].Fingerprint)
|
||||||
|
assert.Equal(t, "a.example.com", items[0].SAN)
|
||||||
|
require.Len(t, items[0].Chain, 1)
|
||||||
|
assert.Equal(t, []byte("old"), items[0].Chain[0].Raw)
|
||||||
|
assert.Equal(t, now.Add(-48*time.Hour), items[0].Chain[0].NotAfter)
|
||||||
|
require.NotNil(t, items[0].IssuerData.ACME)
|
||||||
|
assert.Equal(t, certmagic.LetsEncryptStagingCA, items[0].IssuerData.ACME.CA)
|
||||||
|
assert.Equal(t, testFingerprint("new"), items[1].Fingerprint)
|
||||||
|
require.NotNil(t, items[1].IssuerData.ACME)
|
||||||
|
assert.Equal(t, "https://ca.example/directory", items[1].IssuerData.ACME.CA)
|
||||||
|
assert.Nil(t, items[2].IssuerData.ACME)
|
||||||
|
assert.Equal(t, "z.example.com", items[3].SAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintCertificates_Empty(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
output string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{output: "", want: "No Caddy certificates found in cluster storage.\n"},
|
||||||
|
{output: "json", want: "[]\n"},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
require.NoError(t, printCertificates(&out, nil, tt.output))
|
||||||
|
assert.Equal(t, tt.want, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatIssuer(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
ca string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "production", ca: certmagic.LetsEncryptProductionCA, want: "Let's Encrypt"},
|
||||||
|
{name: "staging", ca: certmagic.LetsEncryptStagingCA, want: "Let's Encrypt (staging)"},
|
||||||
|
{name: "ZeroSSL", ca: certmagic.ZeroSSLProductionCA, want: "ZeroSSL"},
|
||||||
|
{name: "Google Trust Services", ca: certmagic.GoogleTrustProductionCA, want: "Google Trust Services"},
|
||||||
|
{name: "Google Trust Services staging", ca: certmagic.GoogleTrustStagingCA, want: "Google Trust Services (staging)"},
|
||||||
|
{name: "other", ca: "https://ca.example/directory", want: "https://ca.example/directory"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, formatIssuer(api.CertificateIssuerData{
|
||||||
|
ACME: &api.ACMEIssuerData{CA: tt.ca},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "unknown", formatIssuer(api.CertificateIssuerData{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatExpiry_UTC(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
brisbane := time.FixedZone("AEST", 10*60*60)
|
||||||
|
expires := time.Date(now.Year(), now.Month(), now.Day()+3, 3, 0, 0, 0, brisbane)
|
||||||
|
assert.True(t, strings.HasPrefix(formatExpiry(expires), expires.UTC().Format(time.DateOnly)+" ("))
|
||||||
|
assert.NotEqual(t, expires.Format(time.DateOnly), expires.UTC().Format(time.DateOnly))
|
||||||
|
expired := now.Add(-2 * time.Hour)
|
||||||
|
assert.True(t, strings.HasPrefix(formatExpiry(expired), expired.UTC().Format(time.DateOnly)+" (expired "))
|
||||||
|
assert.Contains(t, formatExpiry(expired), " ago)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIssuedCertificate(name, raw string, expires time.Time, ca string) api.IssuedCertificate {
|
||||||
|
cert := api.IssuedCertificate{
|
||||||
|
SAN: name,
|
||||||
|
Chain: []*x509.Certificate{{Raw: []byte(raw), NotAfter: expires}},
|
||||||
|
}
|
||||||
|
if ca != "" {
|
||||||
|
cert.IssuerData.ACME = &api.ACMEIssuerData{CA: ca}
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFingerprint(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cert",
|
||||||
|
Short: "Inspect certificates in cluster storage for Caddy.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(NewListCommand())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+6
-17
@@ -3,18 +3,18 @@ package caddy
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/alecthomas/chroma/v2/quick"
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configOptions struct {
|
type configOptions struct {
|
||||||
machine string
|
machine string
|
||||||
noColor bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigCommand() *cobra.Command {
|
func NewConfigCommand() *cobra.Command {
|
||||||
@@ -32,8 +32,6 @@ func NewConfigCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
||||||
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
|
||||||
"Disable syntax highlighting for the output.")
|
|
||||||
|
|
||||||
completion.MachinesFlag(cmd)
|
completion.MachinesFlag(cmd)
|
||||||
|
|
||||||
@@ -47,24 +45,15 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
|||||||
}
|
}
|
||||||
defer clusterClient.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
if opts.machine != "" {
|
config, err := clusterClient.Caddy.Config(ctx, client.CaddyConfigOptions{Machine: opts.machine})
|
||||||
// If a specific machine is requested, use it to get the Caddy configuration.
|
|
||||||
ctx = clusterClient.ProxySingleMachineContext(ctx, opts.machine)
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get Caddy config: %w", err)
|
return fmt.Errorf("get Caddy config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print the Caddyfile with syntax highlighting.
|
// Print the Caddyfile with syntax highlighting.
|
||||||
if opts.noColor {
|
if err = quick.Highlight(lipgloss.Writer, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
||||||
fmt.Print(config.Caddyfile)
|
// If highlighting fails, fall back to plain output.
|
||||||
} else {
|
lipgloss.Print(config.Caddyfile)
|
||||||
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
|
||||||
// If highlighting fails, fall back to plain output.
|
|
||||||
fmt.Print(config.Caddyfile)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.LastReconciliationError != "" {
|
if config.LastReconciliationError != "" {
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
placement := api.Placement{
|
placement := api.Placement{
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
}
|
}
|
||||||
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
|
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{
|
||||||
|
Image: opts.image, Config: caddyfile, Placement: placement,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package caddy
|
package caddy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/psviderski/uncloud/cmd/uc/caddy/cert"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Short: "Manage Caddy reverse proxy service.",
|
Short: "Manage Caddy reverse proxy service.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
cert.NewRootCommand(),
|
||||||
NewConfigCommand(),
|
NewConfigCommand(),
|
||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
NewLogsCommand(),
|
NewLogsCommand(),
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ const defaultFailedContainerLogsTail = 10
|
|||||||
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
|
// UNCLOUD_FAILED_CONTAINER_LOGS_TAIL environment variable override when set and valid.
|
||||||
func failedContainerLogsTail() int {
|
func failedContainerLogsTail() int {
|
||||||
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
|
if v := os.Getenv("UNCLOUD_FAILED_CONTAINER_LOGS_TAIL"); v != "" {
|
||||||
if tail, err := logs.Tail(v); err == nil && (tail == -1 || tail > 0) {
|
if tail, err := logs.ParseTail(v); err == nil && (tail == -1 || tail > 0) {
|
||||||
return tail
|
return tail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
|
"github.com/psviderski/uncloud/internal/grpcversion"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ func set(ctx context.Context, uncli *cli.CLI, name string) error {
|
|||||||
}
|
}
|
||||||
defer clusterClient.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
ctx = metadata.AppendToOutgoingContext(ctx, grpcversion.MetadataKeyMinServerVersion, "0.21.0")
|
||||||
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
|
_, err = clusterClient.SetDomain(ctx, &pb.SetDomainRequest{Name: name})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.AlreadyExists {
|
if status.Convert(err).Code() == codes.AlreadyExists {
|
||||||
|
|||||||
+46
-45
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/charmbracelet/colorprofile"
|
"github.com/charmbracelet/colorprofile"
|
||||||
"github.com/containerd/platforms"
|
"github.com/containerd/platforms"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
@@ -102,7 +103,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
|||||||
var rows []imageRow
|
var rows []imageRow
|
||||||
|
|
||||||
for _, machineImages := range clusterImages {
|
for _, machineImages := range clusterImages {
|
||||||
if err := machineImages.Error(); err != nil {
|
if err := machineImages.Metadata.Err(); err != nil {
|
||||||
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
|
tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s",
|
||||||
machineImages.Metadata.MachineName, err))
|
machineImages.Metadata.MachineName, err))
|
||||||
continue
|
continue
|
||||||
@@ -116,49 +117,7 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
|||||||
store = "containerd"
|
store = "containerd"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each image for this machine.
|
rows = append(rows, imageRows(machineImages.Images, machineName, store)...)
|
||||||
for _, img := range machineImages.Images {
|
|
||||||
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
|
||||||
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
|
||||||
|
|
||||||
name := "<none>"
|
|
||||||
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
|
||||||
name = img.RepoTags[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
imgPlatforms, _ := imagePlatforms(img)
|
|
||||||
formattedPlatforms := formatPlatforms(imgPlatforms)
|
|
||||||
|
|
||||||
created := ""
|
|
||||||
createdAt := time.Unix(img.Created, 0)
|
|
||||||
if !createdAt.IsZero() {
|
|
||||||
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
|
||||||
}
|
|
||||||
|
|
||||||
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
|
||||||
|
|
||||||
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
|
||||||
inUse := "-"
|
|
||||||
if img.Containers != -1 { // -1 means the info is not available.
|
|
||||||
if img.Containers > 0 {
|
|
||||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
|
||||||
} else {
|
|
||||||
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, imageRow{
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
platforms: formattedPlatforms,
|
|
||||||
createdHuman: created,
|
|
||||||
createdUnix: img.Created,
|
|
||||||
size: size,
|
|
||||||
inUse: inUse,
|
|
||||||
store: store,
|
|
||||||
machine: machineName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
@@ -184,13 +143,55 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// imageRows keeps every stored name visible and retains digest-only and unnamed images.
|
||||||
|
func imageRows(images []image.Summary, machineName, store string) []imageRow {
|
||||||
|
var rows []imageRow
|
||||||
|
for _, img := range images {
|
||||||
|
// RepoTags includes stored digest names on containerd. RepoDigests can include derived names.
|
||||||
|
imageNames := img.RepoTags
|
||||||
|
if len(imageNames) == 0 {
|
||||||
|
imageNames = []string{"<untagged>"}
|
||||||
|
}
|
||||||
|
|
||||||
|
imgPlatforms, _ := imagePlatforms(img)
|
||||||
|
created := ""
|
||||||
|
if img.Created > 0 {
|
||||||
|
created = units.HumanDuration(time.Since(time.Unix(img.Created, 0))) + " ago"
|
||||||
|
}
|
||||||
|
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||||
|
inUse := "-"
|
||||||
|
if img.Containers >= 0 { // -1 means the info is not available.
|
||||||
|
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||||
|
if img.Containers > 0 {
|
||||||
|
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range imageNames {
|
||||||
|
rows = append(rows, imageRow{
|
||||||
|
id: stringid.TruncateID(img.ID),
|
||||||
|
name: name,
|
||||||
|
platforms: formatPlatforms(imgPlatforms),
|
||||||
|
createdHuman: created,
|
||||||
|
createdUnix: img.Created,
|
||||||
|
size: units.HumanSizeWithPrecision(float64(img.Size), 3),
|
||||||
|
inUse: inUse,
|
||||||
|
store: store,
|
||||||
|
machine: machineName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
||||||
func imagePlatforms(img image.Summary) ([]string, bool) {
|
func imagePlatforms(img image.Summary) ([]string, bool) {
|
||||||
var formattedPlatforms []string
|
var formattedPlatforms []string
|
||||||
multiPlatform := false
|
multiPlatform := false
|
||||||
|
|
||||||
for _, m := range img.Manifests {
|
for _, m := range img.Manifests {
|
||||||
if m.Kind != image.ManifestKindImage || !m.Available {
|
if m.Kind != image.ManifestKindImage || !m.Available || m.ImageData == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
|
|||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Preparing Caddy deployment...")
|
fmt.Println("Preparing Caddy deployment...")
|
||||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
|
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: caddyImage})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,17 +221,17 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
initOpts.WireguardEndpoints = endpoints
|
initOpts.WireguardEndpoints = endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := uncli.InitCluster(ctx, initOpts)
|
clusterClient, err := uncli.InitCluster(ctx, initOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
// Since the cluster API needs a few moments to become ready after cluster initialisation,
|
// Since the cluster API needs a few moments to become ready after cluster initialisation,
|
||||||
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
|
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
|
||||||
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
|
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
|
||||||
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
|
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
|
||||||
return client.WaitClusterReady(ctx, 1*time.Minute)
|
return clusterClient.WaitClusterReady(ctx, 1*time.Minute)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
||||||
@@ -245,7 +245,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
if !opts.noDNS {
|
if !opts.noDNS {
|
||||||
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.noCaddy {
|
if !opts.noCaddy {
|
||||||
d, err := client.NewCaddyDeployment("", "", api.Placement{})
|
d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create caddy deployment: %w", err)
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
|
return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+12
-7
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||||
@@ -64,6 +65,15 @@ If no services are specified, streams logs from the uncloud service.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
||||||
|
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tail, err := opts.TailLines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if len(services) == 0 {
|
if len(services) == 0 {
|
||||||
services = []string{api.SystemServiceUncloud}
|
services = []string{api.SystemServiceUncloud}
|
||||||
}
|
}
|
||||||
@@ -74,11 +84,6 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tail, err := logs.Tail(opts.Tail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := uncli.ConnectCluster(ctx)
|
c, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
@@ -88,8 +93,8 @@ func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.O
|
|||||||
logsOpts := api.ServiceLogsOptions{
|
logsOpts := api.ServiceLogsOptions{
|
||||||
Follow: opts.Follow,
|
Follow: opts.Follow,
|
||||||
Tail: tail,
|
Tail: tail,
|
||||||
Since: opts.Since,
|
Since: since,
|
||||||
Until: opts.Until,
|
Until: until,
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunLogsInvalidTimeFilters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Invalid filters must fail before connecting to the cluster.
|
||||||
|
for _, flag := range []string{"since", "until"} {
|
||||||
|
opts := logs.Options{}
|
||||||
|
if flag == "since" {
|
||||||
|
opts.Since = "invalid"
|
||||||
|
} else {
|
||||||
|
opts.Until = "invalid"
|
||||||
|
}
|
||||||
|
err := runLogs(context.Background(), nil, nil, opts)
|
||||||
|
require.ErrorContains(t, err, "invalid --"+flag+" value")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,8 +54,8 @@ func rtt(ctx context.Context, uncli *cli.CLI) error {
|
|||||||
tui.PrintWarning("metadata is missing in response from unknown server")
|
tui.PrintWarning("metadata is missing in response from unknown server")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m.Metadata.Error != "" {
|
if err := m.Metadata.Err(); err != nil {
|
||||||
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error))
|
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m.Machine == nil {
|
if m.Machine == nil {
|
||||||
|
|||||||
+2
-2
@@ -207,9 +207,9 @@ func collectContainers(ctx context.Context, cli *client.Client) ([]containerInfo
|
|||||||
|
|
||||||
machineName := msc.Metadata.MachineName
|
machineName := msc.Metadata.MachineName
|
||||||
|
|
||||||
if msc.Metadata.Error != "" {
|
if err := msc.Metadata.Err(); err != nil {
|
||||||
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
|
tui.PrintWarning(fmt.Sprintf("failed to list service containers on machine %s: %s",
|
||||||
machineName, msc.Metadata.Error))
|
machineName, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
mapset "github.com/deckarep/golang-set/v2"
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
@@ -78,6 +79,15 @@ If no services are specified, streams logs from all services defined in the Comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
|
func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Options) error {
|
||||||
|
since, until, err := logs.TimeRange(opts.Since, opts.Until, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tail, err := opts.TailLines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
serviceArgs, err := logs.ParseServiceArgs(args)
|
serviceArgs, err := logs.ParseServiceArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,12 +116,6 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tail option.
|
|
||||||
tail, err := logs.Tail(opts.Tail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := uncli.ConnectCluster(ctx)
|
c, err := uncli.ConnectCluster(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
@@ -121,8 +125,8 @@ func RunLogs(ctx context.Context, uncli *cli.CLI, args []string, opts logs.Optio
|
|||||||
baseOpts := api.ServiceLogsOptions{
|
baseOpts := api.ServiceLogsOptions{
|
||||||
Follow: opts.Follow,
|
Follow: opts.Follow,
|
||||||
Tail: tail,
|
Tail: tail,
|
||||||
Since: opts.Since,
|
Since: since,
|
||||||
Until: opts.Until,
|
Until: until,
|
||||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunLogsInvalidTimeFilters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Invalid filters must fail before loading Compose files or connecting to the cluster.
|
||||||
|
for _, flag := range []string{"since", "until"} {
|
||||||
|
opts := logs.Options{}
|
||||||
|
if flag == "since" {
|
||||||
|
opts.Since = "invalid"
|
||||||
|
} else {
|
||||||
|
opts.Until = "invalid"
|
||||||
|
}
|
||||||
|
err := RunLogs(context.Background(), nil, nil, opts)
|
||||||
|
require.ErrorContains(t, err, "invalid --"+flag+" value")
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-22
@@ -1,6 +1,6 @@
|
|||||||
module github.com/psviderski/uncloud/experiment
|
module github.com/psviderski/uncloud/experiment
|
||||||
|
|
||||||
go 1.26
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
@@ -18,10 +18,10 @@ require (
|
|||||||
github.com/psviderski/uncloud v0.0.0
|
github.com/psviderski/uncloud v0.0.0
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.28.0
|
||||||
golang.org/x/net v0.43.0
|
golang.org/x/net v0.59.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
google.golang.org/grpc v1.74.2
|
google.golang.org/grpc v1.81.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -79,18 +79,18 @@ require (
|
|||||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
|
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
|
||||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||||
github.com/josharian/native v1.1.0 // indirect
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.19.1 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||||
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
||||||
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
||||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/miekg/dns v1.1.65 // indirect
|
github.com/miekg/dns v1.1.73 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||||
github.com/mitchellh/cli v1.1.5 // indirect
|
github.com/mitchellh/cli v1.1.5 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
@@ -113,30 +113,30 @@ require (
|
|||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/siderolabs/gen v0.4.8 // indirect
|
github.com/siderolabs/gen v0.4.8 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/crypto v0.41.0 // indirect
|
golang.org/x/crypto v0.57.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
golang.org/x/time v0.16.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.50.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
|
|||||||
@@ -302,8 +302,10 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
|||||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
|
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
|
||||||
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
|
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
|
||||||
@@ -349,6 +351,7 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
|
|||||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||||
@@ -368,6 +371,7 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
|
|||||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||||
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
|
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
|
||||||
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||||
|
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
|
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
|
||||||
@@ -529,6 +533,7 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
|
|||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
|
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
|
||||||
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||||
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
|
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
|
||||||
@@ -581,16 +586,20 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
|||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
|
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
|
||||||
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
@@ -607,6 +616,7 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
|
|||||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
@@ -622,6 +632,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
|||||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
@@ -635,6 +646,7 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -654,6 +666,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -666,6 +679,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -700,6 +714,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
@@ -711,8 +726,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
@@ -727,6 +744,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -745,6 +763,7 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
|
|||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
@@ -753,6 +772,7 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
|
|||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||||
|
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -764,6 +784,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
|
|||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/mattn/go-shellwords v1.0.12
|
github.com/mattn/go-shellwords v1.0.12
|
||||||
|
github.com/mholt/acmez/v3 v3.1.6
|
||||||
github.com/miekg/dns v1.1.73
|
github.com/miekg/dns v1.1.73
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/moby/term v0.5.2
|
github.com/moby/term v0.5.2
|
||||||
@@ -178,7 +179,6 @@ require (
|
|||||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||||
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
|
||||||
github.com/miekg/pkcs11 v1.1.2 // indirect
|
github.com/miekg/pkcs11 v1.1.2 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
|
|||||||
+1
-1
@@ -458,7 +458,7 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (_ *clie
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the cluster store version so the new machine can catch up before participating.
|
// Snapshot the cluster store version so the new machine can catch up before participating.
|
||||||
var storeVersion map[string]uint64
|
var storeVersion api.StoreVersion
|
||||||
inspectResp, err = c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
|
inspectResp, err = c.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO(lhf): remove Unimplemented check when v0.17.0 is released.
|
// TODO(lhf): remove Unimplemented check when v0.17.0 is released.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duration is an extended standard time.Duration that also supports days as a unit.
|
||||||
|
type Duration = time.Duration
|
||||||
|
|
||||||
|
// ParseDuration parses a Go duration with the additional unit d, meaning exactly 24 hours.
|
||||||
|
// Days can be fractional or combined with other units, such as "1.5d" or "2d3h".
|
||||||
|
func ParseDuration(value string) (Duration, error) {
|
||||||
|
if !strings.Contains(value, "d") {
|
||||||
|
return time.ParseDuration(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := value
|
||||||
|
var normalised strings.Builder
|
||||||
|
if len(rest) > 0 && (rest[0] == '-' || rest[0] == '+') {
|
||||||
|
normalised.WriteByte(rest[0])
|
||||||
|
rest = rest[1:]
|
||||||
|
}
|
||||||
|
for len(rest) > 0 {
|
||||||
|
// Each component is a decimal number followed by a unit. Only the leading sign is allowed.
|
||||||
|
numberEnd := 0
|
||||||
|
for numberEnd < len(rest) && (isDigit(rest[numberEnd]) || rest[numberEnd] == '.') {
|
||||||
|
numberEnd++
|
||||||
|
}
|
||||||
|
unitEnd := numberEnd
|
||||||
|
for unitEnd < len(rest) && !isDigit(rest[unitEnd]) && rest[unitEnd] != '.' {
|
||||||
|
unitEnd++
|
||||||
|
}
|
||||||
|
if numberEnd == 0 || unitEnd == numberEnd {
|
||||||
|
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||||
|
}
|
||||||
|
number, unit := rest[:numberEnd], rest[numberEnd:unitEnd]
|
||||||
|
if unit == "d" {
|
||||||
|
// Use exact decimal arithmetic to avoid float rounding and retain nanosecond precision.
|
||||||
|
days, ok := new(big.Rat).SetString(number)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("time: invalid duration '%s'", value)
|
||||||
|
}
|
||||||
|
days.Mul(days, new(big.Rat).SetInt64(int64(24*time.Hour)))
|
||||||
|
nanoseconds := new(big.Int).Quo(days.Num(), days.Denom())
|
||||||
|
normalised.WriteString(nanoseconds.String())
|
||||||
|
normalised.WriteString("ns")
|
||||||
|
} else {
|
||||||
|
normalised.WriteString(rest[:unitEnd])
|
||||||
|
}
|
||||||
|
rest = rest[unitEnd:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The standard parser validates the remaining units and checks the total for overflow.
|
||||||
|
duration, err := time.ParseDuration(normalised.String())
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("time: invalid duration '%s': %w", value, err)
|
||||||
|
}
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(c byte) bool {
|
||||||
|
return c >= '0' && c <= '9'
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDuration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
input string
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"0", 0},
|
||||||
|
{"2m30s", 150 * time.Second},
|
||||||
|
{"1µs", time.Microsecond},
|
||||||
|
{"1μs", time.Microsecond},
|
||||||
|
{"2d", 48 * time.Hour},
|
||||||
|
{"010d", 240 * time.Hour},
|
||||||
|
{"08d", 192 * time.Hour},
|
||||||
|
{"2d3h", 51 * time.Hour},
|
||||||
|
{"3h2d", 51 * time.Hour},
|
||||||
|
{"1d1d", 48 * time.Hour},
|
||||||
|
{"1.5d", 36 * time.Hour},
|
||||||
|
{".5d", 12 * time.Hour},
|
||||||
|
{"1.d", 24 * time.Hour},
|
||||||
|
{"-2d3h", -51 * time.Hour},
|
||||||
|
{"+2d", 48 * time.Hour},
|
||||||
|
{"0d", 0},
|
||||||
|
{"0d1ns", time.Nanosecond},
|
||||||
|
{"0.000000000001d", 86 * time.Nanosecond},
|
||||||
|
{"-0.000000000001d", -86 * time.Nanosecond},
|
||||||
|
{"106751d23h47m16.854775807s", time.Duration(math.MaxInt64)},
|
||||||
|
{"-106751d23h47m16.854775808s", time.Duration(math.MinInt64)},
|
||||||
|
} {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
actual, err := ParseDuration(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDuration_Invalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"", "d", "2d3", "1..2d", ".d", "1d-2h", "1d+2h", "1d 2h", "1day", "2D", "1w",
|
||||||
|
"1e2d", "1d2w", "106752d", "-106752d", "106751d23h47m16.854775808s",
|
||||||
|
"-106751d23h47m16.854775809s", "999999999999999999999999999d",
|
||||||
|
} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
_, err := ParseDuration(input)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/colorprofile"
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -24,6 +25,10 @@ type Formatter struct {
|
|||||||
maxServiceWidth int
|
maxServiceWidth int
|
||||||
|
|
||||||
utc bool
|
utc bool
|
||||||
|
|
||||||
|
// Cache each stream's terminal profile to avoid detection on every log entry.
|
||||||
|
stdout *colorprofile.Writer
|
||||||
|
stderr *colorprofile.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
||||||
@@ -50,6 +55,8 @@ func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
|||||||
maxMachineWidth: maxMachineWidth,
|
maxMachineWidth: maxMachineWidth,
|
||||||
maxServiceWidth: maxServiceWidth,
|
maxServiceWidth: maxServiceWidth,
|
||||||
utc: utc,
|
utc: utc,
|
||||||
|
stdout: colorprofile.NewWriter(os.Stdout, os.Environ()),
|
||||||
|
stderr: colorprofile.NewWriter(os.Stderr, os.Environ()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +145,9 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
|||||||
|
|
||||||
// Print to appropriate stream.
|
// Print to appropriate stream.
|
||||||
if entry.Stream == api.LogStreamStderr {
|
if entry.Stream == api.LogStreamStderr {
|
||||||
fmt.Fprint(os.Stderr, output.String())
|
fmt.Fprint(f.stderr, output.String())
|
||||||
} else {
|
} else {
|
||||||
fmt.Print(output.String())
|
fmt.Fprint(f.stdout, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +155,7 @@ func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
|||||||
func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
||||||
if entry.Metadata.ServiceName == "" {
|
if entry.Metadata.ServiceName == "" {
|
||||||
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed)
|
fmt.Fprintln(f.stderr, tui.BoldRed.Render(msg))
|
||||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +177,7 @@ func (f *Formatter) printError(entry api.ServiceLogEntry) {
|
|||||||
msg += fmt.Sprintf(": %v", entry.Err)
|
msg += fmt.Sprintf(": %v", entry.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightYellow)
|
fmt.Fprintln(f.stderr, tui.BoldYellow.Render(msg))
|
||||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// palette is available colors for machine/service differentiation.
|
// palette is available colors for machine/service differentiation.
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ type Options struct {
|
|||||||
Machines []string
|
Machines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TailLines resolves the default tail limit after parsing flags. An empty opts.Tail means the user
|
||||||
|
// did not specify a limit, so --since can select all matching logs without overriding an explicit --tail.
|
||||||
|
func (opts Options) TailLines() (int, error) {
|
||||||
|
if opts.Tail == "" {
|
||||||
|
if opts.Since != "" {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
return 100, nil
|
||||||
|
}
|
||||||
|
return ParseTail(opts.Tail)
|
||||||
|
}
|
||||||
|
|
||||||
func Flags(options *Options) *pflag.FlagSet {
|
func Flags(options *Options) *pflag.FlagSet {
|
||||||
set := &pflag.FlagSet{}
|
set := &pflag.FlagSet{}
|
||||||
|
|
||||||
@@ -29,14 +41,17 @@ func Flags(options *Options) *pflag.FlagSet {
|
|||||||
set.StringVar(&options.Since, "since", "",
|
set.StringVar(&options.Since, "since", "",
|
||||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
"Examples:\n"+
|
"Examples:\n"+
|
||||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
" --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+
|
||||||
" --since 1h Relative duration (1 hour ago)\n"+
|
" Supported units: d (day = 24h), h (hour), m (minute),\n"+
|
||||||
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
" s (second), ms (millisecond),\n"+
|
||||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
" us/µs (microsecond), ns (nanosecond)\n"+
|
||||||
|
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
|
||||||
|
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
|
||||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||||
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||||
set.StringVarP(&options.Tail, "tail", "n", "100",
|
set.StringVarP(&options.Tail, "tail", "n", "",
|
||||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.\n"+
|
||||||
|
"Defaults to 100, or 'all' when --since is set.")
|
||||||
set.StringVar(&options.Until, "until", "",
|
set.StringVar(&options.Until, "until", "",
|
||||||
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||||
"See --since for examples.")
|
"See --since for examples.")
|
||||||
@@ -46,7 +61,7 @@ func Flags(options *Options) *pflag.FlagSet {
|
|||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
func Tail(tail string) (int, error) {
|
func ParseTail(tail string) (int, error) {
|
||||||
if tail == "all" {
|
if tail == "all" {
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,42 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOptionsTailLines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"default", nil, 100},
|
||||||
|
{"follow", []string{"-f"}, 100},
|
||||||
|
{"since", []string{"--since", "1h"}, -1},
|
||||||
|
{"since and follow", []string{"--since", "1h", "-f"}, -1},
|
||||||
|
{"until only", []string{"--until", "1h"}, 100},
|
||||||
|
{"time range", []string{"--since", "3h", "--until", "1h"}, -1},
|
||||||
|
{"explicit default with since", []string{"--since", "1h", "--tail", "100"}, 100},
|
||||||
|
{"explicit limit with since", []string{"--since", "1h", "-n", "20"}, 20},
|
||||||
|
{"explicit limit before since", []string{"-n", "20", "--since", "1h"}, 20},
|
||||||
|
{"explicit all", []string{"--tail", "all"}, -1},
|
||||||
|
{"explicit zero with since", []string{"--since", "1h", "-n", "0", "-f"}, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var options Options
|
||||||
|
require.NoError(t, Flags(&options).Parse(tt.args))
|
||||||
|
tail, err := options.TailLines()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, tail)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var options Options
|
||||||
|
require.NoError(t, Flags(&options).Parse([]string{"--since", "1h", "--tail", "invalid"}))
|
||||||
|
_, err := options.TailLines()
|
||||||
|
require.ErrorContains(t, err, "invalid --tail value")
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseServiceArgs(t *testing.T) {
|
func TestParseServiceArgs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimeRange resolves log filters that could be RFC 3339, timestamp, or relative duration to UTC timestamps.
|
||||||
|
// Dates without a timezone use now's location. Relative durations are computed from now.
|
||||||
|
func TimeRange(since, until string, now time.Time) (string, string, error) {
|
||||||
|
var err error
|
||||||
|
since, err = timestamp(since, now)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid --since value: %w", err)
|
||||||
|
}
|
||||||
|
until, err = timestamp(until, now)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid --until value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return since, until, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func timestamp(value string, now time.Time) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// A bare zero is the Unix epoch, matching Docker's log filters.
|
||||||
|
if duration, err := ParseDuration(value); value != "0" && err == nil {
|
||||||
|
return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Docker's supported date layouts, but use the location's offset at the requested date.
|
||||||
|
for _, layout := range []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
"2006-01-02T15:04Z07:00",
|
||||||
|
"2006-01-02T15Z07:00",
|
||||||
|
"2006-01-02Z07:00",
|
||||||
|
"2006-01-02T15:04:05.999999999",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02T15",
|
||||||
|
"2006-01-02",
|
||||||
|
} {
|
||||||
|
if t, err := time.ParseInLocation(layout, value, now.Location()); err == nil {
|
||||||
|
return t.UTC().Format(time.RFC3339Nano), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds, fraction, hasFraction := strings.Cut(value, ".")
|
||||||
|
sec, err := strconv.ParseInt(seconds, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse '%s' as a time or duration", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsec int64
|
||||||
|
if hasFraction {
|
||||||
|
if len(fraction) == 0 || len(fraction) > 9 || strings.ContainsAny(fraction, "+-") {
|
||||||
|
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s'", value)
|
||||||
|
}
|
||||||
|
nsec, err = strconv.ParseInt(fraction+strings.Repeat("0", 9-len(fraction)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s': %w", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := time.Unix(sec, nsec).UTC()
|
||||||
|
if t.Year() < 0 || t.Year() > 9999 {
|
||||||
|
return "", fmt.Errorf("Unix timestamp '%s' is outside the RFC 3339 date range", value)
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339Nano), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
timetypes "github.com/docker/docker/api/types/time"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTimeRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Australia/Sydney")
|
||||||
|
require.NoError(t, err)
|
||||||
|
// October is daylight-saving time, but July timestamps must use the winter offset.
|
||||||
|
// Daylight saving starts on 4 October. Day durations must still mean 24 elapsed hours.
|
||||||
|
now := time.Date(2026, 10, 5, 12, 0, 0, 123456789, location)
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"2026-07-01", "2026-06-30T14:00:00Z"},
|
||||||
|
{"2026-07-01T10", "2026-07-01T00:00:00Z"},
|
||||||
|
{"2026-07-01T10:30", "2026-07-01T00:30:00Z"},
|
||||||
|
{"2026-07-01T10:30:45.123456789", "2026-07-01T00:30:45.123456789Z"},
|
||||||
|
{"2026-01-01T10:00:00", "2025-12-31T23:00:00Z"},
|
||||||
|
{"2026-07-01T10:30:45Z", "2026-07-01T10:30:45Z"},
|
||||||
|
{"2026-07-01T10:30:45+02:00", "2026-07-01T08:30:45Z"},
|
||||||
|
{"2026-07-01T10:30:45-04:00", "2026-07-01T14:30:45Z"},
|
||||||
|
{"2026-07-01T10+02:00", "2026-07-01T08:00:00Z"},
|
||||||
|
{"2026-07-01T10:30+02:00", "2026-07-01T08:30:00Z"},
|
||||||
|
{"2026-07-01+02:00", "2026-06-30T22:00:00Z"},
|
||||||
|
{"1763953966", "2025-11-24T03:12:46Z"},
|
||||||
|
{"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"},
|
||||||
|
{"0", "1970-01-01T00:00:00Z"},
|
||||||
|
{"2m30s", "2026-10-05T00:57:30.123456789Z"},
|
||||||
|
{"1d", "2026-10-04T01:00:00.123456789Z"},
|
||||||
|
{"2d", "2026-10-03T01:00:00.123456789Z"},
|
||||||
|
{"2d3h", "2026-10-02T22:00:00.123456789Z"},
|
||||||
|
{"1.5d", "2026-10-03T13:00:00.123456789Z"},
|
||||||
|
{"-2d", "2026-10-07T01:00:00.123456789Z"},
|
||||||
|
{"-1h", "2026-10-05T02:00:00.123456789Z"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
since, until, err := TimeRange(tt.input, tt.input, now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, since)
|
||||||
|
assert.Equal(t, tt.want, until)
|
||||||
|
|
||||||
|
if since != "" {
|
||||||
|
// The server-side Docker SDK must preserve the cutoff even in another timezone.
|
||||||
|
actual, err := timetypes.GetTimestamp(since, now.In(time.UTC))
|
||||||
|
require.NoError(t, err)
|
||||||
|
expected, err := time.Parse(time.RFC3339Nano, tt.want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sec, nsec, err := timetypes.ParseTimestamps(actual, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, expected.Equal(time.Unix(sec, nsec)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
since, until, err := TimeRange("3h", "1h30m", now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2026-10-04T22:00:00.123456789Z", since)
|
||||||
|
assert.Equal(t, "2026-10-04T23:30:00.123456789Z", until)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeRange_Invalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, input := range []string{
|
||||||
|
"invalid", "2026-02-30", "2026-01-01T25:00:00", "1763953966.xyz",
|
||||||
|
"1763953966.1234567890", "253402300800",
|
||||||
|
} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
_, _, err := TimeRange(input, "", time.Now())
|
||||||
|
require.ErrorContains(t, err, "invalid --since value")
|
||||||
|
_, _, err = TimeRange("", input, time.Now())
|
||||||
|
require.ErrorContains(t, err, "invalid --until value")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PrintWarning(msg string) {
|
func PrintWarning(msg string) {
|
||||||
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
|
styledMsg := BoldYellow.Render(fmt.Sprintf("WARNING: %s", msg))
|
||||||
fmt.Fprintln(os.Stderr, styledMsg)
|
lipgloss.Fprintln(os.Stderr, styledMsg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const adminCommandTimeout = 5 * time.Second
|
||||||
|
|
||||||
// AdminClient is a client for the Corrosion admin API.
|
// AdminClient is a client for the Corrosion admin API.
|
||||||
type AdminClient struct {
|
type AdminClient struct {
|
||||||
sockPath string
|
sockPath string
|
||||||
@@ -32,10 +34,15 @@ type Response struct {
|
|||||||
// The channel will be closed after sending the last or error response. The caller must read from the channel until
|
// The channel will be closed after sending the last or error response. The caller must read from the channel until
|
||||||
// it is closed.
|
// it is closed.
|
||||||
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
|
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
|
||||||
conn, err := net.Dial("unix", c.sockPath)
|
// TODO: Accept contexts in admin methods so callers can cancel requests and set their deadlines.
|
||||||
|
conn, err := net.DialTimeout("unix", c.sockPath, adminCommandTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect to admin socket: %w", err)
|
return nil, fmt.Errorf("connect to admin socket: %w", err)
|
||||||
}
|
}
|
||||||
|
if err = conn.SetDeadline(time.Now().Add(adminCommandTimeout)); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("set admin connection deadline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = conn.Write(encodeFrame(cmd)); err != nil {
|
if _, err = conn.Write(encodeFrame(cmd)); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
@@ -31,11 +32,11 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
|
|||||||
|
|
||||||
if opts.Since != "" {
|
if opts.Since != "" {
|
||||||
args = append(args, "-S")
|
args = append(args, "-S")
|
||||||
args = append(args, opts.Since)
|
args = append(args, journalTimestamp(opts.Since))
|
||||||
}
|
}
|
||||||
if opts.Until != "" {
|
if opts.Until != "" {
|
||||||
args = append(args, "-U")
|
args = append(args, "-U")
|
||||||
args = append(args, opts.Until)
|
args = append(args, journalTimestamp(opts.Until))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := commandContext(ctx, journalctl, args...)
|
cmd := commandContext(ctx, journalctl, args...)
|
||||||
@@ -51,6 +52,18 @@ func logs(ctx context.Context, unit string, opts api.ServiceLogsOptions) (io.Rea
|
|||||||
return p, cmd.Wait, nil
|
return p, cmd.Wait, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// journalTimestamp formats normalised client timestamps for journalctl versions that do not
|
||||||
|
// accept RFC 3339 timezone suffixes. Support for timestamps containing T and Z was added in
|
||||||
|
// systemd 255 (6 December 2023). Debian 12 ships systemd 252, so it still needs this conversion.
|
||||||
|
// Journald timestamps have microsecond precision.
|
||||||
|
func journalTimestamp(value string) string {
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||||
|
return t.UTC().Format("2006-01-02 15:04:05.999999") + " UTC"
|
||||||
|
}
|
||||||
|
// Preserve raw filters from older clients and SDK callers.
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// follow synchronously follows the io.Reader, writing each new journal entry to channel.
|
// follow synchronously follows the io.Reader, writing each new journal entry to channel.
|
||||||
// It stops when the reader is exhausted or the context is cancelled.
|
// It stops when the reader is exhausted or the context is cancelled.
|
||||||
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
|
func follow(ctx context.Context, reader io.Reader, outCh chan api.LogEntry) {
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ func TestEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLogs(t *testing.T) {
|
func TestLogs(t *testing.T) {
|
||||||
|
originalCommandContext := commandContext
|
||||||
|
t.Cleanup(func() { commandContext = originalCommandContext })
|
||||||
|
|
||||||
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
commandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
||||||
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||||
}
|
}
|
||||||
@@ -150,3 +153,38 @@ func TestLogs(t *testing.T) {
|
|||||||
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
|
// Still six because heartbeats are not written here and Tail is ignored as the command is overridden.
|
||||||
assert.Equal(t, 6, i)
|
assert.Equal(t, 6, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogs_TimeFilters(t *testing.T) {
|
||||||
|
originalCommandContext := commandContext
|
||||||
|
t.Cleanup(func() { commandContext = originalCommandContext })
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
commandContext = func(ctx context.Context, command string, commandArgs ...string) *exec.Cmd {
|
||||||
|
assert.Equal(t, "journalctl", command)
|
||||||
|
args = commandArgs
|
||||||
|
return exec.CommandContext(ctx, "/usr/bin/tail", "testdata/logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := Logs(context.Background(), "uncloud", api.ServiceLogsOptions{
|
||||||
|
Since: "2026-07-01T10:30:45.123456789+10:00",
|
||||||
|
Until: "2026-07-01T01:30:45Z",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
|
||||||
|
"-S", "2026-07-01 00:30:45.123456 UTC",
|
||||||
|
"-U", "2026-07-01 01:30:45 UTC",
|
||||||
|
}, args)
|
||||||
|
|
||||||
|
// Older clients and SDK callers can still pass journalctl's native filters.
|
||||||
|
ch, err = Logs(context.Background(), "uncloud", api.ServiceLogsOptions{Since: "1h ago", Until: "today"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"-u", "uncloud", "--no-hostname", "-n", "0", "-o", "short-unix",
|
||||||
|
"-S", "1h ago", "-U", "today",
|
||||||
|
}, args)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,10 +16,19 @@ func NewMockCaddyfileValidator(t interface {
|
|||||||
mock.TestingT
|
mock.TestingT
|
||||||
Cleanup(func())
|
Cleanup(func())
|
||||||
}) *MockCaddyfileValidator {
|
}) *MockCaddyfileValidator {
|
||||||
|
if helper, ok := t.(interface{ Helper() }); ok {
|
||||||
|
helper.Helper()
|
||||||
|
}
|
||||||
|
|
||||||
mock := &MockCaddyfileValidator{}
|
mock := &MockCaddyfileValidator{}
|
||||||
mock.Mock.Test(t)
|
mock.Mock.Test(t)
|
||||||
|
|
||||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
t.Cleanup(func() {
|
||||||
|
if helper, ok := t.(interface{ Helper() }); ok {
|
||||||
|
helper.Helper()
|
||||||
|
}
|
||||||
|
mock.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
return mock
|
return mock
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package caddystorage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListCertificates lists issued certificates from this machine's Caddy storage replica.
|
||||||
|
// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored.
|
||||||
|
func (s *Server) ListCertificates(ctx context.Context, _ *emptypb.Empty) (*pb.ListCertificatesResponse, error) {
|
||||||
|
records, err := s.store.List(ctx, "certificates/", store.KeyspaceListOptions{KeysOnly: true})
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, status.FromContextError(ctx.Err()).Err()
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "list Caddy certificates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &pb.ListCertificatesResponse{}
|
||||||
|
for _, record := range records {
|
||||||
|
if err = ctx.Err(); err != nil {
|
||||||
|
return nil, status.FromContextError(err).Err()
|
||||||
|
}
|
||||||
|
if !isCertificateKey(record.Key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert, err := s.loadIssuedCertificate(ctx, record.Key)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to load issued certificate from Caddy storage.", "key", record.Key, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Certificates = append(resp.Certificates, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCertificateKey recognizes CertMagic's certificates/<issuer>/<name>/<name>.crt layout.
|
||||||
|
func isCertificateKey(key string) bool {
|
||||||
|
parts := strings.Split(key, "/")
|
||||||
|
return len(parts) == 4 && parts[0] == "certificates" && parts[3] == parts[2]+".crt"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadIssuedCertificate(ctx context.Context, key string) (*pb.IssuedCertificate, error) {
|
||||||
|
chain, err := s.store.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metaKey := strings.TrimSuffix(key, ".crt") + ".json"
|
||||||
|
meta, err := s.store.Get(ctx, metaKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resource certmagic.CertificateResource
|
||||||
|
if err = json.Unmarshal(meta.Value, &resource); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resource.SANs) == 0 || strings.TrimSpace(resource.SANs[0]) == "" {
|
||||||
|
return nil, fmt.Errorf("certificate metadata has no SAN")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.IssuedCertificate{
|
||||||
|
San: resource.SANs[0],
|
||||||
|
Chain: chain.Value,
|
||||||
|
IssuerData: resource.IssuerData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package caddystorage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerListCertificates(t *testing.T) {
|
||||||
|
for _, sans := range [][]string{
|
||||||
|
{"app.example.com"}, {"*.example.com"}, {"192.0.2.1"}, {"www.example.com", "example.com"},
|
||||||
|
} {
|
||||||
|
t.Run(sans[0], func(t *testing.T) {
|
||||||
|
bundle, _, _ := certificateFixture(t, &x509.Certificate{
|
||||||
|
DNSNames: []string{"example.com", "www.example.com"},
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("192.0.2.1")},
|
||||||
|
NotAfter: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
key := certmagic.StorageKeys.SiteCert("local", sans[0])
|
||||||
|
storage := newCertificateStorage(t)
|
||||||
|
raw := json.RawMessage(`{"unknown_issuer_field":true}`)
|
||||||
|
storage.add(t, key, bundle, sans, raw)
|
||||||
|
storage.keys = append(storage.keys, "certificates", "certificates/local", "certificates/local/app.crt",
|
||||||
|
"certificates/local/app/wrong.crt", "certificates/local/app/app.crt/extra.crt",
|
||||||
|
"pki/authorities/local/root.crt", "ocsp/certificate")
|
||||||
|
|
||||||
|
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Certificates, 1)
|
||||||
|
cert := resp.Certificates[0]
|
||||||
|
assert.Equal(t, sans[0], cert.San)
|
||||||
|
assert.Equal(t, bundle, cert.Chain, "PEM must be returned unchanged")
|
||||||
|
assert.Equal(t, []byte(raw), cert.IssuerData)
|
||||||
|
assert.Equal(t, []string{key, strings.TrimSuffix(key, ".crt") + ".json"}, storage.loads)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerListCertificates_PreservesIssuerData(t *testing.T) {
|
||||||
|
bundle, _, _ := certificateFixture(t, &x509.Certificate{})
|
||||||
|
for _, raw := range []string{
|
||||||
|
"", "null", `{"url":"https://ca/cert/1","ca":"https://ca/directory"}`,
|
||||||
|
`{"url":"https://ca/cert/1","ca":"https://ca/directory","renewal_info":{"_selectedTime":"invalid"}}`,
|
||||||
|
`"provider-record"`, "[1,2,3]",
|
||||||
|
} {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
storage := newCertificateStorage(t)
|
||||||
|
storage.add(t, "certificates/custom/app/app.crt", bundle, []string{"app"}, json.RawMessage(raw))
|
||||||
|
|
||||||
|
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Certificates, 1)
|
||||||
|
assert.Equal(t, raw, string(resp.Certificates[0].IssuerData))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerListCertificates_OrderingAndDuplicates(t *testing.T) {
|
||||||
|
bundle, _, _ := certificateFixture(t, &x509.Certificate{})
|
||||||
|
storage := newCertificateStorage(t)
|
||||||
|
storage.add(t, certmagic.StorageKeys.SiteCert("z-issuer", "a.example.com"), bundle, []string{"a.example.com"}, nil)
|
||||||
|
storage.add(t, certmagic.StorageKeys.SiteCert("a-issuer", "z.example.com"), bundle, []string{"z.example.com"}, nil)
|
||||||
|
storage.add(t, certmagic.StorageKeys.SiteCert("b-issuer", "z.example.com"), bundle, []string{"z.example.com"}, nil)
|
||||||
|
|
||||||
|
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Certificates, 3)
|
||||||
|
certs := resp.Certificates
|
||||||
|
assert.Equal(t, []string{"z.example.com", "z.example.com", "a.example.com"},
|
||||||
|
[]string{certs[0].San, certs[1].San, certs[2].San})
|
||||||
|
for _, cert := range certs {
|
||||||
|
assert.Equal(t, bundle, cert.Chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerListCertificates_SkipsUnreadableEntries(t *testing.T) {
|
||||||
|
bundle, _, _ := certificateFixture(t, &x509.Certificate{})
|
||||||
|
const badKey = "certificates/local/bad/bad.crt"
|
||||||
|
const metaKey = "certificates/local/bad/bad.json"
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
change func(*certificateStorageStub)
|
||||||
|
}{
|
||||||
|
{"missing chain", func(s *certificateStorageStub) { delete(s.values, badKey) }},
|
||||||
|
{"chain read failure", func(s *certificateStorageStub) { s.loadErrors[badKey] = errors.New("offline") }},
|
||||||
|
{"missing metadata", func(s *certificateStorageStub) { delete(s.values, metaKey) }},
|
||||||
|
{"metadata read failure", func(s *certificateStorageStub) { s.loadErrors[metaKey] = errors.New("offline") }},
|
||||||
|
{"invalid JSON", func(s *certificateStorageStub) { s.values[metaKey] = []byte("{") }},
|
||||||
|
{"missing SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{}`) }},
|
||||||
|
{"empty first SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{"sans":["","app"]}`) }},
|
||||||
|
{"blank first SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{"sans":[" "]}`) }},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
storage := newCertificateStorage(t)
|
||||||
|
storage.add(t, badKey, bundle, []string{"bad"}, nil)
|
||||||
|
storage.add(t, "certificates/local/good/good.crt", bundle, []string{"good"}, nil)
|
||||||
|
tt.change(storage)
|
||||||
|
|
||||||
|
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Certificates, 1)
|
||||||
|
assert.Equal(t, "good", resp.Certificates[0].San)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("all entries unreadable", func(t *testing.T) {
|
||||||
|
storage := newCertificateStorage(t)
|
||||||
|
storage.keys = []string{badKey}
|
||||||
|
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, resp.Certificates)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerListCertificates_ListErrors(t *testing.T) {
|
||||||
|
for _, fail := range []bool{false, true} {
|
||||||
|
storage := newCertificateStorage(t)
|
||||||
|
if fail {
|
||||||
|
storage.listError = errors.New("offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{})
|
||||||
|
assert.Empty(t, storage.loads)
|
||||||
|
if fail {
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
assert.Equal(t, codes.Internal, status.Code(err))
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, resp.Certificates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only reads are implemented. Unexpected writes panic through the nil embedded Keyspace.
|
||||||
|
type certificateStorageStub struct {
|
||||||
|
*store.Keyspace
|
||||||
|
t *testing.T
|
||||||
|
keys []string
|
||||||
|
values map[string][]byte
|
||||||
|
loads []string
|
||||||
|
listCalls int
|
||||||
|
listError error
|
||||||
|
loadErrors map[string]error
|
||||||
|
onLoad func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCertificateStorage(t *testing.T) *certificateStorageStub {
|
||||||
|
return &certificateStorageStub{t: t, values: make(map[string][]byte), loadErrors: make(map[string]error)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *certificateStorageStub) add(t *testing.T, key string, bundle []byte, sans []string, issuerData json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
metaKey := strings.TrimSuffix(key, ".crt") + ".json"
|
||||||
|
keyKey := strings.TrimSuffix(key, ".crt") + ".key"
|
||||||
|
meta, err := json.Marshal(certmagic.CertificateResource{SANs: sans, IssuerData: issuerData})
|
||||||
|
require.NoError(t, err)
|
||||||
|
s.keys = append(s.keys, key, metaKey, keyKey)
|
||||||
|
s.values[key], s.values[metaKey], s.values[keyKey] = bundle, meta, []byte("must not read private keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *certificateStorageStub) List(ctx context.Context, prefix string, opts store.KeyspaceListOptions) ([]store.Record, error) {
|
||||||
|
s.listCalls++
|
||||||
|
assert.Equal(s.t, "certificates/", prefix)
|
||||||
|
assert.True(s.t, opts.KeysOnly, "listing must not fetch private key values")
|
||||||
|
var records []store.Record
|
||||||
|
for _, key := range slices.Sorted(slices.Values(s.keys)) {
|
||||||
|
records = append(records, store.Record{Key: key})
|
||||||
|
}
|
||||||
|
return records, s.listError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *certificateStorageStub) Get(ctx context.Context, key string) (store.Record, error) {
|
||||||
|
require.False(s.t, strings.HasSuffix(key, ".key"), "private keys must never be loaded")
|
||||||
|
s.loads = append(s.loads, key)
|
||||||
|
if s.onLoad != nil {
|
||||||
|
s.onLoad(key)
|
||||||
|
}
|
||||||
|
if err := s.loadErrors[key]; err != nil {
|
||||||
|
return store.Record{}, err
|
||||||
|
}
|
||||||
|
value, ok := s.values[key]
|
||||||
|
if !ok {
|
||||||
|
return store.Record{}, store.ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return store.Record{Key: key, Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func certificateFixture(t *testing.T, template *x509.Certificate) ([]byte, *x509.Certificate, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
root := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA"}, IsCA: true,
|
||||||
|
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
|
||||||
|
NotBefore: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
NotAfter: time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
rootDER, err := x509.CreateCertificate(rand.Reader, root, root, publicKey, key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
root, err = x509.ParseCertificate(rootDER)
|
||||||
|
require.NoError(t, err)
|
||||||
|
template.SerialNumber = big.NewInt(2)
|
||||||
|
template.NotBefore = root.NotBefore
|
||||||
|
if template.NotAfter.IsZero() {
|
||||||
|
template.NotAfter = root.NotAfter
|
||||||
|
}
|
||||||
|
leafDER, err := x509.CreateCertificate(rand.Reader, template, root, publicKey, key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
leaf, err := x509.ParseCertificate(leafDER)
|
||||||
|
require.NoError(t, err)
|
||||||
|
bundle := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}),
|
||||||
|
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})...)
|
||||||
|
return bundle, leaf, root
|
||||||
|
}
|
||||||
@@ -25,10 +25,17 @@ const Namespace = "caddy_storage"
|
|||||||
// Server implements the machine-local CaddyStorage gRPC service.
|
// Server implements the machine-local CaddyStorage gRPC service.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pb.UnimplementedCaddyStorageServer
|
pb.UnimplementedCaddyStorageServer
|
||||||
store *store.Keyspace
|
store keyspace
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(store *store.Keyspace) *Server {
|
type keyspace interface {
|
||||||
|
Get(context.Context, string) (store.Record, error)
|
||||||
|
Put(context.Context, string, []byte) error
|
||||||
|
Delete(context.Context, string, store.KeyspaceDeleteOptions) error
|
||||||
|
List(context.Context, string, store.KeyspaceListOptions) ([]store.Record, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(store keyspace) *Server {
|
||||||
return &Server{store: store}
|
return &Server{store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
@@ -87,6 +88,11 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p
|
|||||||
func (c *Cluster) AddMachineWithoutReadyCheck(
|
func (c *Cluster) AddMachineWithoutReadyCheck(
|
||||||
ctx context.Context, req *pb.AddMachineRequest,
|
ctx context.Context, req *pb.AddMachineRequest,
|
||||||
) (*pb.AddMachineResponse, error) {
|
) (*pb.AddMachineResponse, error) {
|
||||||
|
if req.Name != "" {
|
||||||
|
if err := api.ValidateMachineName(req.Name); err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.Network == nil {
|
if req.Network == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "network not set")
|
return nil, status.Error(codes.InvalidArgument, "network not set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddMachine_InvalidName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ready := make(chan struct{})
|
||||||
|
close(ready)
|
||||||
|
// Invalid names must be rejected before any store access.
|
||||||
|
c := NewCluster(nil, nil, nil, ready)
|
||||||
|
for _, name := range []string{"VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||||
|
t.Run("add", func(t *testing.T) {
|
||||||
|
resp, err := c.AddMachine(context.Background(), &pb.AddMachineRequest{Name: name})
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||||
|
require.ErrorContains(t, err, "invalid machine name")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewMachineID generates a new unique machine ID.
|
// NewMachineID generates a new unique machine ID.
|
||||||
@@ -27,7 +28,7 @@ func NewRandomMachineName() (string, error) {
|
|||||||
// a numeric suffix ("-1", "-2", etc.) if needed.
|
// a numeric suffix ("-1", "-2", etc.) if needed.
|
||||||
func DefaultMachineName(hostname string, existing []string) (string, error) {
|
func DefaultMachineName(hostname string, existing []string) (string, error) {
|
||||||
name := machineNameFromHostname(hostname)
|
name := machineNameFromHostname(hostname)
|
||||||
if name == "" {
|
if api.ValidateMachineName(name) != nil {
|
||||||
var err error
|
var err error
|
||||||
if name, err = NewRandomMachineName(); err != nil {
|
if name, err = NewRandomMachineName(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -38,7 +39,9 @@ func DefaultMachineName(hostname string, existing []string) (string, error) {
|
|||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
for i := 1; ; i++ {
|
for i := 1; ; i++ {
|
||||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
suffix := fmt.Sprintf("-%d", i)
|
||||||
|
base := strings.TrimRight(name[:min(len(name), 63-len(suffix))], "-")
|
||||||
|
candidate := base + suffix
|
||||||
if !slices.Contains(existing, candidate) {
|
if !slices.Contains(existing, candidate) {
|
||||||
return candidate, nil
|
return candidate, nil
|
||||||
}
|
}
|
||||||
@@ -55,12 +58,13 @@ func machineNameFromHostname(hostname string) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range label {
|
for _, r := range label {
|
||||||
switch {
|
switch {
|
||||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
default:
|
default:
|
||||||
b.WriteRune('-')
|
b.WriteRune('-')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trim leading and trailing hyphens that may result from the sanitisation above.
|
// Trim leading and trailing hyphens that may result from the sanitisation above.
|
||||||
return strings.Trim(b.String(), "-")
|
name := strings.Trim(b.String(), "-")
|
||||||
|
return strings.TrimRight(name[:min(len(name), 63)], "-")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package cluster
|
package cluster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -18,9 +20,11 @@ func TestMachineNameFromHostname(t *testing.T) {
|
|||||||
{"simple", "web", "web"},
|
{"simple", "web", "web"},
|
||||||
{"fqdn uses first label", "web-1.example.com", "web-1"},
|
{"fqdn uses first label", "web-1.example.com", "web-1"},
|
||||||
{"uppercase lowercased", "Web-Server", "web-server"},
|
{"uppercase lowercased", "Web-Server", "web-server"},
|
||||||
{"invalid chars replaced", "host_name@1", "host_name-1"},
|
{"invalid chars replaced", "host_name@1", "host-name-1"},
|
||||||
{"trim surrounding hyphens", "-_host_-", "_host_"},
|
{"trim surrounding hyphens", "-_host_-", "host"},
|
||||||
{"whitespace trimmed", " myhost ", "myhost"},
|
{"whitespace trimmed", " myhost ", "myhost"},
|
||||||
|
{"long hostname truncated", strings.Repeat("a", 64), strings.Repeat("a", 63)},
|
||||||
|
{"truncation trims trailing hyphen", strings.Repeat("a", 62) + "-b", strings.Repeat("a", 62)},
|
||||||
{"empty", "", ""},
|
{"empty", "", ""},
|
||||||
{"only invalid chars", "@#", ""},
|
{"only invalid chars", "@#", ""},
|
||||||
{"dot only", ".example.com", ""},
|
{"dot only", ".example.com", ""},
|
||||||
@@ -37,10 +41,12 @@ func TestDefaultMachineName(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("falls back to random", func(t *testing.T) {
|
t.Run("falls back to random", func(t *testing.T) {
|
||||||
// An empty or fully invalid hostname falls back to a random "machine-xxxx" name.
|
for _, hostname := range []string{"***", "rr", "nearest", strings.Repeat("a", 32)} {
|
||||||
got, err := DefaultMachineName("***", nil)
|
got, err := DefaultMachineName(hostname, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Regexp(t, `^machine-[a-zA-Z0-9]{4}$`, got)
|
assert.Regexp(t, `^machine-[a-z0-9]{4}$`, got)
|
||||||
|
require.NoError(t, api.ValidateMachineName(got))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -49,10 +55,45 @@ func TestDefaultMachineName(t *testing.T) {
|
|||||||
existing []string
|
existing []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"from hostname", "web-1.example.com", nil, "web-1"},
|
{
|
||||||
{"dedup against existing", "web", []string{"web"}, "web-1"},
|
name: "from hostname",
|
||||||
{"dedup multiple", "web", []string{"web", "web-1"}, "web-2"},
|
hostname: "web-1.example.com",
|
||||||
{"sanitized", "My_Host", nil, "my_host"},
|
want: "web-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup against existing",
|
||||||
|
hostname: "web",
|
||||||
|
existing: []string{"web"},
|
||||||
|
want: "web-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup multiple",
|
||||||
|
hostname: "web",
|
||||||
|
existing: []string{"web", "web-1"},
|
||||||
|
want: "web-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sanitized",
|
||||||
|
hostname: "My_Host",
|
||||||
|
want: "my-host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long hostname",
|
||||||
|
hostname: strings.Repeat("a", 64),
|
||||||
|
want: strings.Repeat("a", 63),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup maximum length",
|
||||||
|
hostname: strings.Repeat("a", 63),
|
||||||
|
existing: []string{strings.Repeat("a", 63)},
|
||||||
|
want: strings.Repeat("a", 61) + "-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedup trims trailing hyphen",
|
||||||
|
hostname: strings.Repeat("a", 60) + "-bb",
|
||||||
|
existing: []string{strings.Repeat("a", 60) + "-bb"},
|
||||||
|
want: strings.Repeat("a", 60) + "-1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -60,6 +101,7 @@ func TestDefaultMachineName(t *testing.T) {
|
|||||||
got, err := DefaultMachineName(tt.hostname, tt.existing)
|
got, err := DefaultMachineName(tt.hostname, tt.existing)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tt.want, got)
|
assert.Equal(t, tt.want, got)
|
||||||
|
require.NoError(t, api.ValidateMachineName(got))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ type ClusterResolver struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
// serviceIPs maps service names to container IPs.
|
// serviceIPs maps service names to container IPs.
|
||||||
serviceIPs map[string][]netip.Addr
|
serviceIPs map[string][]netip.Addr
|
||||||
// mu protects the serviceIPs map.
|
// machineIPs maps machine IDs and names to their IP.
|
||||||
|
machineIPs map[string]netip.Addr
|
||||||
|
// mu protects the serviceIPs and machineIPs map.
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
// lastUpdate tracks when records were last updated.
|
// lastUpdate tracks when records were last updated.
|
||||||
lastUpdate time.Time
|
lastUpdate time.Time
|
||||||
@@ -46,6 +50,13 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
|
|||||||
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||||
r.updateServiceIPs(containers)
|
r.updateServiceIPs(containers)
|
||||||
|
|
||||||
|
machines, mchanges, err := r.store.SubscribeMachines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("subscribe to machine changes: %w", err)
|
||||||
|
}
|
||||||
|
r.log.Info("Subscribed to machine changes in the clsuter to keep machine DNS records updated.")
|
||||||
|
r.updateMachineIPs(machines)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case _, ok := <-changes:
|
case _, ok := <-changes:
|
||||||
@@ -59,9 +70,22 @@ func (r *ClusterResolver) Run(ctx context.Context) error {
|
|||||||
r.log.Error("Failed to list containers.", "err", err)
|
r.log.Error("Failed to list containers.", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
// TODO: implement machine membership check using Corrossion Admin client to filter available containers.
|
||||||
r.updateServiceIPs(containers)
|
r.updateServiceIPs(containers)
|
||||||
|
|
||||||
|
case _, ok := <-mchanges:
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("machine subscription failed")
|
||||||
|
}
|
||||||
|
r.log.Debug("Cluster machines changed, updating DNS records.")
|
||||||
|
|
||||||
|
machines, err := r.store.ListMachines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error("Failed to list machines.", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.updateMachineIPs(machines)
|
||||||
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -121,19 +145,49 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
|||||||
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
r.log.Info("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns IP addresses of the service containers.
|
func (r *ClusterResolver) updateMachineIPs(machines []*pb.MachineInfo) {
|
||||||
func (r *ClusterResolver) Resolve(serviceName string) []netip.Addr {
|
newMachineIPs := make(map[string]netip.Addr, len(machines))
|
||||||
|
|
||||||
|
for _, machine := range machines {
|
||||||
|
subnet, err := machine.Network.Subnet.ToPrefix()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr := network.MachineIP(subnet)
|
||||||
|
newMachineIPs[machine.Name+".m"] = addr
|
||||||
|
newMachineIPs[machine.Id+".m"] = addr
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.machineIPs = newMachineIPs
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
r.log.Info("DNS records updated.", "machines", len(machines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns IP addresses of the service containers or machines.
|
||||||
|
func (r *ClusterResolver) Resolve(name string) []netip.Addr {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
ips, ok := r.serviceIPs[serviceName]
|
// Return a copy of the IPs slice to prevent modification of the original.
|
||||||
if !ok || len(ips) == 0 {
|
ips, ok := r.serviceIPs[name]
|
||||||
return nil
|
if ok && len(ips) > 0 {
|
||||||
|
return slices.Clone(ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a copy of the IPs slice to prevent modification of the original.
|
ip, ok := r.machineIPs[name]
|
||||||
ipsCopy := make([]netip.Addr, len(ips))
|
if ok {
|
||||||
copy(ipsCopy, ips)
|
return slices.Clone([]netip.Addr{ip})
|
||||||
|
}
|
||||||
|
|
||||||
return ipsCopy
|
if name == "m" { // all machines subdomain
|
||||||
|
// collect all unique IP address
|
||||||
|
uniq := map[string]netip.Addr{}
|
||||||
|
for _, addr := range r.machineIPs {
|
||||||
|
uniq[addr.String()] = addr
|
||||||
|
}
|
||||||
|
return slices.Clone(slices.Collect(maps.Values(uniq)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package dns
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/store"
|
"github.com/psviderski/uncloud/internal/machine/store"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -75,3 +77,32 @@ func newRecord(serviceID, serviceName, ip, machineID string) store.ContainerReco
|
|||||||
MachineID: machineID,
|
MachineID: machineID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClusterResolver_UpdateMachineIPs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
machines := []*pb.MachineInfo{
|
||||||
|
newMachineRecord("x0y0z0", "mach-1", "10.210.0.0/24"),
|
||||||
|
newMachineRecord("x1y1z1", "mach-2", "10.210.1.0/24"),
|
||||||
|
newMachineRecord("x2y2z2", "mach-3", "10.210.2.0/24"),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewClusterResolver(nil)
|
||||||
|
r.updateMachineIPs(machines)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, r.Resolve("mach-1.m"))
|
||||||
|
assert.NotEmpty(t, r.Resolve("mach-3.m"))
|
||||||
|
assert.NotEmpty(t, r.Resolve("x0y0z0.m"))
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(r.Resolve("m")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMachineRecord(machineID, machineName, prefix string) *pb.MachineInfo {
|
||||||
|
return &pb.MachineInfo{
|
||||||
|
Id: machineID,
|
||||||
|
Name: machineName,
|
||||||
|
Network: &pb.NetworkConfig{
|
||||||
|
Subnet: pb.NewIPPrefix(netip.MustParsePrefix(prefix)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,13 +293,13 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
|
|||||||
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
||||||
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
||||||
func (s *Server) handleAQuery(name string) []dns.RR {
|
func (s *Server) handleAQuery(name string) []dns.RR {
|
||||||
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
|
unname, mode := extractModeFromDomain(trimInternalDomain(name))
|
||||||
ips := s.resolver.Resolve(serviceName)
|
ips := s.resolver.Resolve(unname)
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
s.log.Debug("Failed to resolve internal name.", "name", name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
s.log.Debug("Resolved service internal name.", "name", name, "ips", ips)
|
||||||
|
|
||||||
if len(ips) > 1 {
|
if len(ips) > 1 {
|
||||||
// Shuffle the IPs to approximate round-robin.
|
// Shuffle the IPs to approximate round-robin.
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
|
|||||||
machineContainers := make([]MachineContainers, len(resp.Messages))
|
machineContainers := make([]MachineContainers, len(resp.Messages))
|
||||||
for i, msg := range resp.Messages {
|
for i, msg := range resp.Messages {
|
||||||
machineContainers[i].Metadata = msg.Metadata
|
machineContainers[i].Metadata = msg.Metadata
|
||||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
if msg.Metadata.Err() != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
|||||||
|
|
||||||
notFoundCount := 0
|
notFoundCount := 0
|
||||||
for _, msg := range resp.Messages {
|
for _, msg := range resp.Messages {
|
||||||
if msg.Metadata != nil && msg.Metadata.Status != nil && codes.Code(msg.Metadata.Status.Code) == codes.NotFound {
|
if status.Code(msg.Metadata.Err()) == codes.NotFound {
|
||||||
notFoundCount++
|
notFoundCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
|||||||
images := make([]api.MachineImage, len(resp.Messages))
|
images := make([]api.MachineImage, len(resp.Messages))
|
||||||
for i, msg := range resp.Messages {
|
for i, msg := range resp.Messages {
|
||||||
images[i].Metadata = msg.Metadata
|
images[i].Metadata = msg.Metadata
|
||||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
if msg.Metadata.Err() != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ func parseRemoteImageMessage(msg *pb.RemoteImage) (api.MachineRemoteImage, error
|
|||||||
mri := api.MachineRemoteImage{
|
mri := api.MachineRemoteImage{
|
||||||
Metadata: msg.Metadata,
|
Metadata: msg.Metadata,
|
||||||
}
|
}
|
||||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
if msg.Metadata.Err() != nil {
|
||||||
return mri, nil
|
return mri, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +393,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
|
|||||||
machineVolumes := make([]MachineVolumes, len(resp.Messages))
|
machineVolumes := make([]MachineVolumes, len(resp.Messages))
|
||||||
for i, msg := range resp.Messages {
|
for i, msg := range resp.Messages {
|
||||||
machineVolumes[i].Metadata = msg.Metadata
|
machineVolumes[i].Metadata = msg.Metadata
|
||||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
if msg.Metadata.Err() != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ func (c *Client) ListServiceContainers(
|
|||||||
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
|
machineContainers := make([]MachineServiceContainers, len(resp.Messages))
|
||||||
for i, msg := range resp.Messages {
|
for i, msg := range resp.Messages {
|
||||||
machineContainers[i].Metadata = msg.Metadata
|
machineContainers[i].Metadata = msg.Metadata
|
||||||
if msg.Metadata != nil && msg.Metadata.Error != "" {
|
if msg.Metadata.Err() != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const (
|
|||||||
type Controller struct {
|
type Controller struct {
|
||||||
// machineID is the ID of the machine where the managed Docker daemon is running.
|
// machineID is the ID of the machine where the managed Docker daemon is running.
|
||||||
machineID string
|
machineID string
|
||||||
client *client.Client
|
client client.APIClient
|
||||||
service *Service
|
service *Service
|
||||||
store *store.Store
|
store *store.Store
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
|
||||||
|
func (s *Service) RemoveImage(ctx context.Context, id string, opts image.RemoveOptions) ([]image.DeleteResponse, error) {
|
||||||
|
if err := s.checkImageRemovalConflicts(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Docker enforces its own conflicts, including protection of a used image's final reference.
|
||||||
|
return s.Client.ImageRemove(ctx, id, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkImageRemovalConflicts rejects an image ID used by a running or stopped container, or a name used by a
|
||||||
|
// container while it still resolves to that container's image. A digest request also checks the repository tags
|
||||||
|
// Docker would remove with it. Unused aliases are allowed.
|
||||||
|
func (s *Service) checkImageRemovalConflicts(ctx context.Context, id string) error {
|
||||||
|
img, err := s.Client.ImageInspect(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Match Docker's resolved ID before parsing names, since a short ID also parses as a repository name.
|
||||||
|
isID := strings.HasPrefix(strings.TrimPrefix(img.ID, "sha256:"), strings.TrimPrefix(id, "sha256:"))
|
||||||
|
var named reference.Named
|
||||||
|
if !isID {
|
||||||
|
named, err = reference.ParseDockerRef(id)
|
||||||
|
if err != nil {
|
||||||
|
return errdefs.ErrInvalidArgument.WithMessage(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, isDigest := named.(reference.Canonical)
|
||||||
|
|
||||||
|
containers, err := s.Client.ContainerList(ctx, container.ListOptions{
|
||||||
|
All: true, Filters: filters.NewArgs(filters.Arg("ancestor", img.ID)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list containers using image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ctr := range containers {
|
||||||
|
// The ancestor filter can include descendants. A retagged name must also be checked against its current ID.
|
||||||
|
if ctr.ImageID != img.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
usedRef, err := reference.ParseDockerRef(ctr.Image)
|
||||||
|
// Familiar names make "alpine:latest" and "docker.io/library/alpine:latest" identify the same tag.
|
||||||
|
removesUsedName := err == nil && named != nil && reference.FamiliarString(usedRef) == reference.FamiliarString(named)
|
||||||
|
if isDigest && err == nil && usedRef.Name() == named.Name() {
|
||||||
|
// Removing a digest also removes this repository's tags pointing at the same image.
|
||||||
|
removesUsedName = true
|
||||||
|
}
|
||||||
|
if isID || removesUsedName {
|
||||||
|
ctrID, state := stringid.TruncateID(ctr.ID), "stopped"
|
||||||
|
if serviceName := ctr.Labels[api.LabelServiceName]; serviceName != "" {
|
||||||
|
ctrID = serviceName + "/" + ctrID
|
||||||
|
}
|
||||||
|
if ctr.State == container.StateRunning || ctr.State == container.StatePaused {
|
||||||
|
state = container.StateRunning
|
||||||
|
}
|
||||||
|
return errdefs.ErrConflict.WithMessage(
|
||||||
|
fmt.Sprintf("image '%s' is used by container '%s' (%s)", id, ctrID, state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckImageRemovalConflicts(t *testing.T) {
|
||||||
|
imageID := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
otherID := "sha256:" + strings.Repeat("b", 64)
|
||||||
|
digestRef := "myapp@" + imageID
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
requested string
|
||||||
|
configured string
|
||||||
|
containerImageID string
|
||||||
|
serviceName string
|
||||||
|
blocked bool
|
||||||
|
}{
|
||||||
|
{name: "used tag", requested: "myapp:current", configured: "myapp:current", serviceName: "web", blocked: true},
|
||||||
|
{name: "qualified request", requested: "docker.io/library/myapp:current", configured: "myapp:current", blocked: true},
|
||||||
|
{name: "qualified container reference", requested: "myapp:current", configured: "docker.io/library/myapp:current", blocked: true},
|
||||||
|
{name: "implicit latest", requested: "myapp", configured: "myapp:latest", blocked: true},
|
||||||
|
{name: "unused alias", requested: "myapp:old", configured: "myapp:current"},
|
||||||
|
{name: "unused alias of ID-used image", requested: "myapp:old", configured: imageID},
|
||||||
|
{name: "tag may clean up a used digest", requested: "myapp:old", configured: digestRef},
|
||||||
|
{name: "used digest", requested: digestRef, configured: digestRef, blocked: true},
|
||||||
|
{name: "digest also removes used tag", requested: digestRef, configured: "myapp:current", blocked: true},
|
||||||
|
{name: "unused repository digest", requested: digestRef, configured: "other:keep"},
|
||||||
|
{name: "image ID", requested: imageID, configured: "myapp:current", blocked: true},
|
||||||
|
{name: "short image ID", requested: "aaaaaaaa", configured: "myapp:current", blocked: true},
|
||||||
|
{name: "short image ID with algorithm", requested: "sha256:aaaaaaaa", configured: "myapp:current", blocked: true},
|
||||||
|
{name: "retagged name", requested: "myapp:current", configured: "myapp:current", containerImageID: otherID},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for _, state := range []string{container.StateExited, container.StateRunning} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s", tt.name, state), func(t *testing.T) {
|
||||||
|
containerImageID := tt.containerImageID
|
||||||
|
if containerImageID == "" {
|
||||||
|
containerImageID = imageID
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := NewMockAPIClient(t)
|
||||||
|
cli.EXPECT().ImageInspect(mock.Anything, tt.requested).
|
||||||
|
Return(image.InspectResponse{ID: imageID}, nil).Once()
|
||||||
|
cli.EXPECT().ContainerList(mock.Anything, mock.MatchedBy(func(opts container.ListOptions) bool {
|
||||||
|
return opts.All // Stopped containers must be checked too.
|
||||||
|
})).Return([]container.Summary{{
|
||||||
|
ID: strings.Repeat("c", 64),
|
||||||
|
Labels: map[string]string{api.LabelServiceName: tt.serviceName},
|
||||||
|
Names: []string{"/web"},
|
||||||
|
ImageID: containerImageID,
|
||||||
|
Image: tt.configured,
|
||||||
|
State: state,
|
||||||
|
}}, nil).Once()
|
||||||
|
|
||||||
|
service := NewService(cli, nil)
|
||||||
|
err := service.checkImageRemovalConflicts(context.Background(), tt.requested)
|
||||||
|
|
||||||
|
if tt.blocked {
|
||||||
|
require.True(t, errdefs.IsConflict(err), "%v", err)
|
||||||
|
assert.ErrorContains(t, err, tt.requested)
|
||||||
|
if tt.serviceName != "" {
|
||||||
|
assert.ErrorContains(t, err, "container 'web/cccccccccccc'")
|
||||||
|
} else {
|
||||||
|
assert.ErrorContains(t, err, "container 'cccccccccccc'")
|
||||||
|
}
|
||||||
|
wantState := "stopped"
|
||||||
|
if state == container.StateRunning {
|
||||||
|
wantState = state
|
||||||
|
}
|
||||||
|
assert.ErrorContains(t, err, wantState)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckImageRemovalConflicts_PropagatesLookupErrors(t *testing.T) {
|
||||||
|
imageID := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
t.Run("image lookup fails", func(t *testing.T) {
|
||||||
|
cli := NewMockAPIClient(t)
|
||||||
|
lookupErr := errdefs.ErrNotFound.WithMessage("no such image")
|
||||||
|
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{}, lookupErr).Once()
|
||||||
|
|
||||||
|
service := NewService(cli, nil)
|
||||||
|
require.ErrorIs(t, service.checkImageRemovalConflicts(context.Background(), "myapp:old"), lookupErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("container lookup fails", func(t *testing.T) {
|
||||||
|
cli := NewMockAPIClient(t)
|
||||||
|
lookupErr := errors.New("Docker unavailable")
|
||||||
|
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{ID: imageID}, nil).Once()
|
||||||
|
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return(nil, lookupErr).Once()
|
||||||
|
|
||||||
|
service := NewService(cli, nil)
|
||||||
|
err := service.checkImageRemovalConflicts(context.Background(), "myapp:old")
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, lookupErr)
|
||||||
|
assert.ErrorContains(t, err, "list containers using image")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceRemoveImage_RejectsUsedNameBeforeDeletion(t *testing.T) {
|
||||||
|
imageID := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
cli := NewMockAPIClient(t)
|
||||||
|
cli.EXPECT().ImageInspect(mock.Anything, "myapp:current").Return(image.InspectResponse{ID: imageID}, nil).Once()
|
||||||
|
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return([]container.Summary{{
|
||||||
|
ID: "container-id", ImageID: imageID, Image: "myapp:current", State: container.StateRunning,
|
||||||
|
}}, nil).Once()
|
||||||
|
|
||||||
|
service := NewService(cli, nil)
|
||||||
|
// ImageRemove has no expectation: any deletion call would fail this test.
|
||||||
|
results, err := service.RemoveImage(context.Background(), "myapp:current", image.RemoveOptions{Force: true})
|
||||||
|
|
||||||
|
require.True(t, errdefs.IsConflict(err), "%v", err)
|
||||||
|
assert.Nil(t, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceRemoveImage_DelegatesToDocker(t *testing.T) {
|
||||||
|
imageID := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts image.RemoveOptions
|
||||||
|
results []image.DeleteResponse
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "options and results pass through",
|
||||||
|
opts: image.RemoveOptions{Force: true, PruneChildren: true, Platforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}}},
|
||||||
|
results: []image.DeleteResponse{{Deleted: imageID}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Docker rejects final reference removal",
|
||||||
|
opts: image.RemoveOptions{PruneChildren: true},
|
||||||
|
err: errdefs.ErrConflict.WithMessage("container is using its referenced image"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cli := NewMockAPIClient(t)
|
||||||
|
cli.EXPECT().ImageInspect(mock.Anything, "myapp:old").Return(image.InspectResponse{ID: imageID}, nil).Once()
|
||||||
|
cli.EXPECT().ContainerList(mock.Anything, mock.Anything).Return([]container.Summary{{
|
||||||
|
ID: "container-id", ImageID: imageID, Image: "myapp@" + imageID, State: container.StateRunning,
|
||||||
|
}}, nil).Once()
|
||||||
|
cli.EXPECT().ImageRemove(mock.Anything, "myapp:old", tt.opts).Return(tt.results, tt.err).Once()
|
||||||
|
|
||||||
|
service := NewService(cli, nil)
|
||||||
|
results, err := service.RemoveImage(context.Background(), "myapp:old", tt.opts)
|
||||||
|
if tt.err != nil {
|
||||||
|
require.ErrorIs(t, err, tt.err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.results, results)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
|||||||
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pb.UnimplementedDockerServer
|
pb.UnimplementedDockerServer
|
||||||
client *client.Client
|
client client.APIClient
|
||||||
service *Service
|
service *Service
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||||
@@ -349,6 +349,41 @@ func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveImage removes an image while protecting names used by containers, even when force is requested.
|
||||||
|
func (s *Server) RemoveImage(ctx context.Context, req *pb.RemoveImageRequest) (*pb.RemoveImageResponse, error) {
|
||||||
|
opts := image.RemoveOptions{Force: req.Force, PruneChildren: req.PruneChildren}
|
||||||
|
for _, platform := range req.Platforms {
|
||||||
|
opts.Platforms = append(opts.Platforms, ocispec.Platform{
|
||||||
|
Architecture: platform.Architecture,
|
||||||
|
OS: platform.Os,
|
||||||
|
OSVersion: platform.OsVersion,
|
||||||
|
OSFeatures: platform.OsFeatures,
|
||||||
|
Variant: platform.Variant,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removals, err := s.service.RemoveImage(ctx, req.Id, opts)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errdefs.IsNotFound(err):
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
case errdefs.IsConflict(err):
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||||
|
case errdefs.IsInvalidArgument(err):
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
default:
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &pb.MachineImageRemoval{}
|
||||||
|
for _, removal := range removals {
|
||||||
|
result.Results = append(result.Results, &pb.ImageRemoval{Deleted: removal.Deleted, Untagged: removal.Untagged})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.RemoveImageResponse{Messages: []*pb.MachineImageRemoval{result}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
||||||
// credentials if necessary.
|
// credentials if necessary.
|
||||||
func (s *Server) InspectRemoteImage(
|
func (s *Server) InspectRemoteImage(
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ import (
|
|||||||
// from the machine database.
|
// from the machine database.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
// Client is a Docker client for managing Docker resources.
|
// Client is a Docker client for managing Docker resources.
|
||||||
Client *client.Client
|
Client client.APIClient
|
||||||
// db is a connection to the machine database.
|
// db is a connection to the machine database.
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new Docker service instance.
|
// NewService creates a new Docker service instance.
|
||||||
func NewService(client *client.Client, db *sqlx.DB) *Service {
|
func NewService(client client.APIClient, db *sqlx.DB) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
Client: client,
|
Client: client,
|
||||||
db: db,
|
db: db,
|
||||||
|
|||||||
@@ -262,11 +262,11 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create corrosion API client: %w", err)
|
return nil, fmt.Errorf("create corrosion API client: %w", err)
|
||||||
}
|
}
|
||||||
corroStore := store.New(corro)
|
|
||||||
corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath)
|
corroAdmin, err := corrosion.NewAdminClient(config.CorrosionAdminSockPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create corrosion admin client: %w", err)
|
return nil, fmt.Errorf("create corrosion admin client: %w", err)
|
||||||
}
|
}
|
||||||
|
corroStore := store.New(corro, corroAdmin)
|
||||||
|
|
||||||
initialised := make(chan struct{})
|
initialised := make(chan struct{})
|
||||||
clusterReady := make(chan struct{})
|
clusterReady := make(chan struct{})
|
||||||
@@ -836,6 +836,11 @@ func (m *Machine) InitCluster(ctx context.Context, req *pb.InitClusterRequest) (
|
|||||||
if m.Initialised() {
|
if m.Initialised() {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
|
return nil, status.Error(codes.FailedPrecondition, "machine is already configured as a cluster member")
|
||||||
}
|
}
|
||||||
|
if req.MachineName != "" {
|
||||||
|
if err := api.ValidateMachineName(req.MachineName); err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clusterNetwork, err := req.Network.ToPrefix()
|
clusterNetwork, err := req.Network.ToPrefix()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1220,8 +1225,8 @@ func (m *Machine) applyMachineUpdate(ctx context.Context, req *pb.UpdateMachineR
|
|||||||
defer m.state.mu.Unlock()
|
defer m.state.mu.Unlock()
|
||||||
|
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
if *req.Name == "" {
|
if err := api.ValidateMachineName(*req.Name); err != nil {
|
||||||
return status.Error(codes.InvalidArgument, "machine name cannot be empty")
|
return status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
// Check for duplicate names across the cluster, excluding this machine.
|
// Check for duplicate names across the cluster, excluding this machine.
|
||||||
if *req.Name != m.state.Name {
|
if *req.Name != m.state.Name {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitCluster_InvalidMachineName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, name := range []string{"VPS1", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||||
|
// Invalid names must be rejected before initializing cluster state.
|
||||||
|
m := &Machine{state: &State{}}
|
||||||
|
|
||||||
|
resp, err := m.InitCluster(context.Background(), &pb.InitClusterRequest{MachineName: name})
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||||
|
require.ErrorContains(t, err, "invalid machine name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMachine_InvalidName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, name := range []string{"", "VPS1", "worker.example", "rr", "nearest", "c337f00600de51ef4375c9a9a267dba5"} {
|
||||||
|
m := &Machine{state: &State{ID: "machine-id", Name: "worker"}}
|
||||||
|
resp, err := m.UpdateMachine(context.Background(), &pb.UpdateMachineRequest{Name: &name})
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||||
|
require.ErrorContains(t, err, "invalid machine name")
|
||||||
|
require.Equal(t, "worker", m.state.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -31,7 +32,7 @@ type State struct {
|
|||||||
// MinStoreVersion is the cluster store version this machine must reach before participating.
|
// MinStoreVersion is the cluster store version this machine must reach before participating.
|
||||||
// Per-actor vector (Corrosion actor UUID → max processed db_version) captured from an existing
|
// Per-actor vector (Corrosion actor UUID → max processed db_version) captured from an existing
|
||||||
// member at join time. Cleared once reached.
|
// member at join time. Cleared once reached.
|
||||||
MinStoreVersion map[string]uint64 `json:",omitempty"`
|
MinStoreVersion api.StoreVersion `json:",omitempty"`
|
||||||
// CorrosionAPIToken authenticates requests to the local Corrosion API.
|
// CorrosionAPIToken authenticates requests to the local Corrosion API.
|
||||||
CorrosionAPIToken secret.Secret `json:",omitempty"`
|
CorrosionAPIToken secret.Secret `json:",omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
// SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
|
// SyncStatusOutdated indicates that a container record may be outdated, for example, due to being unable
|
||||||
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
|
// to retrieve the container's state from the Docker daemon or when the machine is being stopped or restarted.
|
||||||
SyncStatusOutdated = "outdated"
|
SyncStatusOutdated = "outdated"
|
||||||
|
|
||||||
|
// containerChangesDebounceInterval defines how long to wait before notifying subscribers about container changes.
|
||||||
|
// Multiple changes within this window are grouped into a single notification to prevent system overload.
|
||||||
|
containerChangesDebounceInterval = 250 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContainerRecord struct {
|
type ContainerRecord struct {
|
||||||
@@ -92,6 +96,9 @@ func normaliseContainerForStore(ctr *api.ServiceContainer) {
|
|||||||
// Remove the environment variables to avoid leaking secrets.
|
// Remove the environment variables to avoid leaking secrets.
|
||||||
ctr.Config.Env = nil
|
ctr.Config.Env = nil
|
||||||
ctr.ServiceSpec.Container.Env = nil
|
ctr.ServiceSpec.Container.Env = nil
|
||||||
|
if ctr.ServiceSpec.PreDeploy != nil {
|
||||||
|
ctr.ServiceSpec.PreDeploy.Env = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Docker returns Mounts in a non-deterministic order so sort them.
|
// Docker returns Mounts in a non-deterministic order so sort them.
|
||||||
slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int {
|
slices.SortFunc(ctr.Mounts, func(a, b container.MountPoint) int {
|
||||||
@@ -267,6 +274,13 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
|
|||||||
changes := make(chan struct{})
|
changes := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(changes)
|
defer close(changes)
|
||||||
|
// Coalesce bursts of rapid-fire row-level change events (e.g. from frequent health-check updates
|
||||||
|
// across many services) into a single signal per debounce window, instead of forwarding one signal
|
||||||
|
// per event. Every subscriber of this channel (e.g. the Caddy and DNS reconcilers) otherwise reruns
|
||||||
|
// its full reconciliation on every single event, which can burn significant CPU across the cluster
|
||||||
|
// when there's a lot of container churn.
|
||||||
|
var debouncer *time.Timer
|
||||||
|
var debouncerCh <-chan time.Time
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -279,8 +293,21 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-c
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Just signal that there is a change in the containers list.
|
if debouncerCh == nil {
|
||||||
changes <- struct{}{}
|
if debouncer == nil {
|
||||||
|
debouncer = time.NewTimer(containerChangesDebounceInterval)
|
||||||
|
} else {
|
||||||
|
debouncer.Reset(containerChangesDebounceInterval)
|
||||||
|
}
|
||||||
|
debouncerCh = debouncer.C
|
||||||
|
}
|
||||||
|
case <-debouncerCh:
|
||||||
|
select {
|
||||||
|
case changes <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debouncerCh = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNormaliseContainerForStore_StripsEnv pins the behaviour that all fields carrying environment variables
|
||||||
|
// are cleared before a container record is written to the replicated store, regardless of which part of the
|
||||||
|
// spec they live in. See https://github.com/psviderski/uncloud/issues/422.
|
||||||
|
func TestNormaliseContainerForStore_StripsEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
newContainer := func(preDeploy *api.PreDeployHook) api.ServiceContainer {
|
||||||
|
return api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{
|
||||||
|
Env: []string{"CONTROL_TOKEN=control-value-aaa"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServiceSpec: api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Env: api.EnvVars{"CONTROL_TOKEN": "control-value-aaa"},
|
||||||
|
},
|
||||||
|
PreDeploy: preDeploy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("service with pre-deploy hook", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctr := newContainer(&api.PreDeployHook{
|
||||||
|
Command: []string{"/bin/true"},
|
||||||
|
Env: api.EnvVars{"HOOK_TOKEN": "hook-value-bbb"},
|
||||||
|
})
|
||||||
|
|
||||||
|
normaliseContainerForStore(&ctr)
|
||||||
|
|
||||||
|
assert.Nil(t, ctr.Config.Env, "Config.Env should be stripped")
|
||||||
|
assert.Nil(t, ctr.ServiceSpec.Container.Env, "ServiceSpec.Container.Env should be stripped")
|
||||||
|
require.NotNil(t, ctr.ServiceSpec.PreDeploy, "PreDeploy itself should be left in place")
|
||||||
|
assert.Nil(t, ctr.ServiceSpec.PreDeploy.Env, "ServiceSpec.PreDeploy.Env should be stripped")
|
||||||
|
// Non-env fields of the hook must survive the normalisation.
|
||||||
|
assert.Equal(t, []string{"/bin/true"}, ctr.ServiceSpec.PreDeploy.Command)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("service without pre-deploy hook", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctr := newContainer(nil)
|
||||||
|
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
normaliseContainerForStore(&ctr)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, ctr.Config.Env)
|
||||||
|
assert.Nil(t, ctr.ServiceSpec.Container.Env)
|
||||||
|
assert.Nil(t, ctr.ServiceSpec.PreDeploy)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,14 +8,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidStoreVersion indicates an invalid actor UUID in a store version vector.
|
// ErrInvalidStoreVersion indicates an invalid actor UUID in a store version vector.
|
||||||
var ErrInvalidStoreVersion = errors.New("invalid store version")
|
var ErrInvalidStoreVersion = errors.New("invalid store version")
|
||||||
|
|
||||||
// WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known
|
// WaitForVersion waits until the local store has reached each actor's minimum version in minVersion, with no known
|
||||||
// missing or pending transactions through those versions. Corrosion may satisfy a version by applying its surviving
|
// missing or pending transactions through those versions from active members. Corrosion may satisfy a version
|
||||||
// changes or by marking it complete because its changes have been superseded.
|
// by applying its surviving changes or by marking it complete because its changes have been superseded.
|
||||||
//
|
//
|
||||||
// Waiting normally makes the captured data available locally. However, if another write replaces some of that data
|
// Waiting normally makes the captured data available locally. However, if another write replaces some of that data
|
||||||
// before it arrives, Corrosion can complete the older version without transferring the replaced data. If the
|
// before it arrives, Corrosion can complete the older version without transferring the replaced data. If the
|
||||||
@@ -27,7 +29,7 @@ var ErrInvalidStoreVersion = errors.New("invalid store version")
|
|||||||
//
|
//
|
||||||
// The method observes native replication without initiating synchronisation.
|
// The method observes native replication without initiating synchronisation.
|
||||||
// An empty minVersion requires no replication. The context controls cancellation and the deadline.
|
// An empty minVersion requires no replication. The context controls cancellation and the deadline.
|
||||||
func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64) error {
|
func (s *Store) WaitForVersion(ctx context.Context, minVersion api.StoreVersion) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -52,28 +54,64 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all bookkeeping in one SQLite snapshot. Raw buffered rows can remain after application,
|
queryPrefix := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
|
||||||
// but sequence bookkeeping is removed in the same transaction that applies or clears a version.
|
|
||||||
query := `WITH min_version(actor_id, version) AS (VALUES ` + strings.Join(placeholders, ", ") + `)
|
|
||||||
SELECT NOT EXISTS (
|
SELECT NOT EXISTS (
|
||||||
SELECT 1 FROM min_version
|
SELECT 1 FROM min_version
|
||||||
LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id
|
LEFT JOIN crsql_db_versions AS current ON current.site_id = min_version.actor_id
|
||||||
WHERE COALESCE(current.db_version, 0) < min_version.version
|
WHERE COALESCE(current.db_version, 0) < min_version.version`
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM __corro_bookkeeping_gaps
|
versionsReached := false
|
||||||
WHERE actor_id = min_version.actor_id AND start <= min_version.version
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM __corro_seq_bookkeeping
|
|
||||||
WHERE site_id = min_version.actor_id AND db_version <= min_version.version
|
|
||||||
)
|
|
||||||
)`
|
|
||||||
|
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
rows, err := s.corro.QueryContext(ctx, query, args...)
|
query := queryPrefix
|
||||||
|
queryArgs := args
|
||||||
|
// Check for gaps and pending transactions only after the requested versions are reached.
|
||||||
|
if versionsReached {
|
||||||
|
states, err := s.corroAdmin.ClusterMembershipStates(true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get cluster membership for store replication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activePlaceholders := make([]string, 0, len(states))
|
||||||
|
queryArgs = make([]any, len(args), len(args)+len(states))
|
||||||
|
copy(queryArgs, args)
|
||||||
|
for _, state := range states {
|
||||||
|
if state.State != corrosion.MembershipStateAlive && state.State != corrosion.MembershipStateSuspect {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actorID, err := uuid.Parse(state.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse cluster member actor '%s': %w", state.ID, err)
|
||||||
|
}
|
||||||
|
activePlaceholders = append(activePlaceholders, "?")
|
||||||
|
queryArgs = append(queryArgs, [16]byte(actorID))
|
||||||
|
}
|
||||||
|
if len(activePlaceholders) > 0 {
|
||||||
|
// Check active members' gaps and pending sequences in the same query for simplicity.
|
||||||
|
// Buffered rows can remain in __corro_buffered_changes after application, but sequence bookkeeping
|
||||||
|
// is removed when a version completes.
|
||||||
|
query += `
|
||||||
|
OR (
|
||||||
|
min_version.actor_id IN (` + strings.Join(activePlaceholders, ", ") + `)
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM __corro_bookkeeping_gaps
|
||||||
|
WHERE actor_id = min_version.actor_id AND start <= min_version.version
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM __corro_seq_bookkeeping
|
||||||
|
WHERE site_id = min_version.actor_id AND db_version <= min_version.version
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query += ")"
|
||||||
|
|
||||||
|
rows, err := s.corro.QueryContext(ctx, query, queryArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check store replication: %w", err)
|
return fmt.Errorf("check store replication: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,6 +121,7 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64
|
|||||||
}
|
}
|
||||||
return errors.New("check store replication: store version query returned no rows")
|
return errors.New("check store replication: store version query returned no rows")
|
||||||
}
|
}
|
||||||
|
|
||||||
var reached int
|
var reached int
|
||||||
if err = rows.Scan(&reached); err != nil {
|
if err = rows.Scan(&reached); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
@@ -94,7 +133,11 @@ func (s *Store) WaitForVersion(ctx context.Context, minVersion map[string]uint64
|
|||||||
return fmt.Errorf("check store replication: %w", err)
|
return fmt.Errorf("check store replication: %w", err)
|
||||||
}
|
}
|
||||||
if reached == 1 {
|
if reached == 1 {
|
||||||
return ctx.Err()
|
if versionsReached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
versionsReached = true
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/corrosion"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,11 +24,12 @@ var (
|
|||||||
|
|
||||||
// Store is a cluster store backed by a distributed Corrosion database.
|
// Store is a cluster store backed by a distributed Corrosion database.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
corro *corrosion.APIClient
|
corro *corrosion.APIClient
|
||||||
|
corroAdmin *corrosion.AdminClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(corro *corrosion.APIClient) *Store {
|
func New(corro *corrosion.APIClient, corroAdmin *corrosion.AdminClient) *Store {
|
||||||
return &Store{corro: corro}
|
return &Store{corro: corro, corroAdmin: corroAdmin}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves an unnamespaced legacy value.
|
// Get retrieves an unnamespaced legacy value.
|
||||||
@@ -81,14 +83,14 @@ func (s *Store) Delete(ctx context.Context, key string) error {
|
|||||||
// limitations documented there.
|
// limitations documented there.
|
||||||
//
|
//
|
||||||
// Capturing a vector does not wait for replication or prevent further writes.
|
// Capturing a vector does not wait for replication or prevent further writes.
|
||||||
func (s *Store) Version(ctx context.Context) (map[string]uint64, error) {
|
func (s *Store) Version(ctx context.Context) (api.StoreVersion, error) {
|
||||||
rows, err := s.corro.QueryContext(ctx, "SELECT site_id, db_version FROM crsql_db_versions")
|
rows, err := s.corro.QueryContext(ctx, "SELECT site_id, db_version FROM crsql_db_versions")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query crsql_db_versions: %w", err)
|
return nil, fmt.Errorf("query crsql_db_versions: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
versions := make(map[string]uint64)
|
versions := make(api.StoreVersion)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
siteID []byte
|
siteID []byte
|
||||||
|
|||||||
@@ -1,36 +1,43 @@
|
|||||||
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
||||||
|
|
||||||
[[tools."aqua:vektra/mockery"]]
|
[[tools."aqua:vektra/mockery"]]
|
||||||
version = "3.5.3"
|
version = "3.8.1"
|
||||||
backend = "aqua:vektra/mockery"
|
backend = "aqua:vektra/mockery"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.linux-arm64"]
|
[tools."aqua:vektra/mockery"."platforms.linux-arm64"]
|
||||||
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290"
|
checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915757"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.linux-arm64-musl"]
|
[tools."aqua:vektra/mockery"."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:ec02ca72b7ad1ff5a1de5503a27d3b1b3f230d722eb35c5b92e43e295a8e1290"
|
checksum = "sha256:d1f481f3a6e9868dd3ad65da265238ee109ab3a5dd7508958d5811953696f452"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_arm64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915757"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.linux-x64"]
|
[tools."aqua:vektra/mockery"."platforms.linux-x64"]
|
||||||
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_x86_64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915756"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.linux-x64-musl"]
|
[tools."aqua:vektra/mockery"."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
checksum = "sha256:953c7d38b58dd6398b1c595f27caf8bbf27e9b733b49c404d2e124e86d2ae72a"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Linux_x86_64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Linux_x86_64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915756"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.macos-arm64"]
|
[tools."aqua:vektra/mockery"."platforms.macos-arm64"]
|
||||||
checksum = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
checksum = "sha256:303a794484243906b37da0c37056f974bf94278c2e10d7b1372e6bd53a2d5034"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_arm64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Darwin_arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915806"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.macos-x64"]
|
[tools."aqua:vektra/mockery"."platforms.macos-x64"]
|
||||||
checksum = "sha256:8bd2b3e4722e476400395920fb5bbd68be4ea22ec12cddf1e424c40610fda863"
|
checksum = "sha256:6882331344e946268b304dce042b01371a0213a896536413be8abe49d532ca6f"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Darwin_x86_64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Darwin_x86_64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915746"
|
||||||
|
|
||||||
[tools."aqua:vektra/mockery"."platforms.windows-x64"]
|
[tools."aqua:vektra/mockery"."platforms.windows-x64"]
|
||||||
checksum = "sha256:3b4264066db615a56a8b59abda992f6db82b8cd7666937ed9dd8f4a4bfc424a0"
|
checksum = "sha256:7a7458756e9d7bce5f9936b7a63394000f3affa751b9740e65350c4bdcb627dc"
|
||||||
url = "https://github.com/vektra/mockery/releases/download/v3.5.3/mockery_3.5.3_Windows_x86_64.tar.gz"
|
url = "https://github.com/vektra/mockery/releases/download/v3.8.1/mockery_3.8.1_Windows_x86_64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/632915759"
|
||||||
|
|
||||||
[[tools.go]]
|
[[tools.go]]
|
||||||
version = "1.26.1"
|
version = "1.26.1"
|
||||||
@@ -71,32 +78,44 @@ backend = "aqua:golangci/golangci-lint"
|
|||||||
[tools.golangci-lint."platforms.linux-arm64"]
|
[tools.golangci-lint."platforms.linux-arm64"]
|
||||||
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
|
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.golangci-lint."platforms.linux-arm64-musl"]
|
[tools.golangci-lint."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
|
checksum = "sha256:c3c83c7ed003e881991af21361c487151abd37969927ed9062cd0b48f3cd1d8d"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099658"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.golangci-lint."platforms.linux-x64"]
|
[tools.golangci-lint."platforms.linux-x64"]
|
||||||
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
|
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.golangci-lint."platforms.linux-x64-musl"]
|
[tools.golangci-lint."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
|
checksum = "sha256:852741ea769f4543ab4b6cf86b313cb7173f2e8d2d39f00eb0cd5ef5d4d5eda9"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-linux-amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099674"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.golangci-lint."platforms.macos-arm64"]
|
[tools.golangci-lint."platforms.macos-arm64"]
|
||||||
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
|
checksum = "sha256:11e9549b955bae4bfb71aa3b5c2762e446e2d3f975fe963d6d767b8f745d9344"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099671"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.golangci-lint."platforms.macos-x64"]
|
[tools.golangci-lint."platforms.macos-x64"]
|
||||||
checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af"
|
checksum = "sha256:1dbbbe745eadba9cf012d49bd0b8c9290cbcb50dbf2cf9611f75a715b46ae3af"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-amd64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-darwin-amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099653"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.golangci-lint."platforms.windows-x64"]
|
[tools.golangci-lint."platforms.windows-x64"]
|
||||||
checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3"
|
checksum = "sha256:60e50178253d276d1c808ef00845eddcabca63b7d69d86a6dbaa41e88bc843f3"
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.2/golangci-lint-2.11.2-windows-amd64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/369099651"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.goreleaser]]
|
[[tools.goreleaser]]
|
||||||
version = "2.15.2"
|
version = "2.15.2"
|
||||||
@@ -105,37 +124,44 @@ backend = "aqua:goreleaser/goreleaser"
|
|||||||
[tools.goreleaser."platforms.linux-arm64"]
|
[tools.goreleaser."platforms.linux-arm64"]
|
||||||
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
|
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
|
||||||
provenance = "cosign"
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.goreleaser."platforms.linux-arm64-musl"]
|
[tools.goreleaser."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
|
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
|
||||||
provenance = "cosign"
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702105"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.goreleaser."platforms.linux-x64"]
|
[tools.goreleaser."platforms.linux-x64"]
|
||||||
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
|
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.goreleaser."platforms.linux-x64-musl"]
|
[tools.goreleaser."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
|
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
|
||||||
provenance = "cosign"
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702106"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.goreleaser."platforms.macos-arm64"]
|
[tools.goreleaser."platforms.macos-arm64"]
|
||||||
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
|
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.goreleaser."platforms.macos-x64"]
|
[tools.goreleaser."platforms.macos-x64"]
|
||||||
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
|
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
|
||||||
provenance = "cosign"
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702148"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.goreleaser."platforms.windows-x64"]
|
[tools.goreleaser."platforms.windows-x64"]
|
||||||
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
|
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
|
||||||
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip"
|
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip"
|
||||||
provenance = "cosign"
|
url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/385702135"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.node]]
|
[[tools.node]]
|
||||||
version = "22.20.0"
|
version = "22.20.0"
|
||||||
@@ -146,16 +172,17 @@ checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c
|
|||||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
|
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.linux-arm64-musl"]
|
[tools.node."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
|
checksum = "sha256:79a087c1e23fc97d173ad6676df5b1a4e1a02daf1acca05ee25662747c5d492c"
|
||||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-arm64.tar.gz"
|
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0.tar.gz"
|
||||||
|
install = "source"
|
||||||
|
|
||||||
[tools.node."platforms.linux-x64"]
|
[tools.node."platforms.linux-x64"]
|
||||||
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
||||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.linux-x64-musl"]
|
[tools.node."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
checksum = "sha256:461aafed5eb607357479c6de5df978515d74daf7e0169e972e61c8390c035f1a"
|
||||||
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
url = "https://unofficial-builds.nodejs.org/download/release/v22.20.0/node-v22.20.0-linux-x64-musl.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.macos-arm64"]
|
[tools.node."platforms.macos-arm64"]
|
||||||
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
|
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
|
||||||
@@ -173,9 +200,34 @@ url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-win-x64.zip"
|
|||||||
version = "27.3"
|
version = "27.3"
|
||||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||||
|
|
||||||
|
[tools.protoc."platforms.linux-arm64"]
|
||||||
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-aarch_64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006374"
|
||||||
|
|
||||||
|
[tools.protoc."platforms.linux-arm64-musl"]
|
||||||
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-aarch_64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006374"
|
||||||
|
|
||||||
[tools.protoc."platforms.linux-x64"]
|
[tools.protoc."platforms.linux-x64"]
|
||||||
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
checksum = "blake3:8c2938accf7061164ea3968529a47ced1db4824ca553857f6903c68ae4347739"
|
||||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006380"
|
||||||
|
|
||||||
|
[tools.protoc."platforms.linux-x64-musl"]
|
||||||
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-linux-x86_64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006380"
|
||||||
|
|
||||||
|
[tools.protoc."platforms.macos-arm64"]
|
||||||
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-aarch_64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006383"
|
||||||
|
|
||||||
|
[tools.protoc."platforms.macos-x64"]
|
||||||
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-osx-x86_64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006381"
|
||||||
|
|
||||||
|
[tools.protoc."platforms.windows-x64"]
|
||||||
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v27.3/protoc-27.3-win64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/183006385"
|
||||||
|
|
||||||
[[tools.protoc-gen-go]]
|
[[tools.protoc-gen-go]]
|
||||||
version = "1.34.2"
|
version = "1.34.2"
|
||||||
@@ -183,30 +235,62 @@ backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
|||||||
|
|
||||||
[tools.protoc-gen-go."platforms.linux-arm64"]
|
[tools.protoc-gen-go."platforms.linux-arm64"]
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
|
||||||
|
|
||||||
[tools.protoc-gen-go."platforms.linux-arm64-musl"]
|
[tools.protoc-gen-go."platforms.linux-arm64-musl"]
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109690"
|
||||||
|
|
||||||
[tools.protoc-gen-go."platforms.linux-x64"]
|
[tools.protoc-gen-go."platforms.linux-x64"]
|
||||||
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
checksum = "blake3:f765e393e0f0b25070c13848d88f5e6303e66090132afa1b753e981507a4174e"
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
|
||||||
|
|
||||||
[tools.protoc-gen-go."platforms.linux-x64-musl"]
|
[tools.protoc-gen-go."platforms.linux-x64-musl"]
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.linux.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109692"
|
||||||
|
|
||||||
[tools.protoc-gen-go."platforms.macos-arm64"]
|
[tools.protoc-gen-go."platforms.macos-arm64"]
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109697"
|
||||||
|
|
||||||
[tools.protoc-gen-go."platforms.macos-x64"]
|
[tools.protoc-gen-go."platforms.macos-x64"]
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.amd64.tar.gz"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.darwin.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109698"
|
||||||
|
|
||||||
[tools.protoc-gen-go."platforms.windows-x64"]
|
[tools.protoc-gen-go."platforms.windows-x64"]
|
||||||
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.windows.amd64.zip"
|
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.34.2/protoc-gen-go.v1.34.2.windows.amd64.zip"
|
||||||
|
url_api = "https://api.github.com/repos/protocolbuffers/protobuf-go/releases/assets/173109685"
|
||||||
|
|
||||||
[[tools.protoc-gen-go-grpc]]
|
[[tools.protoc-gen-go-grpc]]
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc."platforms.linux-arm64"]
|
||||||
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572238"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc."platforms.linux-arm64-musl"]
|
||||||
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572238"
|
||||||
|
|
||||||
[tools.protoc-gen-go-grpc."platforms.linux-x64"]
|
[tools.protoc-gen-go-grpc."platforms.linux-x64"]
|
||||||
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
checksum = "blake3:197739132f0408f017d0c83773ac1ace85c0572473fdd658ec6e4ccbf20e0552"
|
||||||
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572229"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc."platforms.linux-x64-musl"]
|
||||||
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572229"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc."platforms.macos-arm64"]
|
||||||
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572243"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc."platforms.macos-x64"]
|
||||||
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.darwin.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572208"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc."platforms.windows-x64"]
|
||||||
|
url = "https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v1.5.1/protoc-gen-go-grpc.v1.5.1.windows.amd64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/grpc/grpc-go/releases/assets/182572216"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
experimental = true
|
experimental = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
"aqua:vektra/mockery" = "3.5.3"
|
"aqua:vektra/mockery" = "3.8.1"
|
||||||
go = "1.26.1"
|
go = "1.26.1"
|
||||||
golangci-lint = "2.11.2"
|
golangci-lint = "2.11.2"
|
||||||
goreleaser = "2.15.2"
|
goreleaser = "2.15.2"
|
||||||
@@ -40,6 +40,10 @@ outputs = ["uncloudd-linux-arm64"]
|
|||||||
description = "Update mise.lock with hashes for all platforms"
|
description = "Update mise.lock with hashes for all platforms"
|
||||||
run = "mise lock --platform linux-x64,macos-arm64"
|
run = "mise lock --platform linux-x64,macos-arm64"
|
||||||
|
|
||||||
|
[tasks.mocks]
|
||||||
|
description = "Regenerate Go test mocks"
|
||||||
|
run = "mockery"
|
||||||
|
|
||||||
[tasks.proto]
|
[tasks.proto]
|
||||||
description = "Regenerate gRPC API code from .proto files"
|
description = "Regenerate gRPC API code from .proto files"
|
||||||
run = """
|
run = """
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,18 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaddyConfig is the saved Caddy configuration on a machine.
|
||||||
|
type CaddyConfig struct {
|
||||||
|
Caddyfile string
|
||||||
|
// ModifiedAt is zero when the server does not supply a modification timestamp.
|
||||||
|
ModifiedAt time.Time
|
||||||
|
// LastReconciliationError describes the latest unsuccessful configuration reconciliation.
|
||||||
|
LastReconciliationError string
|
||||||
|
}
|
||||||
|
|
||||||
// CaddySpec is the Caddy reverse proxy configuration for a service.
|
// CaddySpec is the Caddy reverse proxy configuration for a service.
|
||||||
type CaddySpec struct {
|
type CaddySpec struct {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mholt/acmez/v3/acme"
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssuedCertificate describes a certificate in Caddy's managed certificate storage.
|
||||||
|
// It does not indicate whether Caddy currently serves the certificate or whether it is trusted.
|
||||||
|
type IssuedCertificate struct {
|
||||||
|
// SAN is the Subject Alternative Name (SAN) of the certificate.
|
||||||
|
// Caddy doesn't issue certificates with multiple SANs.
|
||||||
|
SAN string
|
||||||
|
// Chain contains the parsed certificates in stored order, with the leaf first.
|
||||||
|
Chain []*x509.Certificate
|
||||||
|
// IssuerData is extra information associated with the certificate, usually provided by the issuer implementation.
|
||||||
|
IssuerData CertificateIssuerData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint returns the SHA-256 fingerprint of the leaf certificate.
|
||||||
|
func (c IssuedCertificate) Fingerprint() [sha256.Size]byte {
|
||||||
|
return sha256.Sum256(c.Chain[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
|
||||||
|
type CertificateIssuerData struct {
|
||||||
|
// Raw is the original issuer_data JSON, including unrecognized formats and fields.
|
||||||
|
Raw json.RawMessage
|
||||||
|
// ACME is populated when the metadata can be decoded as an ACME record with CA and certificate URLs.
|
||||||
|
// It is nil for absent, unrecognized, or malformed metadata.
|
||||||
|
ACME *ACMEIssuerData
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACMEIssuerData identifies the ACME resources used to issue a certificate.
|
||||||
|
type ACMEIssuerData struct {
|
||||||
|
// URL is the certificate resource URL as provisioned by the ACME server.
|
||||||
|
URL string
|
||||||
|
// CA is the directory URL of the ACME CA that issued this certificate.
|
||||||
|
CA string
|
||||||
|
// Account is the URL of the account that obtained the certificate.
|
||||||
|
Account string
|
||||||
|
// RenewalInfo is the stored renewal guidance, not a guarantee of when renewal will run.
|
||||||
|
RenewalInfo *acme.RenewalInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuedCertificateFromProto parses a stored certificate chain without verifying trust or expiry.
|
||||||
|
// Issuer metadata is decoded as ACME on a best-effort basis and is always preserved in its raw form.
|
||||||
|
func IssuedCertificateFromProto(p *pb.IssuedCertificate) (IssuedCertificate, error) {
|
||||||
|
if p == nil || strings.TrimSpace(p.San) == "" {
|
||||||
|
return IssuedCertificate{}, fmt.Errorf("invalid certificate: missing SAN")
|
||||||
|
}
|
||||||
|
chain, err := parseCertificateChain(p.Chain)
|
||||||
|
if err != nil {
|
||||||
|
return IssuedCertificate{}, fmt.Errorf("parse certificate '%s': %w", p.San, err)
|
||||||
|
}
|
||||||
|
return IssuedCertificate{
|
||||||
|
SAN: p.San,
|
||||||
|
Chain: chain,
|
||||||
|
IssuerData: parseCertificateIssuerData(p.IssuerData),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCertificateChain(data []byte) ([]*x509.Certificate, error) {
|
||||||
|
var chain []*x509.Certificate
|
||||||
|
for len(data) > 0 {
|
||||||
|
var block *pem.Block
|
||||||
|
block, data = pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
return nil, fmt.Errorf("unexpected PEM block type '%s'", block.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse X.509 certificate: %w", err)
|
||||||
|
}
|
||||||
|
chain = append(chain, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chain) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty certificate chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCertificateIssuerData(raw json.RawMessage) CertificateIssuerData {
|
||||||
|
data := CertificateIssuerData{Raw: raw}
|
||||||
|
// Recognize ACME by its resource URLs and decodable metadata.
|
||||||
|
// Unknown formats and malformed records remain raw-only.
|
||||||
|
var cert acme.Certificate
|
||||||
|
if err := json.Unmarshal(raw, &cert); err != nil || cert.CA == "" || cert.URL == "" {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
data.ACME = &ACMEIssuerData{
|
||||||
|
URL: cert.URL,
|
||||||
|
CA: cert.CA,
|
||||||
|
Account: cert.Account,
|
||||||
|
RenewalInfo: cert.RenewalInfo,
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mholt/acmez/v3/acme"
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssuedCertificateFromProto(t *testing.T) {
|
||||||
|
leaf := testCertificatePEM(t, 1)
|
||||||
|
issuer := testCertificatePEM(t, 2)
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
chain []byte
|
||||||
|
serials []int64
|
||||||
|
}{
|
||||||
|
{name: "single certificate", chain: leaf, serials: []int64{1}},
|
||||||
|
{name: "certificate chain", chain: append(append([]byte(nil), leaf...), issuer...), serials: []int64{1, 2}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
|
||||||
|
San: "app.example.com", Chain: tt.chain,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "app.example.com", cert.SAN)
|
||||||
|
require.Len(t, cert.Chain, len(tt.serials))
|
||||||
|
|
||||||
|
for i, serial := range tt.serials {
|
||||||
|
assert.Equal(t, big.NewInt(serial), cert.Chain[i].SerialNumber, "chain order must be preserved")
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"app.example.com"}, cert.Chain[0].DNSNames)
|
||||||
|
// Expired, self-signed certificates are parsed without verifying trust or validity.
|
||||||
|
assert.Equal(t, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), cert.Chain[0].NotAfter)
|
||||||
|
assert.Empty(t, cert.IssuerData)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuedCertificateFingerprint(t *testing.T) {
|
||||||
|
cert := IssuedCertificate{Chain: []*x509.Certificate{
|
||||||
|
{Raw: []byte("abc")},
|
||||||
|
{Raw: []byte("issuer")},
|
||||||
|
}}
|
||||||
|
want, err := hex.DecodeString("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
||||||
|
require.NoError(t, err)
|
||||||
|
fingerprint := cert.Fingerprint()
|
||||||
|
assert.Equal(t, want, fingerprint[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
|
||||||
|
valid := testCertificatePEM(t, 1)
|
||||||
|
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
input *pb.IssuedCertificate
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "nil input", wantErr: "missing SAN"},
|
||||||
|
{name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"},
|
||||||
|
{name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"},
|
||||||
|
{name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"},
|
||||||
|
{
|
||||||
|
name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")},
|
||||||
|
wantErr: "empty certificate chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong PEM block type", input: &pb.IssuedCertificate{
|
||||||
|
San: "app.example.com",
|
||||||
|
Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")}),
|
||||||
|
},
|
||||||
|
wantErr: "unexpected PEM block type",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER},
|
||||||
|
wantErr: "parse X.509 certificate",
|
||||||
|
},
|
||||||
|
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{
|
||||||
|
San: "app.example.com",
|
||||||
|
Chain: append(append([]byte(nil), valid...), invalidDER...),
|
||||||
|
}, wantErr: "parse X.509 certificate"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cert, err := IssuedCertificateFromProto(tt.input)
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
assert.Empty(t, cert)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuedCertificateFromProto_IssuerData(t *testing.T) {
|
||||||
|
chain := testCertificatePEM(t, 1)
|
||||||
|
retryAfter := time.Date(2029, 12, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
renewalInfo := &acme.RenewalInfo{
|
||||||
|
ExplanationURL: "https://ca.example/why",
|
||||||
|
UniqueIdentifier: "aki.serial",
|
||||||
|
RetryAfter: &retryAfter,
|
||||||
|
SelectedTime: time.Date(2030, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
renewalInfo.SuggestedWindow.Start = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
renewalInfo.SuggestedWindow.End = time.Date(2030, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want *ACMEIssuerData
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ACME",
|
||||||
|
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","account":"https://ca.example/acct/1","future_field":true}`,
|
||||||
|
want: &ACMEIssuerData{
|
||||||
|
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory", Account: "https://ca.example/acct/1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ACME with renewal information",
|
||||||
|
raw: `{
|
||||||
|
"url": "https://ca.example/cert/1",
|
||||||
|
"ca": "https://ca.example/directory",
|
||||||
|
"account": "https://ca.example/acct/1",
|
||||||
|
"renewal_info": {
|
||||||
|
"suggestedWindow": {"start": "2030-01-01T00:00:00Z", "end": "2030-01-03T00:00:00Z"},
|
||||||
|
"explanationURL": "https://ca.example/why",
|
||||||
|
"_uniqueIdentifier": "aki.serial",
|
||||||
|
"_retryAfter": "2029-12-01T00:00:00Z",
|
||||||
|
"_selectedTime": "2030-01-02T00:00:00Z"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: &ACMEIssuerData{
|
||||||
|
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory",
|
||||||
|
Account: "https://ca.example/acct/1", RenewalInfo: renewalInfo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{name: "absent"},
|
||||||
|
{name: "null", raw: "null"},
|
||||||
|
{name: "unknown issuer", raw: `{"id":"provider-id","status":"issued"}`},
|
||||||
|
{name: "non-object JSON", raw: `"provider-record"`},
|
||||||
|
{name: "missing CA", raw: `{"url":"https://ca.example/cert/1"}`},
|
||||||
|
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
|
||||||
|
{name: "invalid JSON", raw: "{"},
|
||||||
|
{name: "invalid field types", raw: `{"url":123,"ca":false}`},
|
||||||
|
{
|
||||||
|
name: "malformed renewal information",
|
||||||
|
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
|
||||||
|
San: "app.example.com", Chain: chain, IssuerData: []byte(tt.raw),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "issuer metadata must not prevent certificate parsing")
|
||||||
|
require.Len(t, cert.Chain, 1)
|
||||||
|
assert.Equal(t, tt.raw, string(cert.IssuerData.Raw), "preserve original JSON including unknown fields")
|
||||||
|
assert.Equal(t, tt.want, cert.IssuerData.ACME)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testCertificatePEM creates an expired, self-signed certificate for parsing tests.
|
||||||
|
func testCertificatePEM(t *testing.T, serial int64) []byte {
|
||||||
|
t.Helper()
|
||||||
|
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(serial),
|
||||||
|
DNSNames: []string{"app.example.com"},
|
||||||
|
NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
NotAfter: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ type DNSClient interface {
|
|||||||
type ImageClient interface {
|
type ImageClient interface {
|
||||||
InspectImage(ctx context.Context, id string) ([]MachineImage, error)
|
InspectImage(ctx context.Context, id string) ([]MachineImage, error)
|
||||||
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
|
InspectRemoteImage(ctx context.Context, id string) ([]MachineRemoteImage, error)
|
||||||
|
ListImages(ctx context.Context, filter ImageFilter) ([]MachineImages, error)
|
||||||
|
RemoveImage(ctx context.Context, image string, opts RemoveImageOptions) ([]MachineImageRemoval, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogsClient interface {
|
type LogsClient interface {
|
||||||
|
|||||||
+12
-10
@@ -1,8 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
@@ -24,14 +22,6 @@ type MachineImages struct {
|
|||||||
ContainerdStore bool
|
ContainerdStore bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error returns a non-nil error if listing images on this machine failed.
|
|
||||||
func (mi MachineImages) Error() error {
|
|
||||||
if mi.Metadata != nil && mi.Metadata.Error != "" {
|
|
||||||
return errors.New(mi.Metadata.Error)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageFilter defines criteria to filter images in ListImages.
|
// ImageFilter defines criteria to filter images in ListImages.
|
||||||
type ImageFilter struct {
|
type ImageFilter struct {
|
||||||
// Machines filters images to those present on the specified machines (names or IDs).
|
// Machines filters images to those present on the specified machines (names or IDs).
|
||||||
@@ -42,6 +32,18 @@ type ImageFilter struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveImageOptions defines options for removing an image from cluster machines.
|
||||||
|
type RemoveImageOptions struct {
|
||||||
|
// Machines selects machines to remove the image from (names or IDs). If empty, all cluster machines are selected.
|
||||||
|
Machines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineImageRemoval contains the outcome of removing an image on one machine.
|
||||||
|
type MachineImageRemoval struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
Results []image.DeleteResponse
|
||||||
|
}
|
||||||
|
|
||||||
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||||
type MachineRemoteImage struct {
|
type MachineRemoteImage struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ValidateMachineName checks that a machine name is a lowercase DNS label and doesn't conflict with
|
||||||
|
// internal DNS query modes or machine IDs.
|
||||||
|
func ValidateMachineName(name string) error {
|
||||||
|
if !DNSLabelRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid machine name %q: must be 1-63 characters, lowercase letters, numbers, "+
|
||||||
|
"and hyphens only, starting and ending with a letter or number", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "rr", "nearest":
|
||||||
|
return fmt.Errorf("invalid machine name %q: reserved for internal DNS query modes", name)
|
||||||
|
}
|
||||||
|
if IDRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid machine name %q: must not match the machine ID format (32 hexadecimal characters)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MachineFilter defines criteria to filter machines in ListMachines.
|
// MachineFilter defines criteria to filter machines in ListMachines.
|
||||||
type MachineFilter struct {
|
type MachineFilter struct {
|
||||||
// Available filters machines that are not DOWN.
|
// Available filters machines that are not DOWN.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
@@ -10,6 +11,52 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestValidateMachineName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "single letter", input: "a"},
|
||||||
|
{name: "single digit", input: "1"},
|
||||||
|
{name: "two characters", input: "a1"},
|
||||||
|
{name: "generated name", input: "machine-ab12"},
|
||||||
|
{name: "hyphens and digits", input: "1-worker-2"},
|
||||||
|
{name: "maximum length", input: strings.Repeat("a", 63)},
|
||||||
|
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
|
||||||
|
{name: "machine namespace label", input: "m"},
|
||||||
|
{name: "mode prefix", input: "nearest-worker"},
|
||||||
|
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
|
||||||
|
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
|
||||||
|
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
|
||||||
|
{name: "empty", wantErr: "must be 1-63 characters"},
|
||||||
|
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
|
||||||
|
{name: "uppercase", input: "VPS1", wantErr: "lowercase letters"},
|
||||||
|
{name: "leading hyphen", input: "-worker", wantErr: "starting and ending"},
|
||||||
|
{name: "trailing hyphen", input: "worker-", wantErr: "starting and ending"},
|
||||||
|
{name: "underscore", input: "worker_1", wantErr: "hyphens only"},
|
||||||
|
{name: "dot", input: "worker.example", wantErr: "hyphens only"},
|
||||||
|
{name: "space", input: "worker 1", wantErr: "hyphens only"},
|
||||||
|
{name: "leading whitespace", input: " worker", wantErr: "hyphens only"},
|
||||||
|
{name: "trailing whitespace", input: "worker\t", wantErr: "hyphens only"},
|
||||||
|
{name: "non-ASCII", input: "wörker", wantErr: "lowercase letters"},
|
||||||
|
{name: "round-robin mode", input: "rr", wantErr: "reserved for internal DNS query modes"},
|
||||||
|
{name: "nearest mode", input: "nearest", wantErr: "reserved for internal DNS query modes"},
|
||||||
|
{name: "machine ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "machine ID format"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := ValidateMachineName(tt.input)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMachineMembersList_Info(t *testing.T) {
|
func TestMachineMembersList_Info(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+23
-9
@@ -41,12 +41,30 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
IDRegex = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||||
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
|
DNSLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateServiceID(id string) bool {
|
func ValidateServiceID(id string) bool {
|
||||||
return serviceIDRegexp.MatchString(id)
|
return IDRegex.MatchString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateServiceName checks that a service name is a lowercase DNS label and doesn't conflict with
|
||||||
|
// the machine DNS namespace or service IDs.
|
||||||
|
func ValidateServiceName(name string) error {
|
||||||
|
if !DNSLabelRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid service name %q: must be 1-63 characters, lowercase letters, numbers, "+
|
||||||
|
"and hyphens only, starting and ending with a letter or number", name)
|
||||||
|
}
|
||||||
|
if name == "m" {
|
||||||
|
return fmt.Errorf("invalid service name %q: reserved for the machine DNS namespace", name)
|
||||||
|
}
|
||||||
|
if IDRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid service name %q: must not match the service ID format (32 hexadecimal characters)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceSpec defines the desired state of a service.
|
// ServiceSpec defines the desired state of a service.
|
||||||
@@ -147,12 +165,8 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Name != "" {
|
if s.Name != "" {
|
||||||
if len(s.Name) > 63 {
|
if err := ValidateServiceName(s.Name); err != nil {
|
||||||
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
|
return err
|
||||||
}
|
|
||||||
if !dnsLabelRegexp.MatchString(s.Name) {
|
|
||||||
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+
|
|
||||||
"and dashes only; must start and end with a letter or number", s.Name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
@@ -9,6 +10,59 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestServiceSpec_Validate_Name(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "empty allows generated name"},
|
||||||
|
{name: "single character", input: "a"},
|
||||||
|
{name: "single digit", input: "1"},
|
||||||
|
{name: "two characters", input: "a1"},
|
||||||
|
{name: "hyphens and digits", input: "1-web-2"},
|
||||||
|
{name: "consecutive hyphens", input: "web--1"},
|
||||||
|
{name: "maximum length", input: strings.Repeat("a", 63)},
|
||||||
|
{name: "maximum length with hyphens", input: "a" + strings.Repeat("-", 61) + "1"},
|
||||||
|
{name: "round-robin mode is a valid service name", input: "rr"},
|
||||||
|
{name: "nearest mode is a valid service name", input: "nearest"},
|
||||||
|
{name: "machine namespace prefix", input: "m-service"},
|
||||||
|
{name: "short hexadecimal name", input: strings.Repeat("a", 31)},
|
||||||
|
{name: "long hexadecimal name", input: strings.Repeat("a", 33)},
|
||||||
|
{name: "non-hexadecimal 32 characters", input: strings.Repeat("g", 32)},
|
||||||
|
{name: "too long", input: strings.Repeat("a", 64), wantErr: "must be 1-63 characters"},
|
||||||
|
{name: "uppercase", input: "WEB1", wantErr: "lowercase letters"},
|
||||||
|
{name: "leading hyphen", input: "-web", wantErr: "starting and ending"},
|
||||||
|
{name: "trailing hyphen", input: "web-", wantErr: "starting and ending"},
|
||||||
|
{name: "hyphen only", input: "-", wantErr: "starting and ending"},
|
||||||
|
{name: "underscore", input: "web_1", wantErr: "hyphens only"},
|
||||||
|
{name: "dot", input: "web.example", wantErr: "hyphens only"},
|
||||||
|
{name: "slash", input: "web/api", wantErr: "hyphens only"},
|
||||||
|
{name: "space", input: "web 1", wantErr: "hyphens only"},
|
||||||
|
{name: "leading whitespace", input: " web", wantErr: "hyphens only"},
|
||||||
|
{name: "trailing whitespace", input: "web ", wantErr: "hyphens only"},
|
||||||
|
{name: "tab", input: "web\t1", wantErr: "hyphens only"},
|
||||||
|
{name: "newline", input: "web\n", wantErr: "hyphens only"},
|
||||||
|
{name: "non-ASCII", input: "wéb", wantErr: "lowercase letters"},
|
||||||
|
{name: "machine DNS namespace", input: "m", wantErr: "reserved for the machine DNS namespace"},
|
||||||
|
{name: "service ID", input: "c337f00600de51ef4375c9a9a267dba5", wantErr: "service ID format"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
spec := ServiceSpec{Name: tt.input, Container: ContainerSpec{Image: "nginx:latest"}}
|
||||||
|
err := spec.Validate()
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
|
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// StoreVersion records the highest processed database version for each Corrosion actor UUID.
|
||||||
|
// A version can include changes that were superseded, so it does not guarantee that all data
|
||||||
|
// through that version is available locally.
|
||||||
|
type StoreVersion map[string]uint64
|
||||||
|
|
||||||
|
// MergeMax keeps the highest observed version for each actor in other.
|
||||||
|
// The receiver must be initialised before calling MergeMax.
|
||||||
|
func (v StoreVersion) MergeMax(other StoreVersion) {
|
||||||
|
for actor, version := range other {
|
||||||
|
v[actor] = max(v[actor], version)
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-23
@@ -1,15 +1,19 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/semver"
|
"github.com/Masterminds/semver"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/google/go-containerregistry/pkg/name"
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,12 +24,66 @@ const (
|
|||||||
|
|
||||||
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
||||||
|
|
||||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
// CaddyClient provides Caddy operations over its parent Client's connection.
|
||||||
|
// The parent client owns the connection and must be used to close it.
|
||||||
|
type CaddyClient struct {
|
||||||
|
// Storage provides low-level access to Caddy's cluster-backed storage.
|
||||||
|
Storage pb.CaddyStorageClient
|
||||||
|
grpc pb.CaddyClient
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaddyConfigOptions controls which machine's saved Caddy configuration is retrieved.
|
||||||
|
type CaddyConfigOptions struct {
|
||||||
|
// Machine is the machine name or ID. If empty, the configuration is retrieved from the machine the client
|
||||||
|
// is connected to.
|
||||||
|
Machine string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config retrieves the saved Caddy configuration from the machine selected by opts.
|
||||||
|
func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.CaddyConfig, error) {
|
||||||
|
if opts.Machine != "" {
|
||||||
|
ctx = ProxySingleMachineContext(ctx, opts.Machine)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.grpc.GetConfig(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return api.CaddyConfig{}, fmt.Errorf("get Caddy config: %w", err)
|
||||||
|
}
|
||||||
|
config := api.CaddyConfig{
|
||||||
|
Caddyfile: resp.Caddyfile,
|
||||||
|
LastReconciliationError: resp.LastReconciliationError,
|
||||||
|
}
|
||||||
|
if resp.ModifiedAt != nil {
|
||||||
|
if err := resp.ModifiedAt.CheckValid(); err != nil {
|
||||||
|
return api.CaddyConfig{}, fmt.Errorf("invalid Caddy config modification timestamp: %w", err)
|
||||||
|
}
|
||||||
|
config.ModifiedAt = resp.ModifiedAt.AsTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaddyDeploymentOptions configures a Caddy reverse proxy deployment.
|
||||||
|
type CaddyDeploymentOptions struct {
|
||||||
|
// Image defaults to the latest stable 2.x.x official Caddy image.
|
||||||
|
// Custom images must include curl and start Caddy with /etc/caddy/Caddyfile.
|
||||||
|
Image string
|
||||||
|
// Config contains an optional global Caddyfile.
|
||||||
|
Config string
|
||||||
|
Placement api.Placement
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||||
// version of the official Caddy Docker image is used.
|
// version of the official Caddy Docker image is used.
|
||||||
func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placement) (*deploy.Deployment, error) {
|
func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOptions) (*deploy.Deployment, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
image := opts.Image
|
||||||
if image == "" {
|
if image == "" {
|
||||||
latest, err := LatestCaddyImage()
|
latest, err := latestCaddyImage(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
||||||
}
|
}
|
||||||
@@ -35,17 +93,32 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
|
|||||||
|
|
||||||
spec := api.ServiceSpec{
|
spec := api.ServiceSpec{
|
||||||
Container: api.ContainerSpec{
|
Container: api.ContainerSpec{
|
||||||
Command: []string{"caddy", "run", "-c", "/config/Caddyfile"},
|
|
||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
"CADDY_ADMIN": "unix//run/caddy/admin.sock",
|
||||||
},
|
},
|
||||||
|
Healthcheck: &api.HealthcheckSpec{
|
||||||
|
Test: []string{
|
||||||
|
"CMD",
|
||||||
|
"curl", "-fsS",
|
||||||
|
"-o", "/dev/null",
|
||||||
|
"--unix-socket", "/run/caddy/admin.sock",
|
||||||
|
"http://localhost/config/",
|
||||||
|
},
|
||||||
|
Interval: 30 * time.Second,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Retries: 3,
|
||||||
|
StartPeriod: 10 * time.Second,
|
||||||
|
StartInterval: 1 * time.Second,
|
||||||
|
},
|
||||||
Image: image,
|
Image: image,
|
||||||
VolumeMounts: []api.VolumeMount{
|
VolumeMounts: []api.VolumeMount{
|
||||||
{
|
{
|
||||||
VolumeName: "data",
|
VolumeName: "data",
|
||||||
ContainerPath: "/config",
|
ContainerPath: "/etc/caddy",
|
||||||
|
ReadOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Keep local TLS assets persistent while Caddy uses the default file system storage.
|
||||||
VolumeName: "data",
|
VolumeName: "data",
|
||||||
ContainerPath: "/data",
|
ContainerPath: "/data",
|
||||||
},
|
},
|
||||||
@@ -62,7 +135,7 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
|
|||||||
},
|
},
|
||||||
Mode: api.ServiceModeGlobal,
|
Mode: api.ServiceModeGlobal,
|
||||||
Name: CaddyServiceName,
|
Name: CaddyServiceName,
|
||||||
Placement: placement,
|
Placement: opts.Placement,
|
||||||
Ports: []api.PortSpec{
|
Ports: []api.PortSpec{
|
||||||
{
|
{
|
||||||
PublishedPort: 80,
|
PublishedPort: 80,
|
||||||
@@ -113,28 +186,44 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if config != "" {
|
if opts.Config != "" {
|
||||||
spec.Caddy = &api.CaddySpec{
|
spec.Caddy = &api.CaddySpec{
|
||||||
Config: config,
|
Config: opts.Config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.NewDeployment(spec, nil), nil
|
return c.client.NewDeployment(spec, nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
||||||
// The latest image is determined by the latest version tag 2.x.x.
|
// The latest image is determined by the latest version tag 2.x.x.
|
||||||
func LatestCaddyImage() (reference.NamedTagged, error) {
|
func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
repo, err := name.NewRepository(CaddyImage)
|
repo, err := name.NewRepository(CaddyImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse image: %w", err)
|
return nil, fmt.Errorf("parse image: %w", err)
|
||||||
}
|
}
|
||||||
tags, err := remote.List(repo)
|
tags, err := remote.List(repo, remote.WithContext(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list image tags: %w", err)
|
return nil, fmt.Errorf("list image tags: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to the 'latest' tag but try to find the latest version tag 2.x.x.
|
image, err := reference.ParseDockerRef(CaddyImage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse image: %w", err)
|
||||||
|
}
|
||||||
|
imageWithTag, err := reference.WithTag(image, latestCaddyTag(tags))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("set image tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageWithTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestCaddyTag selects the newest stable 2.x.x tag, falling back to latest.
|
||||||
|
func latestCaddyTag(tags []string) string {
|
||||||
latestTag := "latest"
|
latestTag := "latest"
|
||||||
var latestVersion *semver.Version
|
var latestVersion *semver.Version
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
@@ -152,14 +241,5 @@ func LatestCaddyImage() (reference.NamedTagged, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
image, err := reference.ParseDockerRef(CaddyImage)
|
return latestTag
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse image: %w", err)
|
|
||||||
}
|
|
||||||
imageWithTag, err := reference.WithTag(image, latestTag)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("set image tag: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageWithTag, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaddyListCertificatesOptions controls which machine's certificate storage replica is queried.
|
||||||
|
type CaddyListCertificatesOptions struct {
|
||||||
|
// Machine is the machine name or ID. If empty, the machine the client is connected to is used.
|
||||||
|
Machine string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCertificates lists issued certificates from the Caddy's managed certificate storage backed by the distributed
|
||||||
|
// cluster store. It doesn't verify trust or whether Caddy serves them and may include expired certificates.
|
||||||
|
// Private key assets are never read or returned.
|
||||||
|
//
|
||||||
|
// This is a non-atomic inventory of one store replica. It does not wait for replication.
|
||||||
|
// It returns parsing errors as a combined error, but still returns successfully read certificates regardless
|
||||||
|
// of errors.
|
||||||
|
func (c *CaddyClient) ListCertificates(ctx context.Context, opts CaddyListCertificatesOptions) ([]api.IssuedCertificate, error) {
|
||||||
|
if opts.Machine != "" {
|
||||||
|
ctx = ProxySingleMachineContext(ctx, opts.Machine)
|
||||||
|
}
|
||||||
|
resp, err := c.Storage.ListCertificates(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var certs []api.IssuedCertificate
|
||||||
|
var errs []error
|
||||||
|
for _, p := range resp.Certificates {
|
||||||
|
if cert, err := api.IssuedCertificateFromProto(p); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
} else {
|
||||||
|
certs = append(certs, cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return certs, errors.Join(errs...)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
@@ -11,8 +12,29 @@ import (
|
|||||||
func TestLatestCaddyImage(t *testing.T) {
|
func TestLatestCaddyImage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
image, err := LatestCaddyImage()
|
image, err := latestCaddyImage(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLatestCaddyTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
tags []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", want: "latest"},
|
||||||
|
{name: "semantic ordering", tags: []string{"2.9.9", "2.11.4", "2.10.0", "2.11.3"}, want: "2.11.4"},
|
||||||
|
{name: "ignore other versions and variants", tags: []string{
|
||||||
|
"latest", "1.0.0", "3.0.0", "2.12.0-rc.1", "2.12.0-alpine", "2.12", "v2.12.0", "2.11.4",
|
||||||
|
}, want: "2.11.4"},
|
||||||
|
{name: "no stable version", tags: []string{"builder", "2.12.0-beta.1"}, want: "latest"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, latestCaddyTag(tt.tags))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ type Client struct {
|
|||||||
// Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client.
|
// Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client.
|
||||||
pb.MachineClient
|
pb.MachineClient
|
||||||
pb.ClusterClient
|
pb.ClusterClient
|
||||||
Caddy pb.CaddyClient
|
// Caddy provides Caddy configuration, deployment, and certificate storage operations.
|
||||||
CaddyStorage pb.CaddyStorageClient
|
Caddy *CaddyClient
|
||||||
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
|
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
|
||||||
// from generic Docker operations.
|
// from generic Docker operations.
|
||||||
Docker *docker.Client
|
Docker *docker.Client
|
||||||
@@ -58,8 +58,11 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
|
|||||||
|
|
||||||
c.MachineClient = pb.NewMachineClient(c.conn)
|
c.MachineClient = pb.NewMachineClient(c.conn)
|
||||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||||
c.Caddy = pb.NewCaddyClient(c.conn)
|
c.Caddy = &CaddyClient{
|
||||||
c.CaddyStorage = pb.NewCaddyStorageClient(c.conn)
|
Storage: pb.NewCaddyStorageClient(c.conn),
|
||||||
|
grpc: pb.NewCaddyClient(c.conn),
|
||||||
|
client: c,
|
||||||
|
}
|
||||||
c.Docker = docker.NewClient(c.conn)
|
c.Docker = docker.NewClient(c.conn)
|
||||||
c.leases = distlockgrpc.NewLeaseClient(c.conn)
|
c.leases = distlockgrpc.NewLeaseClient(c.conn)
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ func (cli *Client) progressOut() *streams.Out {
|
|||||||
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
// ProxyMachinesContext returns a new context that proxies gRPC requests to the specified machines.
|
||||||
// If namesOrIDs is nil or empty, all machines are included.
|
// If namesOrIDs is nil or empty, all machines are included.
|
||||||
// This triggers One2Many proxying, which always injects metadata into the response.
|
// This triggers One2Many proxying, which always injects metadata into the response.
|
||||||
|
// TODO: validate namesOrIDs can't contain "*" as this is reserved as internal value and may break the caller logic.
|
||||||
func ProxyMachinesContext(ctx context.Context, namesOrIDs []string) context.Context {
|
func ProxyMachinesContext(ctx context.Context, namesOrIDs []string) context.Context {
|
||||||
md := outgoingMetadataWithoutProxyTargets(ctx)
|
md := outgoingMetadataWithoutProxyTargets(ctx)
|
||||||
if len(namesOrIDs) == 0 {
|
if len(namesOrIDs) == 0 {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy"
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plan holds the compose-level deployment plan with volume and service operations.
|
// Plan holds the compose-level deployment plan with volume and service operations.
|
||||||
|
|||||||
@@ -89,40 +89,53 @@ func ResolveSecrets(ctx context.Context, project *types.Project) error {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve only secret references set as values for environment variables in enabled services.
|
|
||||||
for _, service := range project.Services {
|
for _, service := range project.Services {
|
||||||
for k, v := range service.Environment {
|
// Resolve secret references set as values for environment variables in the service.
|
||||||
if v == nil {
|
if err := resolveEnvSecrets(service.Environment, resolve); err != nil {
|
||||||
continue
|
return err
|
||||||
}
|
}
|
||||||
secretName, ok := secretRefName(*v)
|
|
||||||
if !ok {
|
// Resolve secret references set as values for the pre-deploy hook's environment variables, if any.
|
||||||
continue
|
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
|
||||||
}
|
if err := resolveEnvSecrets(hook.Environment, resolve); err != nil {
|
||||||
value, err := resolve(secretName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
service.Environment[k] = &value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCommandSecretRefs reports whether any service environment references a secret that is resolved by running
|
// resolveEnvSecrets resolves 'secret://name' references found as values in env to actual secret values using
|
||||||
// a command.
|
// resolve, setting each referenced variable to the secret's value in place. Variables that are not secret
|
||||||
|
// references are left untouched.
|
||||||
|
func resolveEnvSecrets(env types.MappingWithEquals, resolve func(string) (string, error)) error {
|
||||||
|
for k, v := range env {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secretName, ok := secretRefName(*v)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := resolve(secretName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env[k] = &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCommandSecretRefs reports whether any service environment, including a pre-deploy hook's environment,
|
||||||
|
// references a secret that is resolved by running a command.
|
||||||
func HasCommandSecretRefs(project *types.Project) bool {
|
func HasCommandSecretRefs(project *types.Project) bool {
|
||||||
for _, service := range project.Services {
|
for _, service := range project.Services {
|
||||||
for _, v := range service.Environment {
|
if envHasCommandSecretRef(service.Environment, project) {
|
||||||
if v == nil {
|
return true
|
||||||
continue
|
}
|
||||||
}
|
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
|
||||||
name, ok := secretRefName(*v)
|
if envHasCommandSecretRef(hook.Environment, project) {
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +143,24 @@ func HasCommandSecretRefs(project *types.Project) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envHasCommandSecretRef reports whether env contains a 'secret://name' reference to a secret resolved
|
||||||
|
// by running a command.
|
||||||
|
func envHasCommandSecretRef(env types.MappingWithEquals, project *types.Project) bool {
|
||||||
|
for _, v := range env {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, ok := secretRefName(*v)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if secret, ok := project.Secrets[name]; ok && secret.Driver == secretExecDriver {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// secretValue resolves a secret to its value depending on its source: an 'exec' driver command,
|
// secretValue resolves a secret to its value depending on its source: an 'exec' driver command,
|
||||||
// an environment variable, or a file. A single trailing newline ('\n' or '\r\n') is stripped from the command
|
// an environment variable, or a file. A single trailing newline ('\n' or '\r\n') is stripped from the command
|
||||||
// output as command-line tools commonly append one. All other whitespace is kept. Environment and file values are
|
// output as command-line tools commonly append one. All other whitespace is kept. Environment and file values are
|
||||||
|
|||||||
@@ -328,6 +328,74 @@ secrets:
|
|||||||
assert.Equal(t, "run\n", string(runs), "command should run exactly once across services and repeated resolutions")
|
assert.Equal(t, "run\n", string(runs), "command should run exactly once across services and repeated resolutions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResolveSecrets_PreDeployHook pins the behaviour that 'secret://name' references in the pre-deploy hook's
|
||||||
|
// environment are resolved just like references in the service's own environment.
|
||||||
|
// See https://github.com/psviderski/uncloud/issues/422.
|
||||||
|
func TestResolveSecrets_PreDeployHook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := `
|
||||||
|
services:
|
||||||
|
foo:
|
||||||
|
image: foo
|
||||||
|
environment:
|
||||||
|
CONTROL_TOKEN: secret://control_token
|
||||||
|
x-pre_deploy:
|
||||||
|
command: ["./migrate.sh"]
|
||||||
|
environment:
|
||||||
|
HOOK_TOKEN: secret://hook_token
|
||||||
|
PLAIN: hello
|
||||||
|
secrets:
|
||||||
|
control_token:
|
||||||
|
x-command: printf control-value
|
||||||
|
hook_token:
|
||||||
|
x-command: printf hook-value
|
||||||
|
`
|
||||||
|
project := loadProject(t, content)
|
||||||
|
require.NoError(t, ResolveSecrets(context.Background(), project))
|
||||||
|
|
||||||
|
assert.Equal(t, env(map[string]string{"CONTROL_TOKEN": "control-value"}), project.Services["foo"].Environment)
|
||||||
|
|
||||||
|
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
|
||||||
|
require.True(t, ok, "x-pre_deploy extension not found")
|
||||||
|
assert.Equal(t, env(map[string]string{"HOOK_TOKEN": "hook-value", "PLAIN": "hello"}), hook.Environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveSecrets_PreDeployHook_ResolvedOnce verifies that a secret referenced from both the service's
|
||||||
|
// environment and its pre-deploy hook's environment is only resolved once.
|
||||||
|
func TestResolveSecrets_PreDeployHook_ResolvedOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
counter := filepath.Join(t.TempDir(), "runs")
|
||||||
|
content := fmt.Sprintf(`
|
||||||
|
services:
|
||||||
|
foo:
|
||||||
|
image: foo
|
||||||
|
environment:
|
||||||
|
TOKEN: secret://token
|
||||||
|
x-pre_deploy:
|
||||||
|
command: ["./migrate.sh"]
|
||||||
|
environment:
|
||||||
|
TOKEN: secret://token
|
||||||
|
secrets:
|
||||||
|
token:
|
||||||
|
x-command: "sh -c 'echo run >> %s; printf abc'"
|
||||||
|
`, counter)
|
||||||
|
|
||||||
|
project := loadProject(t, content)
|
||||||
|
require.NoError(t, ResolveSecrets(context.Background(), project))
|
||||||
|
|
||||||
|
assert.Equal(t, "abc", *project.Services["foo"].Environment["TOKEN"])
|
||||||
|
hook, ok := project.Services["foo"].Extensions[PreDeployHookExtensionKey].(PreDeployHook)
|
||||||
|
require.True(t, ok, "x-pre_deploy extension not found")
|
||||||
|
assert.Equal(t, "abc", *hook.Environment["TOKEN"])
|
||||||
|
|
||||||
|
runs, err := os.ReadFile(counter)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "run\n", string(runs),
|
||||||
|
"command should run exactly once across the service and hook environments")
|
||||||
|
}
|
||||||
|
|
||||||
func TestHasCommandSecretRefs(t *testing.T) {
|
func TestHasCommandSecretRefs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -387,6 +455,22 @@ services:
|
|||||||
`,
|
`,
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "command secret referenced from pre-deploy hook",
|
||||||
|
content: `
|
||||||
|
services:
|
||||||
|
foo:
|
||||||
|
image: foo
|
||||||
|
x-pre_deploy:
|
||||||
|
command: ["./migrate.sh"]
|
||||||
|
environment:
|
||||||
|
TOKEN: secret://token
|
||||||
|
secrets:
|
||||||
|
token:
|
||||||
|
x-command: printf abc
|
||||||
|
`,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
|
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/operation"
|
|
||||||
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/operation"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+82
-3
@@ -23,9 +23,11 @@ import (
|
|||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/psviderski/uncloud/api/pb"
|
"github.com/psviderski/uncloud/api/pb"
|
||||||
|
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||||
"github.com/psviderski/uncloud/internal/docker"
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
"github.com/psviderski/uncloud/internal/machine/constants"
|
"github.com/psviderski/uncloud/internal/machine/constants"
|
||||||
@@ -34,6 +36,8 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
netproxy "golang.org/x/net/proxy"
|
netproxy "golang.org/x/net/proxy"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This is the container image used to run socat proxy containers.
|
// This is the container image used to run socat proxy containers.
|
||||||
@@ -61,7 +65,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
|
|||||||
// Broadcast the image list request to the specified machines or all machines if none specified.
|
// Broadcast the image list request to the specified machines or all machines if none specified.
|
||||||
listCtx := ProxyMachinesContext(ctx, filter.Machines)
|
listCtx := ProxyMachinesContext(ctx, filter.Machines)
|
||||||
|
|
||||||
opts := image.ListOptions{Manifests: true}
|
opts := image.ListOptions{All: true, Manifests: true}
|
||||||
if filter.Name != "" {
|
if filter.Name != "" {
|
||||||
opts.Filters = filters.NewArgs(
|
opts.Filters = filters.NewArgs(
|
||||||
filters.Arg("reference", filter.Name),
|
filters.Arg("reference", filter.Name),
|
||||||
@@ -92,7 +96,7 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
|
|||||||
ContainerdStore: msg.ContainerdStore,
|
ContainerdStore: msg.ContainerdStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Metadata.Error == "" && len(msg.Images) > 0 {
|
if msg.Metadata.Err() == nil && len(msg.Images) > 0 {
|
||||||
if err = json.Unmarshal(msg.Images, &mi.Images); err != nil {
|
if err = json.Unmarshal(msg.Images, &mi.Images); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal images: %w", err)
|
return nil, fmt.Errorf("unmarshal images: %w", err)
|
||||||
}
|
}
|
||||||
@@ -104,6 +108,81 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap
|
|||||||
return machineImages, nil
|
return machineImages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveImage removes one exact image name or ID on the selected machines with protection of used references.
|
||||||
|
// Results include the outcome of removing an image on each machine.
|
||||||
|
//
|
||||||
|
// An absent image on some machines is normal if removal succeeds on at least one machine. If every selected machine
|
||||||
|
// reports NotFound, the returned error is api.ErrNotFound. Other machine failures are combined, with successful
|
||||||
|
// results still returned.
|
||||||
|
func (cli *Client) RemoveImage(
|
||||||
|
ctx context.Context, imageNameOrID string, opts api.RemoveImageOptions,
|
||||||
|
) ([]api.MachineImageRemoval, error) {
|
||||||
|
if strings.TrimSpace(imageNameOrID) == "" {
|
||||||
|
return nil, errors.New("image name or ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cli.Docker.GRPCClient.RemoveImage(ProxyMachinesContext(ctx, opts.Machines),
|
||||||
|
&pb.RemoveImageRequest{Id: imageNameOrID, PruneChildren: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := progress.ContextWriter(ctx)
|
||||||
|
var results []api.MachineImageRemoval
|
||||||
|
var errs []error
|
||||||
|
notFoundCount := 0
|
||||||
|
for _, msg := range resp.Messages {
|
||||||
|
result := api.MachineImageRemoval{Metadata: msg.Metadata}
|
||||||
|
for _, removal := range msg.Results {
|
||||||
|
result.Results = append(result.Results, image.DeleteResponse{
|
||||||
|
Deleted: removal.Deleted,
|
||||||
|
Untagged: removal.Untagged,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
|
||||||
|
eventID := cliprogress.ImageEventID(imageNameOrID, msg.Metadata.MachineName)
|
||||||
|
if err = result.Metadata.Err(); err != nil {
|
||||||
|
event := progress.NewEvent(eventID, progress.Done, "Not found")
|
||||||
|
if status.Code(err) == codes.NotFound {
|
||||||
|
notFoundCount++
|
||||||
|
} else {
|
||||||
|
errs = append(errs, fmt.Errorf("machine '%s': %w", msg.Metadata.MachineName, err))
|
||||||
|
event.Status = progress.Error
|
||||||
|
event.StatusText = status.Convert(err).Message()
|
||||||
|
}
|
||||||
|
pw.Event(event)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unnamed image can return Deleted without Untagged. Use the requested name or ID as its row fallback.
|
||||||
|
deleted := false
|
||||||
|
for _, removal := range result.Results {
|
||||||
|
if removal.Untagged != "" {
|
||||||
|
eventID = cliprogress.ImageEventID(removal.Untagged, msg.Metadata.MachineName)
|
||||||
|
pw.Event(progress.NewEvent(eventID, progress.Done, "Untagged"))
|
||||||
|
}
|
||||||
|
if removal.Deleted != "" && !deleted {
|
||||||
|
// Docker reports the deletion of the selected image before any pruned parents or released layers.
|
||||||
|
// Only that first deletion updates the last untagged row marking the image as Removed.
|
||||||
|
deleted = true
|
||||||
|
pw.Event(progress.NewEvent(eventID, progress.Done,
|
||||||
|
fmt.Sprintf("Removed (%s)", stringid.TruncateID(removal.Deleted))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = errors.Join(errs...); err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
if notFoundCount == len(results) {
|
||||||
|
return results, api.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: move PushImageOptions to api package and include PushImage in ImageClient interface.
|
||||||
type PushImageOptions struct {
|
type PushImageOptions struct {
|
||||||
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
|
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
|
||||||
AllMachines bool
|
AllMachines bool
|
||||||
@@ -215,7 +294,7 @@ func (cli *Client) pushImageToMachine(
|
|||||||
Name: "%invalid-name-to-only-check-store-type%",
|
Name: "%invalid-name-to-only-check-store-type%",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = images[0].Error()
|
err = images[0].Metadata.Err()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
|
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user