merge: push mirror adapters into feat/push-mirror
8 files · +1406 -0
A gen/ermite/storage/mirror.pb.go +577 -0
| @@ -0,0 +1,577 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |
| 4 | + | // versions: | |
| 5 | + | // protoc-gen-go v1.36.12 | |
| 6 | + | // protoc (unknown) | |
| 7 | + | // source: ermite/storage/mirror.proto | |
| 8 | + | ||
| 9 | + | package storagepb | |
| 10 | + | ||
| 11 | + | import ( | |
| 12 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |
| 13 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |
| 14 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |
| 15 | + | reflect "reflect" | |
| 16 | + | sync "sync" | |
| 17 | + | unsafe "unsafe" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | // Verify that this generated code is sufficiently up-to-date. | |
| 22 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |
| 23 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |
| 24 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | type MirrorKey struct { | |
| 28 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 29 | + | Sealed []byte `protobuf:"bytes,1,opt,name=sealed,proto3" json:"sealed,omitempty"` | |
| 30 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 31 | + | unknownFields protoimpl.UnknownFields | |
| 32 | + | sizeCache protoimpl.SizeCache | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (x *MirrorKey) Reset() { | |
| 36 | + | *x = MirrorKey{} | |
| 37 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[0] | |
| 38 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 39 | + | ms.StoreMessageInfo(mi) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (x *MirrorKey) String() string { | |
| 43 | + | return protoimpl.X.MessageStringOf(x) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (*MirrorKey) ProtoMessage() {} | |
| 47 | + | ||
| 48 | + | func (x *MirrorKey) ProtoReflect() protoreflect.Message { | |
| 49 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[0] | |
| 50 | + | if x != nil { | |
| 51 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 52 | + | if ms.LoadMessageInfo() == nil { | |
| 53 | + | ms.StoreMessageInfo(mi) | |
| 54 | + | } | |
| 55 | + | return ms | |
| 56 | + | } | |
| 57 | + | return mi.MessageOf(x) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Deprecated: Use MirrorKey.ProtoReflect.Descriptor instead. | |
| 61 | + | func (*MirrorKey) Descriptor() ([]byte, []int) { | |
| 62 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{0} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (x *MirrorKey) GetSealed() []byte { | |
| 66 | + | if x != nil { | |
| 67 | + | return x.Sealed | |
| 68 | + | } | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (x *MirrorKey) GetCreatedAt() *timestamppb.Timestamp { | |
| 73 | + | if x != nil { | |
| 74 | + | return x.CreatedAt | |
| 75 | + | } | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type MirrorGlobal struct { | |
| 80 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 81 | + | Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` | |
| 82 | + | UrlTemplate string `protobuf:"bytes,2,opt,name=url_template,json=urlTemplate,proto3" json:"url_template,omitempty"` | |
| 83 | + | Cron string `protobuf:"bytes,3,opt,name=cron,proto3" json:"cron,omitempty"` | |
| 84 | + | AllBranches bool `protobuf:"varint,4,opt,name=all_branches,json=allBranches,proto3" json:"all_branches,omitempty"` | |
| 85 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 86 | + | unknownFields protoimpl.UnknownFields | |
| 87 | + | sizeCache protoimpl.SizeCache | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (x *MirrorGlobal) Reset() { | |
| 91 | + | *x = MirrorGlobal{} | |
| 92 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[1] | |
| 93 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 94 | + | ms.StoreMessageInfo(mi) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (x *MirrorGlobal) String() string { | |
| 98 | + | return protoimpl.X.MessageStringOf(x) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (*MirrorGlobal) ProtoMessage() {} | |
| 102 | + | ||
| 103 | + | func (x *MirrorGlobal) ProtoReflect() protoreflect.Message { | |
| 104 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[1] | |
| 105 | + | if x != nil { | |
| 106 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 107 | + | if ms.LoadMessageInfo() == nil { | |
| 108 | + | ms.StoreMessageInfo(mi) | |
| 109 | + | } | |
| 110 | + | return ms | |
| 111 | + | } | |
| 112 | + | return mi.MessageOf(x) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Deprecated: Use MirrorGlobal.ProtoReflect.Descriptor instead. | |
| 116 | + | func (*MirrorGlobal) Descriptor() ([]byte, []int) { | |
| 117 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{1} | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (x *MirrorGlobal) GetEnabled() bool { | |
| 121 | + | if x != nil { | |
| 122 | + | return x.Enabled | |
| 123 | + | } | |
| 124 | + | return false | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (x *MirrorGlobal) GetUrlTemplate() string { | |
| 128 | + | if x != nil { | |
| 129 | + | return x.UrlTemplate | |
| 130 | + | } | |
| 131 | + | return "" | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (x *MirrorGlobal) GetCron() string { | |
| 135 | + | if x != nil { | |
| 136 | + | return x.Cron | |
| 137 | + | } | |
| 138 | + | return "" | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (x *MirrorGlobal) GetAllBranches() bool { | |
| 142 | + | if x != nil { | |
| 143 | + | return x.AllBranches | |
| 144 | + | } | |
| 145 | + | return false | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (x *MirrorGlobal) GetUpdatedAt() *timestamppb.Timestamp { | |
| 149 | + | if x != nil { | |
| 150 | + | return x.UpdatedAt | |
| 151 | + | } | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | type MirrorRepo struct { | |
| 156 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 157 | + | Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` | |
| 158 | + | Cron string `protobuf:"bytes,2,opt,name=cron,proto3" json:"cron,omitempty"` | |
| 159 | + | AllBranches *bool `protobuf:"varint,3,opt,name=all_branches,json=allBranches,proto3,oneof" json:"all_branches,omitempty"` | |
| 160 | + | Excluded bool `protobuf:"varint,4,opt,name=excluded,proto3" json:"excluded,omitempty"` | |
| 161 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 162 | + | unknownFields protoimpl.UnknownFields | |
| 163 | + | sizeCache protoimpl.SizeCache | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (x *MirrorRepo) Reset() { | |
| 167 | + | *x = MirrorRepo{} | |
| 168 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[2] | |
| 169 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 170 | + | ms.StoreMessageInfo(mi) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (x *MirrorRepo) String() string { | |
| 174 | + | return protoimpl.X.MessageStringOf(x) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (*MirrorRepo) ProtoMessage() {} | |
| 178 | + | ||
| 179 | + | func (x *MirrorRepo) ProtoReflect() protoreflect.Message { | |
| 180 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[2] | |
| 181 | + | if x != nil { | |
| 182 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 183 | + | if ms.LoadMessageInfo() == nil { | |
| 184 | + | ms.StoreMessageInfo(mi) | |
| 185 | + | } | |
| 186 | + | return ms | |
| 187 | + | } | |
| 188 | + | return mi.MessageOf(x) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Deprecated: Use MirrorRepo.ProtoReflect.Descriptor instead. | |
| 192 | + | func (*MirrorRepo) Descriptor() ([]byte, []int) { | |
| 193 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{2} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (x *MirrorRepo) GetUrl() string { | |
| 197 | + | if x != nil { | |
| 198 | + | return x.Url | |
| 199 | + | } | |
| 200 | + | return "" | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (x *MirrorRepo) GetCron() string { | |
| 204 | + | if x != nil { | |
| 205 | + | return x.Cron | |
| 206 | + | } | |
| 207 | + | return "" | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (x *MirrorRepo) GetAllBranches() bool { | |
| 211 | + | if x != nil && x.AllBranches != nil { | |
| 212 | + | return *x.AllBranches | |
| 213 | + | } | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (x *MirrorRepo) GetExcluded() bool { | |
| 218 | + | if x != nil { | |
| 219 | + | return x.Excluded | |
| 220 | + | } | |
| 221 | + | return false | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (x *MirrorRepo) GetUpdatedAt() *timestamppb.Timestamp { | |
| 225 | + | if x != nil { | |
| 226 | + | return x.UpdatedAt | |
| 227 | + | } | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | type MirrorState struct { | |
| 232 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 233 | + | State string `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` | |
| 234 | + | Class string `protobuf:"bytes,2,opt,name=class,proto3" json:"class,omitempty"` | |
| 235 | + | Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` | |
| 236 | + | Detail string `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` | |
| 237 | + | ConsecutiveFailures int32 `protobuf:"varint,5,opt,name=consecutive_failures,json=consecutiveFailures,proto3" json:"consecutive_failures,omitempty"` | |
| 238 | + | LastAttemptAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=last_attempt_at,json=lastAttemptAt,proto3" json:"last_attempt_at,omitempty"` | |
| 239 | + | LastSuccessAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_success_at,json=lastSuccessAt,proto3" json:"last_success_at,omitempty"` | |
| 240 | + | NextRetryAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=next_retry_at,json=nextRetryAt,proto3" json:"next_retry_at,omitempty"` | |
| 241 | + | PausedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=paused_at,json=pausedAt,proto3" json:"paused_at,omitempty"` | |
| 242 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 243 | + | unknownFields protoimpl.UnknownFields | |
| 244 | + | sizeCache protoimpl.SizeCache | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func (x *MirrorState) Reset() { | |
| 248 | + | *x = MirrorState{} | |
| 249 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[3] | |
| 250 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 251 | + | ms.StoreMessageInfo(mi) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (x *MirrorState) String() string { | |
| 255 | + | return protoimpl.X.MessageStringOf(x) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (*MirrorState) ProtoMessage() {} | |
| 259 | + | ||
| 260 | + | func (x *MirrorState) ProtoReflect() protoreflect.Message { | |
| 261 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[3] | |
| 262 | + | if x != nil { | |
| 263 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 264 | + | if ms.LoadMessageInfo() == nil { | |
| 265 | + | ms.StoreMessageInfo(mi) | |
| 266 | + | } | |
| 267 | + | return ms | |
| 268 | + | } | |
| 269 | + | return mi.MessageOf(x) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Deprecated: Use MirrorState.ProtoReflect.Descriptor instead. | |
| 273 | + | func (*MirrorState) Descriptor() ([]byte, []int) { | |
| 274 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{3} | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (x *MirrorState) GetState() string { | |
| 278 | + | if x != nil { | |
| 279 | + | return x.State | |
| 280 | + | } | |
| 281 | + | return "" | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (x *MirrorState) GetClass() string { | |
| 285 | + | if x != nil { | |
| 286 | + | return x.Class | |
| 287 | + | } | |
| 288 | + | return "" | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (x *MirrorState) GetReason() string { | |
| 292 | + | if x != nil { | |
| 293 | + | return x.Reason | |
| 294 | + | } | |
| 295 | + | return "" | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (x *MirrorState) GetDetail() string { | |
| 299 | + | if x != nil { | |
| 300 | + | return x.Detail | |
| 301 | + | } | |
| 302 | + | return "" | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func (x *MirrorState) GetConsecutiveFailures() int32 { | |
| 306 | + | if x != nil { | |
| 307 | + | return x.ConsecutiveFailures | |
| 308 | + | } | |
| 309 | + | return 0 | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (x *MirrorState) GetLastAttemptAt() *timestamppb.Timestamp { | |
| 313 | + | if x != nil { | |
| 314 | + | return x.LastAttemptAt | |
| 315 | + | } | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (x *MirrorState) GetLastSuccessAt() *timestamppb.Timestamp { | |
| 320 | + | if x != nil { | |
| 321 | + | return x.LastSuccessAt | |
| 322 | + | } | |
| 323 | + | return nil | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (x *MirrorState) GetNextRetryAt() *timestamppb.Timestamp { | |
| 327 | + | if x != nil { | |
| 328 | + | return x.NextRetryAt | |
| 329 | + | } | |
| 330 | + | return nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (x *MirrorState) GetPausedAt() *timestamppb.Timestamp { | |
| 334 | + | if x != nil { | |
| 335 | + | return x.PausedAt | |
| 336 | + | } | |
| 337 | + | return nil | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func (x *MirrorState) GetUpdatedAt() *timestamppb.Timestamp { | |
| 341 | + | if x != nil { | |
| 342 | + | return x.UpdatedAt | |
| 343 | + | } | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | type MirrorSnapshotRef struct { | |
| 348 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 349 | + | Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` | |
| 350 | + | Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` | |
| 351 | + | unknownFields protoimpl.UnknownFields | |
| 352 | + | sizeCache protoimpl.SizeCache | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func (x *MirrorSnapshotRef) Reset() { | |
| 356 | + | *x = MirrorSnapshotRef{} | |
| 357 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[4] | |
| 358 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 359 | + | ms.StoreMessageInfo(mi) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (x *MirrorSnapshotRef) String() string { | |
| 363 | + | return protoimpl.X.MessageStringOf(x) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func (*MirrorSnapshotRef) ProtoMessage() {} | |
| 367 | + | ||
| 368 | + | func (x *MirrorSnapshotRef) ProtoReflect() protoreflect.Message { | |
| 369 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[4] | |
| 370 | + | if x != nil { | |
| 371 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 372 | + | if ms.LoadMessageInfo() == nil { | |
| 373 | + | ms.StoreMessageInfo(mi) | |
| 374 | + | } | |
| 375 | + | return ms | |
| 376 | + | } | |
| 377 | + | return mi.MessageOf(x) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // Deprecated: Use MirrorSnapshotRef.ProtoReflect.Descriptor instead. | |
| 381 | + | func (*MirrorSnapshotRef) Descriptor() ([]byte, []int) { | |
| 382 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{4} | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (x *MirrorSnapshotRef) GetName() string { | |
| 386 | + | if x != nil { | |
| 387 | + | return x.Name | |
| 388 | + | } | |
| 389 | + | return "" | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (x *MirrorSnapshotRef) GetId() string { | |
| 393 | + | if x != nil { | |
| 394 | + | return x.Id | |
| 395 | + | } | |
| 396 | + | return "" | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | type MirrorSnapshot struct { | |
| 400 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 401 | + | Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` | |
| 402 | + | Refs []*MirrorSnapshotRef `protobuf:"bytes,2,rep,name=refs,proto3" json:"refs,omitempty"` | |
| 403 | + | AllBranches bool `protobuf:"varint,3,opt,name=all_branches,json=allBranches,proto3" json:"all_branches,omitempty"` | |
| 404 | + | At *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=at,proto3" json:"at,omitempty"` | |
| 405 | + | unknownFields protoimpl.UnknownFields | |
| 406 | + | sizeCache protoimpl.SizeCache | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (x *MirrorSnapshot) Reset() { | |
| 410 | + | *x = MirrorSnapshot{} | |
| 411 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[5] | |
| 412 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 413 | + | ms.StoreMessageInfo(mi) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func (x *MirrorSnapshot) String() string { | |
| 417 | + | return protoimpl.X.MessageStringOf(x) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func (*MirrorSnapshot) ProtoMessage() {} | |
| 421 | + | ||
| 422 | + | func (x *MirrorSnapshot) ProtoReflect() protoreflect.Message { | |
| 423 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[5] | |
| 424 | + | if x != nil { | |
| 425 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 426 | + | if ms.LoadMessageInfo() == nil { | |
| 427 | + | ms.StoreMessageInfo(mi) | |
| 428 | + | } | |
| 429 | + | return ms | |
| 430 | + | } | |
| 431 | + | return mi.MessageOf(x) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // Deprecated: Use MirrorSnapshot.ProtoReflect.Descriptor instead. | |
| 435 | + | func (*MirrorSnapshot) Descriptor() ([]byte, []int) { | |
| 436 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{5} | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func (x *MirrorSnapshot) GetUrl() string { | |
| 440 | + | if x != nil { | |
| 441 | + | return x.Url | |
| 442 | + | } | |
| 443 | + | return "" | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func (x *MirrorSnapshot) GetRefs() []*MirrorSnapshotRef { | |
| 447 | + | if x != nil { | |
| 448 | + | return x.Refs | |
| 449 | + | } | |
| 450 | + | return nil | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (x *MirrorSnapshot) GetAllBranches() bool { | |
| 454 | + | if x != nil { | |
| 455 | + | return x.AllBranches | |
| 456 | + | } | |
| 457 | + | return false | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func (x *MirrorSnapshot) GetAt() *timestamppb.Timestamp { | |
| 461 | + | if x != nil { | |
| 462 | + | return x.At | |
| 463 | + | } | |
| 464 | + | return nil | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | var File_ermite_storage_mirror_proto protoreflect.FileDescriptor | |
| 468 | + | ||
| 469 | + | const file_ermite_storage_mirror_proto_rawDesc = "" + | |
| 470 | + | "\n" + | |
| 471 | + | "\x1bermite/storage/mirror.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"^\n" + | |
| 472 | + | "\tMirrorKey\x12\x16\n" + | |
| 473 | + | "\x06sealed\x18\x01 \x01(\fR\x06sealed\x129\n" + | |
| 474 | + | "\n" + | |
| 475 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xbd\x01\n" + | |
| 476 | + | "\fMirrorGlobal\x12\x18\n" + | |
| 477 | + | "\aenabled\x18\x01 \x01(\bR\aenabled\x12!\n" + | |
| 478 | + | "\furl_template\x18\x02 \x01(\tR\vurlTemplate\x12\x12\n" + | |
| 479 | + | "\x04cron\x18\x03 \x01(\tR\x04cron\x12!\n" + | |
| 480 | + | "\fall_branches\x18\x04 \x01(\bR\vallBranches\x129\n" + | |
| 481 | + | "\n" + | |
| 482 | + | "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\xc2\x01\n" + | |
| 483 | + | "\n" + | |
| 484 | + | "MirrorRepo\x12\x10\n" + | |
| 485 | + | "\x03url\x18\x01 \x01(\tR\x03url\x12\x12\n" + | |
| 486 | + | "\x04cron\x18\x02 \x01(\tR\x04cron\x12&\n" + | |
| 487 | + | "\fall_branches\x18\x03 \x01(\bH\x00R\vallBranches\x88\x01\x01\x12\x1a\n" + | |
| 488 | + | "\bexcluded\x18\x04 \x01(\bR\bexcluded\x129\n" + | |
| 489 | + | "\n" + | |
| 490 | + | "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\x0f\n" + | |
| 491 | + | "\r_all_branches\"\xd8\x03\n" + | |
| 492 | + | "\vMirrorState\x12\x14\n" + | |
| 493 | + | "\x05state\x18\x01 \x01(\tR\x05state\x12\x14\n" + | |
| 494 | + | "\x05class\x18\x02 \x01(\tR\x05class\x12\x16\n" + | |
| 495 | + | "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x16\n" + | |
| 496 | + | "\x06detail\x18\x04 \x01(\tR\x06detail\x121\n" + | |
| 497 | + | "\x14consecutive_failures\x18\x05 \x01(\x05R\x13consecutiveFailures\x12B\n" + | |
| 498 | + | "\x0flast_attempt_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\rlastAttemptAt\x12B\n" + | |
| 499 | + | "\x0flast_success_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\rlastSuccessAt\x12>\n" + | |
| 500 | + | "\rnext_retry_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\vnextRetryAt\x127\n" + | |
| 501 | + | "\tpaused_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\bpausedAt\x129\n" + | |
| 502 | + | "\n" + | |
| 503 | + | "updated_at\x18\n" + | |
| 504 | + | " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"7\n" + | |
| 505 | + | "\x11MirrorSnapshotRef\x12\x12\n" + | |
| 506 | + | "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + | |
| 507 | + | "\x02id\x18\x02 \x01(\tR\x02id\"\xa8\x01\n" + | |
| 508 | + | "\x0eMirrorSnapshot\x12\x10\n" + | |
| 509 | + | "\x03url\x18\x01 \x01(\tR\x03url\x125\n" + | |
| 510 | + | "\x04refs\x18\x02 \x03(\v2!.ermite.storage.MirrorSnapshotRefR\x04refs\x12!\n" + | |
| 511 | + | "\fall_branches\x18\x03 \x01(\bR\vallBranches\x12*\n" + | |
| 512 | + | "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02atB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 513 | + | ||
| 514 | + | var ( | |
| 515 | + | file_ermite_storage_mirror_proto_rawDescOnce sync.Once | |
| 516 | + | file_ermite_storage_mirror_proto_rawDescData []byte | |
| 517 | + | ) | |
| 518 | + | ||
| 519 | + | func file_ermite_storage_mirror_proto_rawDescGZIP() []byte { | |
| 520 | + | file_ermite_storage_mirror_proto_rawDescOnce.Do(func() { | |
| 521 | + | file_ermite_storage_mirror_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_mirror_proto_rawDesc), len(file_ermite_storage_mirror_proto_rawDesc))) | |
| 522 | + | }) | |
| 523 | + | return file_ermite_storage_mirror_proto_rawDescData | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | var file_ermite_storage_mirror_proto_msgTypes = make([]protoimpl.MessageInfo, 6) | |
| 527 | + | var file_ermite_storage_mirror_proto_goTypes = []any{ | |
| 528 | + | (*MirrorKey)(nil), // 0: ermite.storage.MirrorKey | |
| 529 | + | (*MirrorGlobal)(nil), // 1: ermite.storage.MirrorGlobal | |
| 530 | + | (*MirrorRepo)(nil), // 2: ermite.storage.MirrorRepo | |
| 531 | + | (*MirrorState)(nil), // 3: ermite.storage.MirrorState | |
| 532 | + | (*MirrorSnapshotRef)(nil), // 4: ermite.storage.MirrorSnapshotRef | |
| 533 | + | (*MirrorSnapshot)(nil), // 5: ermite.storage.MirrorSnapshot | |
| 534 | + | (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp | |
| 535 | + | } | |
| 536 | + | var file_ermite_storage_mirror_proto_depIdxs = []int32{ | |
| 537 | + | 6, // 0: ermite.storage.MirrorKey.created_at:type_name -> google.protobuf.Timestamp | |
| 538 | + | 6, // 1: ermite.storage.MirrorGlobal.updated_at:type_name -> google.protobuf.Timestamp | |
| 539 | + | 6, // 2: ermite.storage.MirrorRepo.updated_at:type_name -> google.protobuf.Timestamp | |
| 540 | + | 6, // 3: ermite.storage.MirrorState.last_attempt_at:type_name -> google.protobuf.Timestamp | |
| 541 | + | 6, // 4: ermite.storage.MirrorState.last_success_at:type_name -> google.protobuf.Timestamp | |
| 542 | + | 6, // 5: ermite.storage.MirrorState.next_retry_at:type_name -> google.protobuf.Timestamp | |
| 543 | + | 6, // 6: ermite.storage.MirrorState.paused_at:type_name -> google.protobuf.Timestamp | |
| 544 | + | 6, // 7: ermite.storage.MirrorState.updated_at:type_name -> google.protobuf.Timestamp | |
| 545 | + | 4, // 8: ermite.storage.MirrorSnapshot.refs:type_name -> ermite.storage.MirrorSnapshotRef | |
| 546 | + | 6, // 9: ermite.storage.MirrorSnapshot.at:type_name -> google.protobuf.Timestamp | |
| 547 | + | 10, // [10:10] is the sub-list for method output_type | |
| 548 | + | 10, // [10:10] is the sub-list for method input_type | |
| 549 | + | 10, // [10:10] is the sub-list for extension type_name | |
| 550 | + | 10, // [10:10] is the sub-list for extension extendee | |
| 551 | + | 0, // [0:10] is the sub-list for field type_name | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func init() { file_ermite_storage_mirror_proto_init() } | |
| 555 | + | func file_ermite_storage_mirror_proto_init() { | |
| 556 | + | if File_ermite_storage_mirror_proto != nil { | |
| 557 | + | return | |
| 558 | + | } | |
| 559 | + | file_ermite_storage_mirror_proto_msgTypes[2].OneofWrappers = []any{} | |
| 560 | + | type x struct{} | |
| 561 | + | out := protoimpl.TypeBuilder{ | |
| 562 | + | File: protoimpl.DescBuilder{ | |
| 563 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 564 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_mirror_proto_rawDesc), len(file_ermite_storage_mirror_proto_rawDesc)), | |
| 565 | + | NumEnums: 0, | |
| 566 | + | NumMessages: 6, | |
| 567 | + | NumExtensions: 0, | |
| 568 | + | NumServices: 0, | |
| 569 | + | }, | |
| 570 | + | GoTypes: file_ermite_storage_mirror_proto_goTypes, | |
| 571 | + | DependencyIndexes: file_ermite_storage_mirror_proto_depIdxs, | |
| 572 | + | MessageInfos: file_ermite_storage_mirror_proto_msgTypes, | |
| 573 | + | }.Build() | |
| 574 | + | File_ermite_storage_mirror_proto = out.File | |
| 575 | + | file_ermite_storage_mirror_proto_goTypes = nil | |
| 576 | + | file_ermite_storage_mirror_proto_depIdxs = nil | |
| 577 | + | } | |
| @@ -0,0 +1,577 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |||
| 4 | + | // versions: | |||
| 5 | + | // protoc-gen-go v1.36.12 | |||
| 6 | + | // protoc (unknown) | |||
| 7 | + | // source: ermite/storage/mirror.proto | |||
| 8 | + | ||||
| 9 | + | package storagepb | |||
| 10 | + | ||||
| 11 | + | import ( | |||
| 12 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |||
| 13 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |||
| 14 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |||
| 15 | + | reflect "reflect" | |||
| 16 | + | sync "sync" | |||
| 17 | + | unsafe "unsafe" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | // Verify that this generated code is sufficiently up-to-date. | |||
| 22 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |||
| 23 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |||
| 24 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | type MirrorKey struct { | |||
| 28 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 29 | + | Sealed []byte `protobuf:"bytes,1,opt,name=sealed,proto3" json:"sealed,omitempty"` | |||
| 30 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |||
| 31 | + | unknownFields protoimpl.UnknownFields | |||
| 32 | + | sizeCache protoimpl.SizeCache | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (x *MirrorKey) Reset() { | |||
| 36 | + | *x = MirrorKey{} | |||
| 37 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[0] | |||
| 38 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 39 | + | ms.StoreMessageInfo(mi) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (x *MirrorKey) String() string { | |||
| 43 | + | return protoimpl.X.MessageStringOf(x) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (*MirrorKey) ProtoMessage() {} | |||
| 47 | + | ||||
| 48 | + | func (x *MirrorKey) ProtoReflect() protoreflect.Message { | |||
| 49 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[0] | |||
| 50 | + | if x != nil { | |||
| 51 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 52 | + | if ms.LoadMessageInfo() == nil { | |||
| 53 | + | ms.StoreMessageInfo(mi) | |||
| 54 | + | } | |||
| 55 | + | return ms | |||
| 56 | + | } | |||
| 57 | + | return mi.MessageOf(x) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Deprecated: Use MirrorKey.ProtoReflect.Descriptor instead. | |||
| 61 | + | func (*MirrorKey) Descriptor() ([]byte, []int) { | |||
| 62 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{0} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (x *MirrorKey) GetSealed() []byte { | |||
| 66 | + | if x != nil { | |||
| 67 | + | return x.Sealed | |||
| 68 | + | } | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (x *MirrorKey) GetCreatedAt() *timestamppb.Timestamp { | |||
| 73 | + | if x != nil { | |||
| 74 | + | return x.CreatedAt | |||
| 75 | + | } | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | type MirrorGlobal struct { | |||
| 80 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 81 | + | Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` | |||
| 82 | + | UrlTemplate string `protobuf:"bytes,2,opt,name=url_template,json=urlTemplate,proto3" json:"url_template,omitempty"` | |||
| 83 | + | Cron string `protobuf:"bytes,3,opt,name=cron,proto3" json:"cron,omitempty"` | |||
| 84 | + | AllBranches bool `protobuf:"varint,4,opt,name=all_branches,json=allBranches,proto3" json:"all_branches,omitempty"` | |||
| 85 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |||
| 86 | + | unknownFields protoimpl.UnknownFields | |||
| 87 | + | sizeCache protoimpl.SizeCache | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (x *MirrorGlobal) Reset() { | |||
| 91 | + | *x = MirrorGlobal{} | |||
| 92 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[1] | |||
| 93 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 94 | + | ms.StoreMessageInfo(mi) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (x *MirrorGlobal) String() string { | |||
| 98 | + | return protoimpl.X.MessageStringOf(x) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (*MirrorGlobal) ProtoMessage() {} | |||
| 102 | + | ||||
| 103 | + | func (x *MirrorGlobal) ProtoReflect() protoreflect.Message { | |||
| 104 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[1] | |||
| 105 | + | if x != nil { | |||
| 106 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 107 | + | if ms.LoadMessageInfo() == nil { | |||
| 108 | + | ms.StoreMessageInfo(mi) | |||
| 109 | + | } | |||
| 110 | + | return ms | |||
| 111 | + | } | |||
| 112 | + | return mi.MessageOf(x) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Deprecated: Use MirrorGlobal.ProtoReflect.Descriptor instead. | |||
| 116 | + | func (*MirrorGlobal) Descriptor() ([]byte, []int) { | |||
| 117 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{1} | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (x *MirrorGlobal) GetEnabled() bool { | |||
| 121 | + | if x != nil { | |||
| 122 | + | return x.Enabled | |||
| 123 | + | } | |||
| 124 | + | return false | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (x *MirrorGlobal) GetUrlTemplate() string { | |||
| 128 | + | if x != nil { | |||
| 129 | + | return x.UrlTemplate | |||
| 130 | + | } | |||
| 131 | + | return "" | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (x *MirrorGlobal) GetCron() string { | |||
| 135 | + | if x != nil { | |||
| 136 | + | return x.Cron | |||
| 137 | + | } | |||
| 138 | + | return "" | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (x *MirrorGlobal) GetAllBranches() bool { | |||
| 142 | + | if x != nil { | |||
| 143 | + | return x.AllBranches | |||
| 144 | + | } | |||
| 145 | + | return false | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (x *MirrorGlobal) GetUpdatedAt() *timestamppb.Timestamp { | |||
| 149 | + | if x != nil { | |||
| 150 | + | return x.UpdatedAt | |||
| 151 | + | } | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | type MirrorRepo struct { | |||
| 156 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 157 | + | Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` | |||
| 158 | + | Cron string `protobuf:"bytes,2,opt,name=cron,proto3" json:"cron,omitempty"` | |||
| 159 | + | AllBranches *bool `protobuf:"varint,3,opt,name=all_branches,json=allBranches,proto3,oneof" json:"all_branches,omitempty"` | |||
| 160 | + | Excluded bool `protobuf:"varint,4,opt,name=excluded,proto3" json:"excluded,omitempty"` | |||
| 161 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |||
| 162 | + | unknownFields protoimpl.UnknownFields | |||
| 163 | + | sizeCache protoimpl.SizeCache | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (x *MirrorRepo) Reset() { | |||
| 167 | + | *x = MirrorRepo{} | |||
| 168 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[2] | |||
| 169 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 170 | + | ms.StoreMessageInfo(mi) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (x *MirrorRepo) String() string { | |||
| 174 | + | return protoimpl.X.MessageStringOf(x) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (*MirrorRepo) ProtoMessage() {} | |||
| 178 | + | ||||
| 179 | + | func (x *MirrorRepo) ProtoReflect() protoreflect.Message { | |||
| 180 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[2] | |||
| 181 | + | if x != nil { | |||
| 182 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 183 | + | if ms.LoadMessageInfo() == nil { | |||
| 184 | + | ms.StoreMessageInfo(mi) | |||
| 185 | + | } | |||
| 186 | + | return ms | |||
| 187 | + | } | |||
| 188 | + | return mi.MessageOf(x) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // Deprecated: Use MirrorRepo.ProtoReflect.Descriptor instead. | |||
| 192 | + | func (*MirrorRepo) Descriptor() ([]byte, []int) { | |||
| 193 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{2} | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (x *MirrorRepo) GetUrl() string { | |||
| 197 | + | if x != nil { | |||
| 198 | + | return x.Url | |||
| 199 | + | } | |||
| 200 | + | return "" | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (x *MirrorRepo) GetCron() string { | |||
| 204 | + | if x != nil { | |||
| 205 | + | return x.Cron | |||
| 206 | + | } | |||
| 207 | + | return "" | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (x *MirrorRepo) GetAllBranches() bool { | |||
| 211 | + | if x != nil && x.AllBranches != nil { | |||
| 212 | + | return *x.AllBranches | |||
| 213 | + | } | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (x *MirrorRepo) GetExcluded() bool { | |||
| 218 | + | if x != nil { | |||
| 219 | + | return x.Excluded | |||
| 220 | + | } | |||
| 221 | + | return false | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func (x *MirrorRepo) GetUpdatedAt() *timestamppb.Timestamp { | |||
| 225 | + | if x != nil { | |||
| 226 | + | return x.UpdatedAt | |||
| 227 | + | } | |||
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | type MirrorState struct { | |||
| 232 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 233 | + | State string `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` | |||
| 234 | + | Class string `protobuf:"bytes,2,opt,name=class,proto3" json:"class,omitempty"` | |||
| 235 | + | Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` | |||
| 236 | + | Detail string `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` | |||
| 237 | + | ConsecutiveFailures int32 `protobuf:"varint,5,opt,name=consecutive_failures,json=consecutiveFailures,proto3" json:"consecutive_failures,omitempty"` | |||
| 238 | + | LastAttemptAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=last_attempt_at,json=lastAttemptAt,proto3" json:"last_attempt_at,omitempty"` | |||
| 239 | + | LastSuccessAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_success_at,json=lastSuccessAt,proto3" json:"last_success_at,omitempty"` | |||
| 240 | + | NextRetryAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=next_retry_at,json=nextRetryAt,proto3" json:"next_retry_at,omitempty"` | |||
| 241 | + | PausedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=paused_at,json=pausedAt,proto3" json:"paused_at,omitempty"` | |||
| 242 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |||
| 243 | + | unknownFields protoimpl.UnknownFields | |||
| 244 | + | sizeCache protoimpl.SizeCache | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func (x *MirrorState) Reset() { | |||
| 248 | + | *x = MirrorState{} | |||
| 249 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[3] | |||
| 250 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 251 | + | ms.StoreMessageInfo(mi) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (x *MirrorState) String() string { | |||
| 255 | + | return protoimpl.X.MessageStringOf(x) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (*MirrorState) ProtoMessage() {} | |||
| 259 | + | ||||
| 260 | + | func (x *MirrorState) ProtoReflect() protoreflect.Message { | |||
| 261 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[3] | |||
| 262 | + | if x != nil { | |||
| 263 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 264 | + | if ms.LoadMessageInfo() == nil { | |||
| 265 | + | ms.StoreMessageInfo(mi) | |||
| 266 | + | } | |||
| 267 | + | return ms | |||
| 268 | + | } | |||
| 269 | + | return mi.MessageOf(x) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // Deprecated: Use MirrorState.ProtoReflect.Descriptor instead. | |||
| 273 | + | func (*MirrorState) Descriptor() ([]byte, []int) { | |||
| 274 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{3} | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (x *MirrorState) GetState() string { | |||
| 278 | + | if x != nil { | |||
| 279 | + | return x.State | |||
| 280 | + | } | |||
| 281 | + | return "" | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func (x *MirrorState) GetClass() string { | |||
| 285 | + | if x != nil { | |||
| 286 | + | return x.Class | |||
| 287 | + | } | |||
| 288 | + | return "" | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func (x *MirrorState) GetReason() string { | |||
| 292 | + | if x != nil { | |||
| 293 | + | return x.Reason | |||
| 294 | + | } | |||
| 295 | + | return "" | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (x *MirrorState) GetDetail() string { | |||
| 299 | + | if x != nil { | |||
| 300 | + | return x.Detail | |||
| 301 | + | } | |||
| 302 | + | return "" | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func (x *MirrorState) GetConsecutiveFailures() int32 { | |||
| 306 | + | if x != nil { | |||
| 307 | + | return x.ConsecutiveFailures | |||
| 308 | + | } | |||
| 309 | + | return 0 | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func (x *MirrorState) GetLastAttemptAt() *timestamppb.Timestamp { | |||
| 313 | + | if x != nil { | |||
| 314 | + | return x.LastAttemptAt | |||
| 315 | + | } | |||
| 316 | + | return nil | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func (x *MirrorState) GetLastSuccessAt() *timestamppb.Timestamp { | |||
| 320 | + | if x != nil { | |||
| 321 | + | return x.LastSuccessAt | |||
| 322 | + | } | |||
| 323 | + | return nil | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (x *MirrorState) GetNextRetryAt() *timestamppb.Timestamp { | |||
| 327 | + | if x != nil { | |||
| 328 | + | return x.NextRetryAt | |||
| 329 | + | } | |||
| 330 | + | return nil | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (x *MirrorState) GetPausedAt() *timestamppb.Timestamp { | |||
| 334 | + | if x != nil { | |||
| 335 | + | return x.PausedAt | |||
| 336 | + | } | |||
| 337 | + | return nil | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func (x *MirrorState) GetUpdatedAt() *timestamppb.Timestamp { | |||
| 341 | + | if x != nil { | |||
| 342 | + | return x.UpdatedAt | |||
| 343 | + | } | |||
| 344 | + | return nil | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | type MirrorSnapshotRef struct { | |||
| 348 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 349 | + | Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` | |||
| 350 | + | Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` | |||
| 351 | + | unknownFields protoimpl.UnknownFields | |||
| 352 | + | sizeCache protoimpl.SizeCache | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func (x *MirrorSnapshotRef) Reset() { | |||
| 356 | + | *x = MirrorSnapshotRef{} | |||
| 357 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[4] | |||
| 358 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 359 | + | ms.StoreMessageInfo(mi) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | func (x *MirrorSnapshotRef) String() string { | |||
| 363 | + | return protoimpl.X.MessageStringOf(x) | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func (*MirrorSnapshotRef) ProtoMessage() {} | |||
| 367 | + | ||||
| 368 | + | func (x *MirrorSnapshotRef) ProtoReflect() protoreflect.Message { | |||
| 369 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[4] | |||
| 370 | + | if x != nil { | |||
| 371 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 372 | + | if ms.LoadMessageInfo() == nil { | |||
| 373 | + | ms.StoreMessageInfo(mi) | |||
| 374 | + | } | |||
| 375 | + | return ms | |||
| 376 | + | } | |||
| 377 | + | return mi.MessageOf(x) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | // Deprecated: Use MirrorSnapshotRef.ProtoReflect.Descriptor instead. | |||
| 381 | + | func (*MirrorSnapshotRef) Descriptor() ([]byte, []int) { | |||
| 382 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{4} | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func (x *MirrorSnapshotRef) GetName() string { | |||
| 386 | + | if x != nil { | |||
| 387 | + | return x.Name | |||
| 388 | + | } | |||
| 389 | + | return "" | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func (x *MirrorSnapshotRef) GetId() string { | |||
| 393 | + | if x != nil { | |||
| 394 | + | return x.Id | |||
| 395 | + | } | |||
| 396 | + | return "" | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | type MirrorSnapshot struct { | |||
| 400 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 401 | + | Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` | |||
| 402 | + | Refs []*MirrorSnapshotRef `protobuf:"bytes,2,rep,name=refs,proto3" json:"refs,omitempty"` | |||
| 403 | + | AllBranches bool `protobuf:"varint,3,opt,name=all_branches,json=allBranches,proto3" json:"all_branches,omitempty"` | |||
| 404 | + | At *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=at,proto3" json:"at,omitempty"` | |||
| 405 | + | unknownFields protoimpl.UnknownFields | |||
| 406 | + | sizeCache protoimpl.SizeCache | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func (x *MirrorSnapshot) Reset() { | |||
| 410 | + | *x = MirrorSnapshot{} | |||
| 411 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[5] | |||
| 412 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 413 | + | ms.StoreMessageInfo(mi) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func (x *MirrorSnapshot) String() string { | |||
| 417 | + | return protoimpl.X.MessageStringOf(x) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | func (*MirrorSnapshot) ProtoMessage() {} | |||
| 421 | + | ||||
| 422 | + | func (x *MirrorSnapshot) ProtoReflect() protoreflect.Message { | |||
| 423 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[5] | |||
| 424 | + | if x != nil { | |||
| 425 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 426 | + | if ms.LoadMessageInfo() == nil { | |||
| 427 | + | ms.StoreMessageInfo(mi) | |||
| 428 | + | } | |||
| 429 | + | return ms | |||
| 430 | + | } | |||
| 431 | + | return mi.MessageOf(x) | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // Deprecated: Use MirrorSnapshot.ProtoReflect.Descriptor instead. | |||
| 435 | + | func (*MirrorSnapshot) Descriptor() ([]byte, []int) { | |||
| 436 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{5} | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func (x *MirrorSnapshot) GetUrl() string { | |||
| 440 | + | if x != nil { | |||
| 441 | + | return x.Url | |||
| 442 | + | } | |||
| 443 | + | return "" | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func (x *MirrorSnapshot) GetRefs() []*MirrorSnapshotRef { | |||
| 447 | + | if x != nil { | |||
| 448 | + | return x.Refs | |||
| 449 | + | } | |||
| 450 | + | return nil | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func (x *MirrorSnapshot) GetAllBranches() bool { | |||
| 454 | + | if x != nil { | |||
| 455 | + | return x.AllBranches | |||
| 456 | + | } | |||
| 457 | + | return false | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func (x *MirrorSnapshot) GetAt() *timestamppb.Timestamp { | |||
| 461 | + | if x != nil { | |||
| 462 | + | return x.At | |||
| 463 | + | } | |||
| 464 | + | return nil | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | var File_ermite_storage_mirror_proto protoreflect.FileDescriptor | |||
| 468 | + | ||||
| 469 | + | const file_ermite_storage_mirror_proto_rawDesc = "" + | |||
| 470 | + | "\n" + | |||
| 471 | + | "\x1bermite/storage/mirror.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"^\n" + | |||
| 472 | + | "\tMirrorKey\x12\x16\n" + | |||
| 473 | + | "\x06sealed\x18\x01 \x01(\fR\x06sealed\x129\n" + | |||
| 474 | + | "\n" + | |||
| 475 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xbd\x01\n" + | |||
| 476 | + | "\fMirrorGlobal\x12\x18\n" + | |||
| 477 | + | "\aenabled\x18\x01 \x01(\bR\aenabled\x12!\n" + | |||
| 478 | + | "\furl_template\x18\x02 \x01(\tR\vurlTemplate\x12\x12\n" + | |||
| 479 | + | "\x04cron\x18\x03 \x01(\tR\x04cron\x12!\n" + | |||
| 480 | + | "\fall_branches\x18\x04 \x01(\bR\vallBranches\x129\n" + | |||
| 481 | + | "\n" + | |||
| 482 | + | "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\xc2\x01\n" + | |||
| 483 | + | "\n" + | |||
| 484 | + | "MirrorRepo\x12\x10\n" + | |||
| 485 | + | "\x03url\x18\x01 \x01(\tR\x03url\x12\x12\n" + | |||
| 486 | + | "\x04cron\x18\x02 \x01(\tR\x04cron\x12&\n" + | |||
| 487 | + | "\fall_branches\x18\x03 \x01(\bH\x00R\vallBranches\x88\x01\x01\x12\x1a\n" + | |||
| 488 | + | "\bexcluded\x18\x04 \x01(\bR\bexcluded\x129\n" + | |||
| 489 | + | "\n" + | |||
| 490 | + | "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\x0f\n" + | |||
| 491 | + | "\r_all_branches\"\xd8\x03\n" + | |||
| 492 | + | "\vMirrorState\x12\x14\n" + | |||
| 493 | + | "\x05state\x18\x01 \x01(\tR\x05state\x12\x14\n" + | |||
| 494 | + | "\x05class\x18\x02 \x01(\tR\x05class\x12\x16\n" + | |||
| 495 | + | "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x16\n" + | |||
| 496 | + | "\x06detail\x18\x04 \x01(\tR\x06detail\x121\n" + | |||
| 497 | + | "\x14consecutive_failures\x18\x05 \x01(\x05R\x13consecutiveFailures\x12B\n" + | |||
| 498 | + | "\x0flast_attempt_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\rlastAttemptAt\x12B\n" + | |||
| 499 | + | "\x0flast_success_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\rlastSuccessAt\x12>\n" + | |||
| 500 | + | "\rnext_retry_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\vnextRetryAt\x127\n" + | |||
| 501 | + | "\tpaused_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\bpausedAt\x129\n" + | |||
| 502 | + | "\n" + | |||
| 503 | + | "updated_at\x18\n" + | |||
| 504 | + | " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"7\n" + | |||
| 505 | + | "\x11MirrorSnapshotRef\x12\x12\n" + | |||
| 506 | + | "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + | |||
| 507 | + | "\x02id\x18\x02 \x01(\tR\x02id\"\xa8\x01\n" + | |||
| 508 | + | "\x0eMirrorSnapshot\x12\x10\n" + | |||
| 509 | + | "\x03url\x18\x01 \x01(\tR\x03url\x125\n" + | |||
| 510 | + | "\x04refs\x18\x02 \x03(\v2!.ermite.storage.MirrorSnapshotRefR\x04refs\x12!\n" + | |||
| 511 | + | "\fall_branches\x18\x03 \x01(\bR\vallBranches\x12*\n" + | |||
| 512 | + | "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02atB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |||
| 513 | + | ||||
| 514 | + | var ( | |||
| 515 | + | file_ermite_storage_mirror_proto_rawDescOnce sync.Once | |||
| 516 | + | file_ermite_storage_mirror_proto_rawDescData []byte | |||
| 517 | + | ) | |||
| 518 | + | ||||
| 519 | + | func file_ermite_storage_mirror_proto_rawDescGZIP() []byte { | |||
| 520 | + | file_ermite_storage_mirror_proto_rawDescOnce.Do(func() { | |||
| 521 | + | file_ermite_storage_mirror_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_mirror_proto_rawDesc), len(file_ermite_storage_mirror_proto_rawDesc))) | |||
| 522 | + | }) | |||
| 523 | + | return file_ermite_storage_mirror_proto_rawDescData | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | var file_ermite_storage_mirror_proto_msgTypes = make([]protoimpl.MessageInfo, 6) | |||
| 527 | + | var file_ermite_storage_mirror_proto_goTypes = []any{ | |||
| 528 | + | (*MirrorKey)(nil), // 0: ermite.storage.MirrorKey | |||
| 529 | + | (*MirrorGlobal)(nil), // 1: ermite.storage.MirrorGlobal | |||
| 530 | + | (*MirrorRepo)(nil), // 2: ermite.storage.MirrorRepo | |||
| 531 | + | (*MirrorState)(nil), // 3: ermite.storage.MirrorState | |||
| 532 | + | (*MirrorSnapshotRef)(nil), // 4: ermite.storage.MirrorSnapshotRef | |||
| 533 | + | (*MirrorSnapshot)(nil), // 5: ermite.storage.MirrorSnapshot | |||
| 534 | + | (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp | |||
| 535 | + | } | |||
| 536 | + | var file_ermite_storage_mirror_proto_depIdxs = []int32{ | |||
| 537 | + | 6, // 0: ermite.storage.MirrorKey.created_at:type_name -> google.protobuf.Timestamp | |||
| 538 | + | 6, // 1: ermite.storage.MirrorGlobal.updated_at:type_name -> google.protobuf.Timestamp | |||
| 539 | + | 6, // 2: ermite.storage.MirrorRepo.updated_at:type_name -> google.protobuf.Timestamp | |||
| 540 | + | 6, // 3: ermite.storage.MirrorState.last_attempt_at:type_name -> google.protobuf.Timestamp | |||
| 541 | + | 6, // 4: ermite.storage.MirrorState.last_success_at:type_name -> google.protobuf.Timestamp | |||
| 542 | + | 6, // 5: ermite.storage.MirrorState.next_retry_at:type_name -> google.protobuf.Timestamp | |||
| 543 | + | 6, // 6: ermite.storage.MirrorState.paused_at:type_name -> google.protobuf.Timestamp | |||
| 544 | + | 6, // 7: ermite.storage.MirrorState.updated_at:type_name -> google.protobuf.Timestamp | |||
| 545 | + | 4, // 8: ermite.storage.MirrorSnapshot.refs:type_name -> ermite.storage.MirrorSnapshotRef | |||
| 546 | + | 6, // 9: ermite.storage.MirrorSnapshot.at:type_name -> google.protobuf.Timestamp | |||
| 547 | + | 10, // [10:10] is the sub-list for method output_type | |||
| 548 | + | 10, // [10:10] is the sub-list for method input_type | |||
| 549 | + | 10, // [10:10] is the sub-list for extension type_name | |||
| 550 | + | 10, // [10:10] is the sub-list for extension extendee | |||
| 551 | + | 0, // [0:10] is the sub-list for field type_name | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func init() { file_ermite_storage_mirror_proto_init() } | |||
| 555 | + | func file_ermite_storage_mirror_proto_init() { | |||
| 556 | + | if File_ermite_storage_mirror_proto != nil { | |||
| 557 | + | return | |||
| 558 | + | } | |||
| 559 | + | file_ermite_storage_mirror_proto_msgTypes[2].OneofWrappers = []any{} | |||
| 560 | + | type x struct{} | |||
| 561 | + | out := protoimpl.TypeBuilder{ | |||
| 562 | + | File: protoimpl.DescBuilder{ | |||
| 563 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |||
| 564 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_mirror_proto_rawDesc), len(file_ermite_storage_mirror_proto_rawDesc)), | |||
| 565 | + | NumEnums: 0, | |||
| 566 | + | NumMessages: 6, | |||
| 567 | + | NumExtensions: 0, | |||
| 568 | + | NumServices: 0, | |||
| 569 | + | }, | |||
| 570 | + | GoTypes: file_ermite_storage_mirror_proto_goTypes, | |||
| 571 | + | DependencyIndexes: file_ermite_storage_mirror_proto_depIdxs, | |||
| 572 | + | MessageInfos: file_ermite_storage_mirror_proto_msgTypes, | |||
| 573 | + | }.Build() | |||
| 574 | + | File_ermite_storage_mirror_proto = out.File | |||
| 575 | + | file_ermite_storage_mirror_proto_goTypes = nil | |||
| 576 | + | file_ermite_storage_mirror_proto_depIdxs = nil | |||
| 577 | + | } | |||
A internal/adapters/gitpush/gitpush.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package gitpush implements bounded, non-forcing mirror pushes over git/ssh. | |
| 4 | + | package gitpush | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "bytes" | |
| 8 | + | "context" | |
| 9 | + | "crypto/ed25519" | |
| 10 | + | "encoding/pem" | |
| 11 | + | "errors" | |
| 12 | + | "os" | |
| 13 | + | "os/exec" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | "unicode" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "golang.org/x/crypto/ssh" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | type Config struct { | |
| 24 | + | Bin, Path, Home, KnownHosts, TempDir string | |
| 25 | + | Timeout time.Duration | |
| 26 | + | MaxOutput int | |
| 27 | + | AllowedProtocols string | |
| 28 | + | SSHOptions []string | |
| 29 | + | RepoDir func(domain.RepoName) string | |
| 30 | + | } | |
| 31 | + | type Pusher struct{ cfg Config } | |
| 32 | + | ||
| 33 | + | var _ ports.MirrorPusher = (*Pusher)(nil) | |
| 34 | + | ||
| 35 | + | func New(cfg Config) *Pusher { | |
| 36 | + | if cfg.Timeout <= 0 { | |
| 37 | + | cfg.Timeout = 10 * time.Minute | |
| 38 | + | } | |
| 39 | + | if cfg.MaxOutput <= 0 { | |
| 40 | + | cfg.MaxOutput = 1 << 20 | |
| 41 | + | } | |
| 42 | + | if cfg.AllowedProtocols == "" { | |
| 43 | + | cfg.AllowedProtocols = "ssh" | |
| 44 | + | } | |
| 45 | + | cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...) | |
| 46 | + | return &Pusher{cfg: cfg} | |
| 47 | + | } | |
| 48 | + | func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } | |
| 49 | + | ||
| 50 | + | // validRefspec deliberately accepts only explicit same-name heads and tags. | |
| 51 | + | // Empty sources, wildcards, force prefixes and revision expressions never reach git. | |
| 52 | + | func validRefspec(s string) bool { | |
| 53 | + | a, b, ok := strings.Cut(s, ":") | |
| 54 | + | if !ok || a != b || !(strings.HasPrefix(a, "refs/heads/") || strings.HasPrefix(a, "refs/tags/")) { | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | for _, part := range strings.Split(a, "/") { | |
| 61 | + | if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return !strings.ContainsFunc(a, unicode.IsControl) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) { | |
| 69 | + | start := time.Now() | |
| 70 | + | defer func() { result.Duration = time.Since(start) }() | |
| 71 | + | internal := func(detail string) error { | |
| 72 | + | return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |
| 73 | + | } | |
| 74 | + | if err := parent.Err(); err != nil { | |
| 75 | + | return result, err | |
| 76 | + | } | |
| 77 | + | if len(req.PrivateKey) != ed25519.PrivateKeySize { | |
| 78 | + | return result, internal("invalid mirror private key") | |
| 79 | + | } | |
| 80 | + | if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" { | |
| 81 | + | return result, internal("mirror transport directories are not configured") | |
| 82 | + | } | |
| 83 | + | if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) { | |
| 84 | + | return result, internal("invalid mirror target") | |
| 85 | + | } | |
| 86 | + | for _, ref := range req.Refspecs { | |
| 87 | + | if !validRefspec(ref) { | |
| 88 | + | return result, internal("invalid mirror refspec") | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | if len(req.Refspecs) == 0 { | |
| 92 | + | return result, nil | |
| 93 | + | } // never let git select implicit refspecs | |
| 94 | + | ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout) | |
| 95 | + | defer cancel() | |
| 96 | + | if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil { | |
| 97 | + | return result, internal("create mirror key directory") | |
| 98 | + | } | |
| 99 | + | if err := os.Chmod(p.cfg.TempDir, 0700); err != nil { | |
| 100 | + | return result, internal("protect mirror key directory") | |
| 101 | + | } | |
| 102 | + | dir, e := os.MkdirTemp(p.cfg.TempDir, "push-") | |
| 103 | + | if e != nil { | |
| 104 | + | return result, internal("create temporary mirror directory") | |
| 105 | + | } | |
| 106 | + | defer os.RemoveAll(dir) | |
| 107 | + | block, e := ssh.MarshalPrivateKey(req.PrivateKey, "") | |
| 108 | + | if e != nil { | |
| 109 | + | return result, internal("encode mirror private key") | |
| 110 | + | } | |
| 111 | + | f, e := os.CreateTemp(dir, "key-") | |
| 112 | + | if e != nil { | |
| 113 | + | return result, internal("create temporary mirror key") | |
| 114 | + | } | |
| 115 | + | keyPath := f.Name() | |
| 116 | + | _, e = f.Write(pem.EncodeToMemory(block)) | |
| 117 | + | closeErr := f.Close() | |
| 118 | + | if e != nil || closeErr != nil { | |
| 119 | + | return result, internal("write temporary mirror key") | |
| 120 | + | } | |
| 121 | + | // -F /dev/null prevents ~/.ssh/config and system config from adding credentials | |
| 122 | + | // or weakening the required options. Required options precede caller extras. | |
| 123 | + | command := "ssh -F /dev/null -i " + quote(keyPath) | |
| 124 | + | for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts}, p.cfg.SSHOptions...) { | |
| 125 | + | command += " -o " + quote(opt) | |
| 126 | + | } | |
| 127 | + | cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond) | |
| 128 | + | runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond)) | |
| 129 | + | defer stop() | |
| 130 | + | stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |
| 131 | + | stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |
| 132 | + | args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL} | |
| 133 | + | args = append(args, req.Refspecs...) | |
| 134 | + | cmd := exec.Command(p.cfg.Bin, args...) | |
| 135 | + | cmd.Env = []string{"PATH=" + p.cfg.Path, "HOME=" + p.cfg.Home, "GIT_ALLOW_PROTOCOL=" + p.cfg.AllowedProtocols, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_SSH_COMMAND=" + command, "LC_ALL=C"} | |
| 136 | + | cmd.Dir = p.cfg.Home | |
| 137 | + | cmd.Stdout = stdout | |
| 138 | + | cmd.Stderr = stderr | |
| 139 | + | cmd.WaitDelay = cleanup | |
| 140 | + | runErr := run(runCtx, cmd) | |
| 141 | + | if parent.Err() != nil { | |
| 142 | + | return result, parent.Err() | |
| 143 | + | } | |
| 144 | + | rejected := false | |
| 145 | + | for line := range strings.SplitSeq(stdout.buf.String(), "\n") { | |
| 146 | + | fields := strings.Split(line, "\t") | |
| 147 | + | if len(fields) < 3 || len(fields[0]) != 1 { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | var status ports.MirrorRefStatus | |
| 151 | + | switch fields[0] { | |
| 152 | + | case " ", "*": | |
| 153 | + | status = ports.MirrorRefOK | |
| 154 | + | case "=": | |
| 155 | + | status = ports.MirrorRefUpToDate | |
| 156 | + | case "!": | |
| 157 | + | status = ports.MirrorRefRejected | |
| 158 | + | rejected = true | |
| 159 | + | default: | |
| 160 | + | continue | |
| 161 | + | } | |
| 162 | + | _, ref, _ := strings.Cut(fields[1], ":") | |
| 163 | + | result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)}) | |
| 164 | + | } | |
| 165 | + | if stdout.exceeded || stderr.exceeded { | |
| 166 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"} | |
| 167 | + | } | |
| 168 | + | if runCtx.Err() != nil { | |
| 169 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"} | |
| 170 | + | } | |
| 171 | + | if runErr != nil || rejected { | |
| 172 | + | class := domain.ClassifyMirrorOutput(stderr.buf.String()+"\n"+stdout.buf.String(), runErr, false) | |
| 173 | + | if rejected && class == domain.MirrorClassNone { | |
| 174 | + | class = domain.MirrorClassDiverged | |
| 175 | + | } | |
| 176 | + | var execErr *exec.Error | |
| 177 | + | if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) { | |
| 178 | + | class = domain.MirrorClassInternal | |
| 179 | + | } | |
| 180 | + | detail := safeDetail(stderr.buf.String(), req.URL) | |
| 181 | + | if detail == "" { | |
| 182 | + | detail = "git push failed" | |
| 183 | + | } | |
| 184 | + | return result, &domain.MirrorError{Class: class, Detail: detail} | |
| 185 | + | } | |
| 186 | + | return result, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func localGitError(stderr string) bool { | |
| 190 | + | for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} { | |
| 191 | + | if strings.Contains(strings.ToLower(stderr), marker) { | |
| 192 | + | return true | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // safeDetail removes the complete target before taking a bounded stderr tail. | |
| 199 | + | // SSH diagnostics may repeat the user separately, so suppress user@ tokens too. | |
| 200 | + | func safeDetail(s, url string) string { | |
| 201 | + | s = strings.ReplaceAll(s, url, "[mirror target]") | |
| 202 | + | words := strings.Fields(s) | |
| 203 | + | for i, w := range words { | |
| 204 | + | if strings.Contains(w, "@") || strings.Contains(w, "://") { | |
| 205 | + | words[i] = "[redacted]" | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | s = strings.Join(words, " ") | |
| 209 | + | if len(s) > 4096 { | |
| 210 | + | s = s[len(s)-4096:] | |
| 211 | + | } | |
| 212 | + | return s | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | type limitWriter struct { | |
| 216 | + | buf bytes.Buffer | |
| 217 | + | max int | |
| 218 | + | exceeded bool | |
| 219 | + | cancel context.CancelFunc | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |
| 223 | + | if len(p) > w.max-w.buf.Len() { | |
| 224 | + | w.exceeded = true | |
| 225 | + | w.cancel() | |
| 226 | + | return 0, errors.New("output limit reached") | |
| 227 | + | } | |
| 228 | + | return w.buf.Write(p) | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package gitpush implements bounded, non-forcing mirror pushes over git/ssh. | |||
| 4 | + | package gitpush | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "bytes" | |||
| 8 | + | "context" | |||
| 9 | + | "crypto/ed25519" | |||
| 10 | + | "encoding/pem" | |||
| 11 | + | "errors" | |||
| 12 | + | "os" | |||
| 13 | + | "os/exec" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | "unicode" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | "golang.org/x/crypto/ssh" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | type Config struct { | |||
| 24 | + | Bin, Path, Home, KnownHosts, TempDir string | |||
| 25 | + | Timeout time.Duration | |||
| 26 | + | MaxOutput int | |||
| 27 | + | AllowedProtocols string | |||
| 28 | + | SSHOptions []string | |||
| 29 | + | RepoDir func(domain.RepoName) string | |||
| 30 | + | } | |||
| 31 | + | type Pusher struct{ cfg Config } | |||
| 32 | + | ||||
| 33 | + | var _ ports.MirrorPusher = (*Pusher)(nil) | |||
| 34 | + | ||||
| 35 | + | func New(cfg Config) *Pusher { | |||
| 36 | + | if cfg.Timeout <= 0 { | |||
| 37 | + | cfg.Timeout = 10 * time.Minute | |||
| 38 | + | } | |||
| 39 | + | if cfg.MaxOutput <= 0 { | |||
| 40 | + | cfg.MaxOutput = 1 << 20 | |||
| 41 | + | } | |||
| 42 | + | if cfg.AllowedProtocols == "" { | |||
| 43 | + | cfg.AllowedProtocols = "ssh" | |||
| 44 | + | } | |||
| 45 | + | cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...) | |||
| 46 | + | return &Pusher{cfg: cfg} | |||
| 47 | + | } | |||
| 48 | + | func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } | |||
| 49 | + | ||||
| 50 | + | // validRefspec deliberately accepts only explicit same-name heads and tags. | |||
| 51 | + | // Empty sources, wildcards, force prefixes and revision expressions never reach git. | |||
| 52 | + | func validRefspec(s string) bool { | |||
| 53 | + | a, b, ok := strings.Cut(s, ":") | |||
| 54 | + | if !ok || a != b || !(strings.HasPrefix(a, "refs/heads/") || strings.HasPrefix(a, "refs/tags/")) { | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | for _, part := range strings.Split(a, "/") { | |||
| 61 | + | if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | return !strings.ContainsFunc(a, unicode.IsControl) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) { | |||
| 69 | + | start := time.Now() | |||
| 70 | + | defer func() { result.Duration = time.Since(start) }() | |||
| 71 | + | internal := func(detail string) error { | |||
| 72 | + | return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |||
| 73 | + | } | |||
| 74 | + | if err := parent.Err(); err != nil { | |||
| 75 | + | return result, err | |||
| 76 | + | } | |||
| 77 | + | if len(req.PrivateKey) != ed25519.PrivateKeySize { | |||
| 78 | + | return result, internal("invalid mirror private key") | |||
| 79 | + | } | |||
| 80 | + | if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" { | |||
| 81 | + | return result, internal("mirror transport directories are not configured") | |||
| 82 | + | } | |||
| 83 | + | if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) { | |||
| 84 | + | return result, internal("invalid mirror target") | |||
| 85 | + | } | |||
| 86 | + | for _, ref := range req.Refspecs { | |||
| 87 | + | if !validRefspec(ref) { | |||
| 88 | + | return result, internal("invalid mirror refspec") | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | if len(req.Refspecs) == 0 { | |||
| 92 | + | return result, nil | |||
| 93 | + | } // never let git select implicit refspecs | |||
| 94 | + | ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout) | |||
| 95 | + | defer cancel() | |||
| 96 | + | if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil { | |||
| 97 | + | return result, internal("create mirror key directory") | |||
| 98 | + | } | |||
| 99 | + | if err := os.Chmod(p.cfg.TempDir, 0700); err != nil { | |||
| 100 | + | return result, internal("protect mirror key directory") | |||
| 101 | + | } | |||
| 102 | + | dir, e := os.MkdirTemp(p.cfg.TempDir, "push-") | |||
| 103 | + | if e != nil { | |||
| 104 | + | return result, internal("create temporary mirror directory") | |||
| 105 | + | } | |||
| 106 | + | defer os.RemoveAll(dir) | |||
| 107 | + | block, e := ssh.MarshalPrivateKey(req.PrivateKey, "") | |||
| 108 | + | if e != nil { | |||
| 109 | + | return result, internal("encode mirror private key") | |||
| 110 | + | } | |||
| 111 | + | f, e := os.CreateTemp(dir, "key-") | |||
| 112 | + | if e != nil { | |||
| 113 | + | return result, internal("create temporary mirror key") | |||
| 114 | + | } | |||
| 115 | + | keyPath := f.Name() | |||
| 116 | + | _, e = f.Write(pem.EncodeToMemory(block)) | |||
| 117 | + | closeErr := f.Close() | |||
| 118 | + | if e != nil || closeErr != nil { | |||
| 119 | + | return result, internal("write temporary mirror key") | |||
| 120 | + | } | |||
| 121 | + | // -F /dev/null prevents ~/.ssh/config and system config from adding credentials | |||
| 122 | + | // or weakening the required options. Required options precede caller extras. | |||
| 123 | + | command := "ssh -F /dev/null -i " + quote(keyPath) | |||
| 124 | + | for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts}, p.cfg.SSHOptions...) { | |||
| 125 | + | command += " -o " + quote(opt) | |||
| 126 | + | } | |||
| 127 | + | cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond) | |||
| 128 | + | runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond)) | |||
| 129 | + | defer stop() | |||
| 130 | + | stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |||
| 131 | + | stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |||
| 132 | + | args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL} | |||
| 133 | + | args = append(args, req.Refspecs...) | |||
| 134 | + | cmd := exec.Command(p.cfg.Bin, args...) | |||
| 135 | + | cmd.Env = []string{"PATH=" + p.cfg.Path, "HOME=" + p.cfg.Home, "GIT_ALLOW_PROTOCOL=" + p.cfg.AllowedProtocols, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_SSH_COMMAND=" + command, "LC_ALL=C"} | |||
| 136 | + | cmd.Dir = p.cfg.Home | |||
| 137 | + | cmd.Stdout = stdout | |||
| 138 | + | cmd.Stderr = stderr | |||
| 139 | + | cmd.WaitDelay = cleanup | |||
| 140 | + | runErr := run(runCtx, cmd) | |||
| 141 | + | if parent.Err() != nil { | |||
| 142 | + | return result, parent.Err() | |||
| 143 | + | } | |||
| 144 | + | rejected := false | |||
| 145 | + | for line := range strings.SplitSeq(stdout.buf.String(), "\n") { | |||
| 146 | + | fields := strings.Split(line, "\t") | |||
| 147 | + | if len(fields) < 3 || len(fields[0]) != 1 { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | var status ports.MirrorRefStatus | |||
| 151 | + | switch fields[0] { | |||
| 152 | + | case " ", "*": | |||
| 153 | + | status = ports.MirrorRefOK | |||
| 154 | + | case "=": | |||
| 155 | + | status = ports.MirrorRefUpToDate | |||
| 156 | + | case "!": | |||
| 157 | + | status = ports.MirrorRefRejected | |||
| 158 | + | rejected = true | |||
| 159 | + | default: | |||
| 160 | + | continue | |||
| 161 | + | } | |||
| 162 | + | _, ref, _ := strings.Cut(fields[1], ":") | |||
| 163 | + | result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)}) | |||
| 164 | + | } | |||
| 165 | + | if stdout.exceeded || stderr.exceeded { | |||
| 166 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"} | |||
| 167 | + | } | |||
| 168 | + | if runCtx.Err() != nil { | |||
| 169 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"} | |||
| 170 | + | } | |||
| 171 | + | if runErr != nil || rejected { | |||
| 172 | + | class := domain.ClassifyMirrorOutput(stderr.buf.String()+"\n"+stdout.buf.String(), runErr, false) | |||
| 173 | + | if rejected && class == domain.MirrorClassNone { | |||
| 174 | + | class = domain.MirrorClassDiverged | |||
| 175 | + | } | |||
| 176 | + | var execErr *exec.Error | |||
| 177 | + | if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) { | |||
| 178 | + | class = domain.MirrorClassInternal | |||
| 179 | + | } | |||
| 180 | + | detail := safeDetail(stderr.buf.String(), req.URL) | |||
| 181 | + | if detail == "" { | |||
| 182 | + | detail = "git push failed" | |||
| 183 | + | } | |||
| 184 | + | return result, &domain.MirrorError{Class: class, Detail: detail} | |||
| 185 | + | } | |||
| 186 | + | return result, nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func localGitError(stderr string) bool { | |||
| 190 | + | for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} { | |||
| 191 | + | if strings.Contains(strings.ToLower(stderr), marker) { | |||
| 192 | + | return true | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | return false | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // safeDetail removes the complete target before taking a bounded stderr tail. | |||
| 199 | + | // SSH diagnostics may repeat the user separately, so suppress user@ tokens too. | |||
| 200 | + | func safeDetail(s, url string) string { | |||
| 201 | + | s = strings.ReplaceAll(s, url, "[mirror target]") | |||
| 202 | + | words := strings.Fields(s) | |||
| 203 | + | for i, w := range words { | |||
| 204 | + | if strings.Contains(w, "@") || strings.Contains(w, "://") { | |||
| 205 | + | words[i] = "[redacted]" | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | s = strings.Join(words, " ") | |||
| 209 | + | if len(s) > 4096 { | |||
| 210 | + | s = s[len(s)-4096:] | |||
| 211 | + | } | |||
| 212 | + | return s | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | type limitWriter struct { | |||
| 216 | + | buf bytes.Buffer | |||
| 217 | + | max int | |||
| 218 | + | exceeded bool | |||
| 219 | + | cancel context.CancelFunc | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |||
| 223 | + | if len(p) > w.max-w.buf.Len() { | |||
| 224 | + | w.exceeded = true | |||
| 225 | + | w.cancel() | |||
| 226 | + | return 0, errors.New("output limit reached") | |||
| 227 | + | } | |||
| 228 | + | return w.buf.Write(p) | |||
| 229 | + | } | |||
A internal/adapters/gitpush/gitpush_test.go +159 -0
| @@ -0,0 +1,159 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitpush | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "errors" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func git(t *testing.T, args ...string) string { | |
| 24 | + | t.Helper() | |
| 25 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 26 | + | defer cancel() | |
| 27 | + | cmd := exec.CommandContext(ctx, "git", args...) | |
| 28 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.org", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.org"} | |
| 29 | + | out, err := cmd.CombinedOutput() | |
| 30 | + | require.NoError(t, err, string(out)) | |
| 31 | + | return strings.TrimSpace(string(out)) | |
| 32 | + | } | |
| 33 | + | func setup(t *testing.T) (Config, ports.MirrorPush) { | |
| 34 | + | t.Helper() | |
| 35 | + | root := t.TempDir() | |
| 36 | + | source := filepath.Join(root, "source") | |
| 37 | + | git(t, "init", "-b", "main", source) | |
| 38 | + | git(t, "-C", source, "commit", "--allow-empty", "-m", "first") | |
| 39 | + | bin, err := exec.LookPath("git") | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | _, key, err := ed25519.GenerateKey(rand.Reader) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | return Config{Bin: bin, Path: os.Getenv("PATH"), Home: t.TempDir(), KnownHosts: filepath.Join(root, "mirror_known_hosts"), TempDir: filepath.Join(root, "keys"), RepoDir: func(domain.RepoName) string { return source }, AllowedProtocols: "file", Timeout: 5 * time.Second}, ports.MirrorPush{PrivateKey: key, Refspecs: []string{"refs/heads/main:refs/heads/main"}} | |
| 44 | + | } | |
| 45 | + | func clean(t *testing.T, cfg Config) { | |
| 46 | + | t.Helper() | |
| 47 | + | entries, err := os.ReadDir(cfg.TempDir) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | require.Empty(t, entries) | |
| 50 | + | } | |
| 51 | + | func TestPushLocal(t *testing.T) { | |
| 52 | + | cfg, req := setup(t) | |
| 53 | + | remote := filepath.Join(t.TempDir(), "remote.git") | |
| 54 | + | git(t, "init", "--bare", remote) | |
| 55 | + | req.URL = "file://" + remote | |
| 56 | + | p := New(cfg) | |
| 57 | + | result, err := p.Push(context.Background(), req) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | require.Len(t, result.Refs, 1) | |
| 60 | + | require.Equal(t, ports.MirrorRefOK, result.Refs[0].Status) | |
| 61 | + | clean(t, cfg) | |
| 62 | + | result, err = p.Push(context.Background(), req) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.Equal(t, ports.MirrorRefUpToDate, result.Refs[0].Status) | |
| 65 | + | clean(t, cfg) | |
| 66 | + | other := filepath.Join(t.TempDir(), "other") | |
| 67 | + | git(t, "init", "-b", "main", other) | |
| 68 | + | git(t, "-C", other, "commit", "--allow-empty", "-m", "unrelated") | |
| 69 | + | git(t, "-C", other, "push", "file://"+remote, "refs/heads/main:refs/heads/other") | |
| 70 | + | // Move remote main locally, without a force push, to establish divergence. | |
| 71 | + | otherID := git(t, "-C", other, "rev-parse", "HEAD") | |
| 72 | + | git(t, "--git-dir", remote, "update-ref", "refs/heads/main", otherID) | |
| 73 | + | _, err = p.Push(context.Background(), req) | |
| 74 | + | var me *domain.MirrorError | |
| 75 | + | require.ErrorAs(t, err, &me) | |
| 76 | + | require.Equal(t, domain.MirrorClassDiverged, me.Class) | |
| 77 | + | require.Equal(t, otherID, git(t, "--git-dir", remote, "rev-parse", "refs/heads/main")) | |
| 78 | + | clean(t, cfg) | |
| 79 | + | req.URL = "file://" + filepath.Join(t.TempDir(), "missing.git") | |
| 80 | + | _, err = p.Push(context.Background(), req) | |
| 81 | + | require.ErrorAs(t, err, &me) | |
| 82 | + | require.Equal(t, domain.MirrorClassRemoteNotFound, me.Class) | |
| 83 | + | clean(t, cfg) | |
| 84 | + | } | |
| 85 | + | func TestBoundsAndKeyEnvironment(t *testing.T) { | |
| 86 | + | for _, mode := range []string{"success", "error", "timeout", "stdout", "stderr", "cancel"} { | |
| 87 | + | t.Run(mode, func(t *testing.T) { | |
| 88 | + | cfg, req := setup(t) | |
| 89 | + | req.URL = "file:///unused" | |
| 90 | + | script := filepath.Join(t.TempDir(), "git") | |
| 91 | + | report := filepath.Join(t.TempDir(), "env") | |
| 92 | + | body := "#!/bin/sh\nenv > '" + report + "'\nfor d in '" + cfg.TempDir + "'/push-*; do\n test \"$(stat -c %a \"$d\")\" = 700 || exit 90\n for k in \"$d\"/key-*; do test \"$(stat -c %a \"$k\")\" = 600 || exit 91; done\ndone\n" | |
| 93 | + | switch mode { | |
| 94 | + | case "error": | |
| 95 | + | body += "echo 'Permission denied (publickey)' >&2; exit 1\n" | |
| 96 | + | case "timeout", "cancel": | |
| 97 | + | body += "sleep 10\n" | |
| 98 | + | case "stdout": | |
| 99 | + | body += "head -c 4096 /dev/zero\n" | |
| 100 | + | case "stderr": | |
| 101 | + | body += "head -c 4096 /dev/zero >&2\n" | |
| 102 | + | } | |
| 103 | + | require.NoError(t, os.WriteFile(script, []byte(body), 0700)) | |
| 104 | + | cfg.Bin = script | |
| 105 | + | cfg.MaxOutput = 1024 | |
| 106 | + | cfg.Timeout = 200 * time.Millisecond | |
| 107 | + | t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded") | |
| 108 | + | ctx := context.Background() | |
| 109 | + | if mode == "cancel" { | |
| 110 | + | var cancel context.CancelFunc | |
| 111 | + | ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond) | |
| 112 | + | defer cancel() | |
| 113 | + | } | |
| 114 | + | _, err := New(cfg).Push(ctx, req) | |
| 115 | + | switch mode { | |
| 116 | + | case "success": | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | case "cancel": | |
| 119 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 120 | + | default: | |
| 121 | + | var me *domain.MirrorError | |
| 122 | + | require.ErrorAs(t, err, &me) | |
| 123 | + | want := domain.MirrorClassTransient | |
| 124 | + | if mode == "error" { | |
| 125 | + | want = domain.MirrorClassAuthDenied | |
| 126 | + | } | |
| 127 | + | require.Equal(t, want, me.Class) | |
| 128 | + | } | |
| 129 | + | clean(t, cfg) | |
| 130 | + | env, e := os.ReadFile(report) | |
| 131 | + | require.NoError(t, e) | |
| 132 | + | require.NotContains(t, string(env), "ERMITE_") | |
| 133 | + | require.Contains(t, string(env), "IdentitiesOnly=yes") | |
| 134 | + | require.Contains(t, string(env), "BatchMode=yes") | |
| 135 | + | require.Contains(t, string(env), "StrictHostKeyChecking=accept-new") | |
| 136 | + | require.Contains(t, string(env), "UserKnownHostsFile=") | |
| 137 | + | }) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | func TestClassifierSamples(t *testing.T) { | |
| 141 | + | for _, tc := range []struct { | |
| 142 | + | s string | |
| 143 | + | c domain.MirrorFailureClass | |
| 144 | + | }{{"Permission denied (publickey)", domain.MirrorClassAuthDenied}, {"Repository not found", domain.MirrorClassRemoteNotFound}, {"Host key verification failed", domain.MirrorClassHostKey}, {"Could not resolve hostname", domain.MirrorClassTransient}, {"non-fast-forward", domain.MirrorClassDiverged}} { | |
| 145 | + | require.Equal(t, tc.c, domain.ClassifyMirrorOutput(tc.s, errors.New("exit 1"), false)) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | func TestRejectUnsafeRefspecs(t *testing.T) { | |
| 149 | + | for _, ref := range []string{"+refs/heads/main:refs/heads/main", ":refs/heads/main", "--mirror", "refs/heads/*:refs/heads/*", "refs/heads/main:refs/heads/other"} { | |
| 150 | + | require.False(t, validRefspec(ref)) | |
| 151 | + | } | |
| 152 | + | require.True(t, validRefspec("refs/tags/v1:refs/tags/v1")) | |
| 153 | + | } | |
| 154 | + | func TestSafeDetail(t *testing.T) { | |
| 155 | + | s := safeDetail(strings.Repeat("x", 5000)+" ssh://user:password@host/repo user@host", "ssh://user:password@host/repo") | |
| 156 | + | require.LessOrEqual(t, len(s), 4096) | |
| 157 | + | require.NotContains(t, s, "password") | |
| 158 | + | require.NotContains(t, s, "user@") | |
| 159 | + | } | |
| @@ -0,0 +1,159 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitpush | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/ed25519" | |||
| 8 | + | "crypto/rand" | |||
| 9 | + | "errors" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func git(t *testing.T, args ...string) string { | |||
| 24 | + | t.Helper() | |||
| 25 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 26 | + | defer cancel() | |||
| 27 | + | cmd := exec.CommandContext(ctx, "git", args...) | |||
| 28 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.org", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.org"} | |||
| 29 | + | out, err := cmd.CombinedOutput() | |||
| 30 | + | require.NoError(t, err, string(out)) | |||
| 31 | + | return strings.TrimSpace(string(out)) | |||
| 32 | + | } | |||
| 33 | + | func setup(t *testing.T) (Config, ports.MirrorPush) { | |||
| 34 | + | t.Helper() | |||
| 35 | + | root := t.TempDir() | |||
| 36 | + | source := filepath.Join(root, "source") | |||
| 37 | + | git(t, "init", "-b", "main", source) | |||
| 38 | + | git(t, "-C", source, "commit", "--allow-empty", "-m", "first") | |||
| 39 | + | bin, err := exec.LookPath("git") | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | _, key, err := ed25519.GenerateKey(rand.Reader) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | return Config{Bin: bin, Path: os.Getenv("PATH"), Home: t.TempDir(), KnownHosts: filepath.Join(root, "mirror_known_hosts"), TempDir: filepath.Join(root, "keys"), RepoDir: func(domain.RepoName) string { return source }, AllowedProtocols: "file", Timeout: 5 * time.Second}, ports.MirrorPush{PrivateKey: key, Refspecs: []string{"refs/heads/main:refs/heads/main"}} | |||
| 44 | + | } | |||
| 45 | + | func clean(t *testing.T, cfg Config) { | |||
| 46 | + | t.Helper() | |||
| 47 | + | entries, err := os.ReadDir(cfg.TempDir) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | require.Empty(t, entries) | |||
| 50 | + | } | |||
| 51 | + | func TestPushLocal(t *testing.T) { | |||
| 52 | + | cfg, req := setup(t) | |||
| 53 | + | remote := filepath.Join(t.TempDir(), "remote.git") | |||
| 54 | + | git(t, "init", "--bare", remote) | |||
| 55 | + | req.URL = "file://" + remote | |||
| 56 | + | p := New(cfg) | |||
| 57 | + | result, err := p.Push(context.Background(), req) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | require.Len(t, result.Refs, 1) | |||
| 60 | + | require.Equal(t, ports.MirrorRefOK, result.Refs[0].Status) | |||
| 61 | + | clean(t, cfg) | |||
| 62 | + | result, err = p.Push(context.Background(), req) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.Equal(t, ports.MirrorRefUpToDate, result.Refs[0].Status) | |||
| 65 | + | clean(t, cfg) | |||
| 66 | + | other := filepath.Join(t.TempDir(), "other") | |||
| 67 | + | git(t, "init", "-b", "main", other) | |||
| 68 | + | git(t, "-C", other, "commit", "--allow-empty", "-m", "unrelated") | |||
| 69 | + | git(t, "-C", other, "push", "file://"+remote, "refs/heads/main:refs/heads/other") | |||
| 70 | + | // Move remote main locally, without a force push, to establish divergence. | |||
| 71 | + | otherID := git(t, "-C", other, "rev-parse", "HEAD") | |||
| 72 | + | git(t, "--git-dir", remote, "update-ref", "refs/heads/main", otherID) | |||
| 73 | + | _, err = p.Push(context.Background(), req) | |||
| 74 | + | var me *domain.MirrorError | |||
| 75 | + | require.ErrorAs(t, err, &me) | |||
| 76 | + | require.Equal(t, domain.MirrorClassDiverged, me.Class) | |||
| 77 | + | require.Equal(t, otherID, git(t, "--git-dir", remote, "rev-parse", "refs/heads/main")) | |||
| 78 | + | clean(t, cfg) | |||
| 79 | + | req.URL = "file://" + filepath.Join(t.TempDir(), "missing.git") | |||
| 80 | + | _, err = p.Push(context.Background(), req) | |||
| 81 | + | require.ErrorAs(t, err, &me) | |||
| 82 | + | require.Equal(t, domain.MirrorClassRemoteNotFound, me.Class) | |||
| 83 | + | clean(t, cfg) | |||
| 84 | + | } | |||
| 85 | + | func TestBoundsAndKeyEnvironment(t *testing.T) { | |||
| 86 | + | for _, mode := range []string{"success", "error", "timeout", "stdout", "stderr", "cancel"} { | |||
| 87 | + | t.Run(mode, func(t *testing.T) { | |||
| 88 | + | cfg, req := setup(t) | |||
| 89 | + | req.URL = "file:///unused" | |||
| 90 | + | script := filepath.Join(t.TempDir(), "git") | |||
| 91 | + | report := filepath.Join(t.TempDir(), "env") | |||
| 92 | + | body := "#!/bin/sh\nenv > '" + report + "'\nfor d in '" + cfg.TempDir + "'/push-*; do\n test \"$(stat -c %a \"$d\")\" = 700 || exit 90\n for k in \"$d\"/key-*; do test \"$(stat -c %a \"$k\")\" = 600 || exit 91; done\ndone\n" | |||
| 93 | + | switch mode { | |||
| 94 | + | case "error": | |||
| 95 | + | body += "echo 'Permission denied (publickey)' >&2; exit 1\n" | |||
| 96 | + | case "timeout", "cancel": | |||
| 97 | + | body += "sleep 10\n" | |||
| 98 | + | case "stdout": | |||
| 99 | + | body += "head -c 4096 /dev/zero\n" | |||
| 100 | + | case "stderr": | |||
| 101 | + | body += "head -c 4096 /dev/zero >&2\n" | |||
| 102 | + | } | |||
| 103 | + | require.NoError(t, os.WriteFile(script, []byte(body), 0700)) | |||
| 104 | + | cfg.Bin = script | |||
| 105 | + | cfg.MaxOutput = 1024 | |||
| 106 | + | cfg.Timeout = 200 * time.Millisecond | |||
| 107 | + | t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded") | |||
| 108 | + | ctx := context.Background() | |||
| 109 | + | if mode == "cancel" { | |||
| 110 | + | var cancel context.CancelFunc | |||
| 111 | + | ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond) | |||
| 112 | + | defer cancel() | |||
| 113 | + | } | |||
| 114 | + | _, err := New(cfg).Push(ctx, req) | |||
| 115 | + | switch mode { | |||
| 116 | + | case "success": | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | case "cancel": | |||
| 119 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |||
| 120 | + | default: | |||
| 121 | + | var me *domain.MirrorError | |||
| 122 | + | require.ErrorAs(t, err, &me) | |||
| 123 | + | want := domain.MirrorClassTransient | |||
| 124 | + | if mode == "error" { | |||
| 125 | + | want = domain.MirrorClassAuthDenied | |||
| 126 | + | } | |||
| 127 | + | require.Equal(t, want, me.Class) | |||
| 128 | + | } | |||
| 129 | + | clean(t, cfg) | |||
| 130 | + | env, e := os.ReadFile(report) | |||
| 131 | + | require.NoError(t, e) | |||
| 132 | + | require.NotContains(t, string(env), "ERMITE_") | |||
| 133 | + | require.Contains(t, string(env), "IdentitiesOnly=yes") | |||
| 134 | + | require.Contains(t, string(env), "BatchMode=yes") | |||
| 135 | + | require.Contains(t, string(env), "StrictHostKeyChecking=accept-new") | |||
| 136 | + | require.Contains(t, string(env), "UserKnownHostsFile=") | |||
| 137 | + | }) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | func TestClassifierSamples(t *testing.T) { | |||
| 141 | + | for _, tc := range []struct { | |||
| 142 | + | s string | |||
| 143 | + | c domain.MirrorFailureClass | |||
| 144 | + | }{{"Permission denied (publickey)", domain.MirrorClassAuthDenied}, {"Repository not found", domain.MirrorClassRemoteNotFound}, {"Host key verification failed", domain.MirrorClassHostKey}, {"Could not resolve hostname", domain.MirrorClassTransient}, {"non-fast-forward", domain.MirrorClassDiverged}} { | |||
| 145 | + | require.Equal(t, tc.c, domain.ClassifyMirrorOutput(tc.s, errors.New("exit 1"), false)) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | func TestRejectUnsafeRefspecs(t *testing.T) { | |||
| 149 | + | for _, ref := range []string{"+refs/heads/main:refs/heads/main", ":refs/heads/main", "--mirror", "refs/heads/*:refs/heads/*", "refs/heads/main:refs/heads/other"} { | |||
| 150 | + | require.False(t, validRefspec(ref)) | |||
| 151 | + | } | |||
| 152 | + | require.True(t, validRefspec("refs/tags/v1:refs/tags/v1")) | |||
| 153 | + | } | |||
| 154 | + | func TestSafeDetail(t *testing.T) { | |||
| 155 | + | s := safeDetail(strings.Repeat("x", 5000)+" ssh://user:password@host/repo user@host", "ssh://user:password@host/repo") | |||
| 156 | + | require.LessOrEqual(t, len(s), 4096) | |||
| 157 | + | require.NotContains(t, s, "password") | |||
| 158 | + | require.NotContains(t, s, "user@") | |||
| 159 | + | } | |||
A internal/adapters/gitpush/run_linux.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build linux | |
| 4 | + | ||
| 5 | + | package gitpush | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "os/exec" | |
| 12 | + | "syscall" | |
| 13 | + | ||
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Tests replace these two. | |
| 18 | + | var ( | |
| 19 | + | waitid = unix.Waitid | |
| 20 | + | killGroup = func(pgid int) error { return syscall.Kill(-pgid, syscall.SIGKILL) } | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // run starts cmd, waits for it, and kills what it leaves running. | |
| 24 | + | // | |
| 25 | + | // git runs git-remote-https, which is the process that connects and that holds | |
| 26 | + | // the pipes of git's output. Killing git alone leaves it running, and Wait | |
| 27 | + | // blocked on the pipes. So git leads a process group of its own, and the group | |
| 28 | + | // is killed when ctx is done and also when git has exited. | |
| 29 | + | // | |
| 30 | + | // The invariant that makes killing a group safe: the group is only signalled | |
| 31 | + | // while its leader is still not reaped. Linux does not reuse a PID as long as | |
| 32 | + | // the process is a zombie nobody waited for, nor while it is the group ID of a | |
| 33 | + | // live member, so -pid is still our group and cannot be another one. The | |
| 34 | + | // leader is therefore waited for with waitid(WNOWAIT), which observes its exit | |
| 35 | + | // without reaping it; the group is signalled; and only then cmd.Wait reaps. A | |
| 36 | + | // kill(-pid) after Wait, or from Cmd.Cancel which races with it, would not be | |
| 37 | + | // safe. When waitid fails, whatever the reason, nothing proves the leader is | |
| 38 | + | // unreaped: the group is not signalled, only the leader is killed (through | |
| 39 | + | // its pidfd, which cannot hit a reused PID), and the error is returned. | |
| 40 | + | // cmd.WaitDelay (set by the caller) stays the backstop for a descendant that | |
| 41 | + | // escaped the group and holds the pipes. | |
| 42 | + | func run(ctx context.Context, cmd *exec.Cmd) error { | |
| 43 | + | if err := ctx.Err(); err != nil { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 47 | + | if err := cmd.Start(); err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | pid := cmd.Process.Pid | |
| 51 | + | observed := make(chan error, 1) | |
| 52 | + | go func() { observed <- waitExit(pid) }() | |
| 53 | + | ||
| 54 | + | var werr error | |
| 55 | + | select { | |
| 56 | + | case err := <-observed: | |
| 57 | + | if err != nil { | |
| 58 | + | werr = err // the leader may have been reaped: no group signal | |
| 59 | + | } else { | |
| 60 | + | signalGroup(cmd, pid) // exited, not reaped: a zombie | |
| 61 | + | } | |
| 62 | + | case <-ctx.Done(): | |
| 63 | + | select { | |
| 64 | + | case err := <-observed: | |
| 65 | + | // waitid answered at the same time: trust it as above. | |
| 66 | + | if err != nil { | |
| 67 | + | werr = err | |
| 68 | + | } else { | |
| 69 | + | signalGroup(cmd, pid) | |
| 70 | + | } | |
| 71 | + | default: | |
| 72 | + | // No waitid error so far: the leader is alive or a zombie, unreaped | |
| 73 | + | // (only cmd.Wait below reaps it). | |
| 74 | + | signalGroup(cmd, pid) | |
| 75 | + | if err := <-observed; err != nil { | |
| 76 | + | werr = err // after the signals: reported, nothing more to do | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | if werr != nil { | |
| 81 | + | _ = cmd.Process.Kill() | |
| 82 | + | } | |
| 83 | + | err := cmd.Wait() | |
| 84 | + | if werr != nil { | |
| 85 | + | return fmt.Errorf("waiting for the process %d: %w", pid, werr) | |
| 86 | + | } | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // signalGroup kills the group of the leader, and the leader alone should it | |
| 91 | + | // have left the group. The leader must not be reaped yet. | |
| 92 | + | func signalGroup(cmd *exec.Cmd, pid int) { | |
| 93 | + | _ = killGroup(pid) | |
| 94 | + | _ = cmd.Process.Kill() | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // waitExit returns when the child pid has exited, without reaping it, or the | |
| 98 | + | // error of waitid. | |
| 99 | + | func waitExit(pid int) error { | |
| 100 | + | var info unix.Siginfo | |
| 101 | + | for { | |
| 102 | + | err := waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil) | |
| 103 | + | if !errors.Is(err, unix.EINTR) { | |
| 104 | + | return err | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | //go:build linux | |||
| 4 | + | ||||
| 5 | + | package gitpush | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "syscall" | |||
| 13 | + | ||||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Tests replace these two. | |||
| 18 | + | var ( | |||
| 19 | + | waitid = unix.Waitid | |||
| 20 | + | killGroup = func(pgid int) error { return syscall.Kill(-pgid, syscall.SIGKILL) } | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // run starts cmd, waits for it, and kills what it leaves running. | |||
| 24 | + | // | |||
| 25 | + | // git runs git-remote-https, which is the process that connects and that holds | |||
| 26 | + | // the pipes of git's output. Killing git alone leaves it running, and Wait | |||
| 27 | + | // blocked on the pipes. So git leads a process group of its own, and the group | |||
| 28 | + | // is killed when ctx is done and also when git has exited. | |||
| 29 | + | // | |||
| 30 | + | // The invariant that makes killing a group safe: the group is only signalled | |||
| 31 | + | // while its leader is still not reaped. Linux does not reuse a PID as long as | |||
| 32 | + | // the process is a zombie nobody waited for, nor while it is the group ID of a | |||
| 33 | + | // live member, so -pid is still our group and cannot be another one. The | |||
| 34 | + | // leader is therefore waited for with waitid(WNOWAIT), which observes its exit | |||
| 35 | + | // without reaping it; the group is signalled; and only then cmd.Wait reaps. A | |||
| 36 | + | // kill(-pid) after Wait, or from Cmd.Cancel which races with it, would not be | |||
| 37 | + | // safe. When waitid fails, whatever the reason, nothing proves the leader is | |||
| 38 | + | // unreaped: the group is not signalled, only the leader is killed (through | |||
| 39 | + | // its pidfd, which cannot hit a reused PID), and the error is returned. | |||
| 40 | + | // cmd.WaitDelay (set by the caller) stays the backstop for a descendant that | |||
| 41 | + | // escaped the group and holds the pipes. | |||
| 42 | + | func run(ctx context.Context, cmd *exec.Cmd) error { | |||
| 43 | + | if err := ctx.Err(); err != nil { | |||
| 44 | + | return err | |||
| 45 | + | } | |||
| 46 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 47 | + | if err := cmd.Start(); err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | pid := cmd.Process.Pid | |||
| 51 | + | observed := make(chan error, 1) | |||
| 52 | + | go func() { observed <- waitExit(pid) }() | |||
| 53 | + | ||||
| 54 | + | var werr error | |||
| 55 | + | select { | |||
| 56 | + | case err := <-observed: | |||
| 57 | + | if err != nil { | |||
| 58 | + | werr = err // the leader may have been reaped: no group signal | |||
| 59 | + | } else { | |||
| 60 | + | signalGroup(cmd, pid) // exited, not reaped: a zombie | |||
| 61 | + | } | |||
| 62 | + | case <-ctx.Done(): | |||
| 63 | + | select { | |||
| 64 | + | case err := <-observed: | |||
| 65 | + | // waitid answered at the same time: trust it as above. | |||
| 66 | + | if err != nil { | |||
| 67 | + | werr = err | |||
| 68 | + | } else { | |||
| 69 | + | signalGroup(cmd, pid) | |||
| 70 | + | } | |||
| 71 | + | default: | |||
| 72 | + | // No waitid error so far: the leader is alive or a zombie, unreaped | |||
| 73 | + | // (only cmd.Wait below reaps it). | |||
| 74 | + | signalGroup(cmd, pid) | |||
| 75 | + | if err := <-observed; err != nil { | |||
| 76 | + | werr = err // after the signals: reported, nothing more to do | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | if werr != nil { | |||
| 81 | + | _ = cmd.Process.Kill() | |||
| 82 | + | } | |||
| 83 | + | err := cmd.Wait() | |||
| 84 | + | if werr != nil { | |||
| 85 | + | return fmt.Errorf("waiting for the process %d: %w", pid, werr) | |||
| 86 | + | } | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // signalGroup kills the group of the leader, and the leader alone should it | |||
| 91 | + | // have left the group. The leader must not be reaped yet. | |||
| 92 | + | func signalGroup(cmd *exec.Cmd, pid int) { | |||
| 93 | + | _ = killGroup(pid) | |||
| 94 | + | _ = cmd.Process.Kill() | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // waitExit returns when the child pid has exited, without reaping it, or the | |||
| 98 | + | // error of waitid. | |||
| 99 | + | func waitExit(pid int) error { | |||
| 100 | + | var info unix.Siginfo | |||
| 101 | + | for { | |||
| 102 | + | err := waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil) | |||
| 103 | + | if !errors.Is(err, unix.EINTR) { | |||
| 104 | + | return err | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
A internal/adapters/kvstore/mirror.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | kv "bnema.dev/ermite-kv" | |
| 13 | + | "google.golang.org/protobuf/proto" | |
| 14 | + | ||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var _ ports.MirrorStore = (*Store)(nil) | |
| 21 | + | var _ ports.MirrorKeyStore = (*Store)(nil) | |
| 22 | + | ||
| 23 | + | const mirrorConfigKey = "mirror/config" | |
| 24 | + | const mirrorRepoPrefix = "mirror/repo/" | |
| 25 | + | const mirrorStatePrefix = "mirror/state/" | |
| 26 | + | const mirrorSnapshotPrefix = "mirror/snapshot/" | |
| 27 | + | ||
| 28 | + | func mirrorKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 29 | + | var keys [][]byte | |
| 30 | + | for _, prefix := range []string{mirrorRepoPrefix, mirrorStatePrefix, mirrorSnapshotPrefix} { | |
| 31 | + | key := []byte(prefix + repo.String()) | |
| 32 | + | if _, ok := snap.Get(ctx, key); ok { | |
| 33 | + | keys = append(keys, key) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return keys | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (s *Store) readMirror(ctx context.Context, key string, pb proto.Message) error { | |
| 40 | + | item, ok := s.db.Get(ctx, []byte(key)) | |
| 41 | + | if !ok { | |
| 42 | + | return domain.ErrNotFound | |
| 43 | + | } | |
| 44 | + | if err := proto.Unmarshal(item.Value, pb); err != nil { | |
| 45 | + | return fmt.Errorf("decode mirror record: %w", err) | |
| 46 | + | } | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error { | |
| 51 | + | val := mustMarshal(pb) | |
| 52 | + | for range updateAttempts { | |
| 53 | + | var b kv.Batch | |
| 54 | + | if key != mirrorConfigKey { | |
| 55 | + | if err := guardRepoKey(ctx, s.db, &b, repo); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | b.Put([]byte(key), val) | |
| 60 | + | _, err := s.db.Apply(ctx, &b) | |
| 61 | + | if errors.Is(err, kv.ErrConflict) { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | if err != nil { | |
| 65 | + | return fmt.Errorf("store mirror record: %w", err) | |
| 66 | + | } | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | return domain.ErrConflict | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) { | |
| 73 | + | var pb storagepb.MirrorGlobal | |
| 74 | + | err := s.readMirror(ctx, mirrorConfigKey, &pb) | |
| 75 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 76 | + | err = nil | |
| 77 | + | } | |
| 78 | + | return domain.MirrorGlobalConfig{Enabled: pb.Enabled, URLTemplate: pb.UrlTemplate, Cron: pb.Cron, AllBranches: pb.AllBranches, UpdatedAt: decodeTime(pb.UpdatedAt)}, err | |
| 79 | + | } | |
| 80 | + | func (s *Store) PutGlobal(ctx context.Context, g domain.MirrorGlobalConfig) error { | |
| 81 | + | return s.writeMirror(ctx, mirrorConfigKey, domain.RepoName{}, &storagepb.MirrorGlobal{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: encodeTime(g.UpdatedAt)}) | |
| 82 | + | } | |
| 83 | + | func decodeMirrorRepo(repo domain.RepoName, pb *storagepb.MirrorRepo) domain.MirrorRepoConfig { | |
| 84 | + | return domain.MirrorRepoConfig{Repo: repo, URL: pb.Url, Cron: pb.Cron, AllBranches: pb.AllBranches, Excluded: pb.Excluded, UpdatedAt: decodeTime(pb.UpdatedAt)} | |
| 85 | + | } | |
| 86 | + | func (s *Store) GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) { | |
| 87 | + | var pb storagepb.MirrorRepo | |
| 88 | + | err := s.readMirror(ctx, mirrorRepoPrefix+repo.String(), &pb) | |
| 89 | + | return decodeMirrorRepo(repo, &pb), err | |
| 90 | + | } | |
| 91 | + | func (s *Store) PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error { | |
| 92 | + | return s.writeMirror(ctx, mirrorRepoPrefix+c.Repo.String(), c.Repo, &storagepb.MirrorRepo{Url: c.URL, Cron: c.Cron, AllBranches: c.AllBranches, Excluded: c.Excluded, UpdatedAt: encodeTime(c.UpdatedAt)}) | |
| 93 | + | } | |
| 94 | + | func (s *Store) DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error { | |
| 95 | + | var b kv.Batch | |
| 96 | + | b.Delete([]byte(mirrorRepoPrefix + repo.String())) | |
| 97 | + | _, err := s.db.Apply(ctx, &b) | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("delete mirror config: %w", err) | |
| 100 | + | } | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | func (s *Store) ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) { | |
| 104 | + | var out []domain.MirrorRepoConfig | |
| 105 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorRepoPrefix)) { | |
| 106 | + | repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorRepoPrefix)) | |
| 107 | + | if err != nil { | |
| 108 | + | return nil, err | |
| 109 | + | } | |
| 110 | + | var pb storagepb.MirrorRepo | |
| 111 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 112 | + | return nil, fmt.Errorf("decode mirror config: %w", err) | |
| 113 | + | } | |
| 114 | + | out = append(out, decodeMirrorRepo(repo, &pb)) | |
| 115 | + | } | |
| 116 | + | return out, nil | |
| 117 | + | } | |
| 118 | + | func decodeMirrorStatus(repo domain.RepoName, pb *storagepb.MirrorState) domain.MirrorStatus { | |
| 119 | + | return domain.MirrorStatus{Repo: repo, State: domain.MirrorState(pb.State), Class: domain.MirrorFailureClass(pb.Class), Reason: pb.Reason, Detail: pb.Detail, ConsecutiveFailures: int(pb.ConsecutiveFailures), LastAttemptAt: decodeTime(pb.LastAttemptAt), LastSuccessAt: decodeTime(pb.LastSuccessAt), NextRetryAt: decodeTime(pb.NextRetryAt), PausedAt: decodeTime(pb.PausedAt), UpdatedAt: decodeTime(pb.UpdatedAt)} | |
| 120 | + | } | |
| 121 | + | func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) { | |
| 122 | + | var pb storagepb.MirrorState | |
| 123 | + | err := s.readMirror(ctx, mirrorStatePrefix+repo.String(), &pb) | |
| 124 | + | return decodeMirrorStatus(repo, &pb), err | |
| 125 | + | } | |
| 126 | + | func (s *Store) PutStatus(ctx context.Context, c domain.MirrorStatus) error { | |
| 127 | + | return s.writeMirror(ctx, mirrorStatePrefix+c.Repo.String(), c.Repo, &storagepb.MirrorState{State: string(c.State), Class: string(c.Class), Reason: c.Reason, Detail: c.Detail, ConsecutiveFailures: int32(c.ConsecutiveFailures), LastAttemptAt: encodeTime(c.LastAttemptAt), LastSuccessAt: encodeTime(c.LastSuccessAt), NextRetryAt: encodeTime(c.NextRetryAt), PausedAt: encodeTime(c.PausedAt), UpdatedAt: encodeTime(c.UpdatedAt)}) | |
| 128 | + | } | |
| 129 | + | func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) { | |
| 130 | + | var out []domain.MirrorStatus | |
| 131 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorStatePrefix)) { | |
| 132 | + | repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorStatePrefix)) | |
| 133 | + | if err != nil { | |
| 134 | + | return nil, err | |
| 135 | + | } | |
| 136 | + | var pb storagepb.MirrorState | |
| 137 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 138 | + | return nil, fmt.Errorf("decode mirror state: %w", err) | |
| 139 | + | } | |
| 140 | + | out = append(out, decodeMirrorStatus(repo, &pb)) | |
| 141 | + | } | |
| 142 | + | return out, nil | |
| 143 | + | } | |
| 144 | + | func (s *Store) GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) { | |
| 145 | + | var pb storagepb.MirrorSnapshot | |
| 146 | + | err := s.readMirror(ctx, mirrorSnapshotPrefix+repo.String(), &pb) | |
| 147 | + | out := domain.MirrorSnapshot{URL: pb.Url, AllBranches: pb.AllBranches, At: decodeTime(pb.At)} | |
| 148 | + | for _, ref := range pb.Refs { | |
| 149 | + | id, parseErr := domain.ParseObjectID(ref.Id) | |
| 150 | + | if parseErr != nil { | |
| 151 | + | return domain.MirrorSnapshot{}, fmt.Errorf("decode mirror snapshot ref: %w", parseErr) | |
| 152 | + | } | |
| 153 | + | out.Refs = append(out.Refs, domain.MirrorRef{Name: ref.Name, ID: id}) | |
| 154 | + | } | |
| 155 | + | return out, err | |
| 156 | + | } | |
| 157 | + | func (s *Store) PutSnapshot(ctx context.Context, repo domain.RepoName, c domain.MirrorSnapshot) error { | |
| 158 | + | pb := &storagepb.MirrorSnapshot{Url: c.URL, AllBranches: c.AllBranches, At: encodeTime(c.At)} | |
| 159 | + | for _, ref := range c.Refs { | |
| 160 | + | pb.Refs = append(pb.Refs, &storagepb.MirrorSnapshotRef{Name: ref.Name, Id: ref.ID.String()}) | |
| 161 | + | } | |
| 162 | + | return s.writeMirror(ctx, mirrorSnapshotPrefix+repo.String(), repo, pb) | |
| 163 | + | } | |
| 164 | + | func (s *Store) GetMirrorKey(ctx context.Context) ([]byte, time.Time, error) { | |
| 165 | + | var pb storagepb.MirrorKey | |
| 166 | + | err := s.readMirror(ctx, domain.MirrorKeyPath, &pb) | |
| 167 | + | return pb.Sealed, decodeTime(pb.CreatedAt), err | |
| 168 | + | } | |
| 169 | + | func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, ifAbsent bool) error { | |
| 170 | + | var b kv.Batch | |
| 171 | + | key := []byte(domain.MirrorKeyPath) | |
| 172 | + | if ifAbsent { | |
| 173 | + | b.RequireAbsent(key) | |
| 174 | + | } | |
| 175 | + | b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)})) | |
| 176 | + | _, err := s.db.Apply(ctx, &b) | |
| 177 | + | if ifAbsent && errors.Is(err, kv.ErrConflict) { | |
| 178 | + | return domain.ErrAlreadyExists | |
| 179 | + | } | |
| 180 | + | if err != nil { | |
| 181 | + | return fmt.Errorf("store mirror key: %w", err) | |
| 182 | + | } | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // RawMirrorKey exposes the stored protobuf for encryption-at-rest tests. | |
| 187 | + | func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) { | |
| 188 | + | item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath)) | |
| 189 | + | return item.Value, ok | |
| 190 | + | } | |
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | kv "bnema.dev/ermite-kv" | |||
| 13 | + | "google.golang.org/protobuf/proto" | |||
| 14 | + | ||||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var _ ports.MirrorStore = (*Store)(nil) | |||
| 21 | + | var _ ports.MirrorKeyStore = (*Store)(nil) | |||
| 22 | + | ||||
| 23 | + | const mirrorConfigKey = "mirror/config" | |||
| 24 | + | const mirrorRepoPrefix = "mirror/repo/" | |||
| 25 | + | const mirrorStatePrefix = "mirror/state/" | |||
| 26 | + | const mirrorSnapshotPrefix = "mirror/snapshot/" | |||
| 27 | + | ||||
| 28 | + | func mirrorKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |||
| 29 | + | var keys [][]byte | |||
| 30 | + | for _, prefix := range []string{mirrorRepoPrefix, mirrorStatePrefix, mirrorSnapshotPrefix} { | |||
| 31 | + | key := []byte(prefix + repo.String()) | |||
| 32 | + | if _, ok := snap.Get(ctx, key); ok { | |||
| 33 | + | keys = append(keys, key) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return keys | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (s *Store) readMirror(ctx context.Context, key string, pb proto.Message) error { | |||
| 40 | + | item, ok := s.db.Get(ctx, []byte(key)) | |||
| 41 | + | if !ok { | |||
| 42 | + | return domain.ErrNotFound | |||
| 43 | + | } | |||
| 44 | + | if err := proto.Unmarshal(item.Value, pb); err != nil { | |||
| 45 | + | return fmt.Errorf("decode mirror record: %w", err) | |||
| 46 | + | } | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error { | |||
| 51 | + | val := mustMarshal(pb) | |||
| 52 | + | for range updateAttempts { | |||
| 53 | + | var b kv.Batch | |||
| 54 | + | if key != mirrorConfigKey { | |||
| 55 | + | if err := guardRepoKey(ctx, s.db, &b, repo); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | b.Put([]byte(key), val) | |||
| 60 | + | _, err := s.db.Apply(ctx, &b) | |||
| 61 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | if err != nil { | |||
| 65 | + | return fmt.Errorf("store mirror record: %w", err) | |||
| 66 | + | } | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | return domain.ErrConflict | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) { | |||
| 73 | + | var pb storagepb.MirrorGlobal | |||
| 74 | + | err := s.readMirror(ctx, mirrorConfigKey, &pb) | |||
| 75 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 76 | + | err = nil | |||
| 77 | + | } | |||
| 78 | + | return domain.MirrorGlobalConfig{Enabled: pb.Enabled, URLTemplate: pb.UrlTemplate, Cron: pb.Cron, AllBranches: pb.AllBranches, UpdatedAt: decodeTime(pb.UpdatedAt)}, err | |||
| 79 | + | } | |||
| 80 | + | func (s *Store) PutGlobal(ctx context.Context, g domain.MirrorGlobalConfig) error { | |||
| 81 | + | return s.writeMirror(ctx, mirrorConfigKey, domain.RepoName{}, &storagepb.MirrorGlobal{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: encodeTime(g.UpdatedAt)}) | |||
| 82 | + | } | |||
| 83 | + | func decodeMirrorRepo(repo domain.RepoName, pb *storagepb.MirrorRepo) domain.MirrorRepoConfig { | |||
| 84 | + | return domain.MirrorRepoConfig{Repo: repo, URL: pb.Url, Cron: pb.Cron, AllBranches: pb.AllBranches, Excluded: pb.Excluded, UpdatedAt: decodeTime(pb.UpdatedAt)} | |||
| 85 | + | } | |||
| 86 | + | func (s *Store) GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) { | |||
| 87 | + | var pb storagepb.MirrorRepo | |||
| 88 | + | err := s.readMirror(ctx, mirrorRepoPrefix+repo.String(), &pb) | |||
| 89 | + | return decodeMirrorRepo(repo, &pb), err | |||
| 90 | + | } | |||
| 91 | + | func (s *Store) PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error { | |||
| 92 | + | return s.writeMirror(ctx, mirrorRepoPrefix+c.Repo.String(), c.Repo, &storagepb.MirrorRepo{Url: c.URL, Cron: c.Cron, AllBranches: c.AllBranches, Excluded: c.Excluded, UpdatedAt: encodeTime(c.UpdatedAt)}) | |||
| 93 | + | } | |||
| 94 | + | func (s *Store) DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error { | |||
| 95 | + | var b kv.Batch | |||
| 96 | + | b.Delete([]byte(mirrorRepoPrefix + repo.String())) | |||
| 97 | + | _, err := s.db.Apply(ctx, &b) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return fmt.Errorf("delete mirror config: %w", err) | |||
| 100 | + | } | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | func (s *Store) ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) { | |||
| 104 | + | var out []domain.MirrorRepoConfig | |||
| 105 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorRepoPrefix)) { | |||
| 106 | + | repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorRepoPrefix)) | |||
| 107 | + | if err != nil { | |||
| 108 | + | return nil, err | |||
| 109 | + | } | |||
| 110 | + | var pb storagepb.MirrorRepo | |||
| 111 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |||
| 112 | + | return nil, fmt.Errorf("decode mirror config: %w", err) | |||
| 113 | + | } | |||
| 114 | + | out = append(out, decodeMirrorRepo(repo, &pb)) | |||
| 115 | + | } | |||
| 116 | + | return out, nil | |||
| 117 | + | } | |||
| 118 | + | func decodeMirrorStatus(repo domain.RepoName, pb *storagepb.MirrorState) domain.MirrorStatus { | |||
| 119 | + | return domain.MirrorStatus{Repo: repo, State: domain.MirrorState(pb.State), Class: domain.MirrorFailureClass(pb.Class), Reason: pb.Reason, Detail: pb.Detail, ConsecutiveFailures: int(pb.ConsecutiveFailures), LastAttemptAt: decodeTime(pb.LastAttemptAt), LastSuccessAt: decodeTime(pb.LastSuccessAt), NextRetryAt: decodeTime(pb.NextRetryAt), PausedAt: decodeTime(pb.PausedAt), UpdatedAt: decodeTime(pb.UpdatedAt)} | |||
| 120 | + | } | |||
| 121 | + | func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) { | |||
| 122 | + | var pb storagepb.MirrorState | |||
| 123 | + | err := s.readMirror(ctx, mirrorStatePrefix+repo.String(), &pb) | |||
| 124 | + | return decodeMirrorStatus(repo, &pb), err | |||
| 125 | + | } | |||
| 126 | + | func (s *Store) PutStatus(ctx context.Context, c domain.MirrorStatus) error { | |||
| 127 | + | return s.writeMirror(ctx, mirrorStatePrefix+c.Repo.String(), c.Repo, &storagepb.MirrorState{State: string(c.State), Class: string(c.Class), Reason: c.Reason, Detail: c.Detail, ConsecutiveFailures: int32(c.ConsecutiveFailures), LastAttemptAt: encodeTime(c.LastAttemptAt), LastSuccessAt: encodeTime(c.LastSuccessAt), NextRetryAt: encodeTime(c.NextRetryAt), PausedAt: encodeTime(c.PausedAt), UpdatedAt: encodeTime(c.UpdatedAt)}) | |||
| 128 | + | } | |||
| 129 | + | func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) { | |||
| 130 | + | var out []domain.MirrorStatus | |||
| 131 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorStatePrefix)) { | |||
| 132 | + | repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorStatePrefix)) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return nil, err | |||
| 135 | + | } | |||
| 136 | + | var pb storagepb.MirrorState | |||
| 137 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |||
| 138 | + | return nil, fmt.Errorf("decode mirror state: %w", err) | |||
| 139 | + | } | |||
| 140 | + | out = append(out, decodeMirrorStatus(repo, &pb)) | |||
| 141 | + | } | |||
| 142 | + | return out, nil | |||
| 143 | + | } | |||
| 144 | + | func (s *Store) GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) { | |||
| 145 | + | var pb storagepb.MirrorSnapshot | |||
| 146 | + | err := s.readMirror(ctx, mirrorSnapshotPrefix+repo.String(), &pb) | |||
| 147 | + | out := domain.MirrorSnapshot{URL: pb.Url, AllBranches: pb.AllBranches, At: decodeTime(pb.At)} | |||
| 148 | + | for _, ref := range pb.Refs { | |||
| 149 | + | id, parseErr := domain.ParseObjectID(ref.Id) | |||
| 150 | + | if parseErr != nil { | |||
| 151 | + | return domain.MirrorSnapshot{}, fmt.Errorf("decode mirror snapshot ref: %w", parseErr) | |||
| 152 | + | } | |||
| 153 | + | out.Refs = append(out.Refs, domain.MirrorRef{Name: ref.Name, ID: id}) | |||
| 154 | + | } | |||
| 155 | + | return out, err | |||
| 156 | + | } | |||
| 157 | + | func (s *Store) PutSnapshot(ctx context.Context, repo domain.RepoName, c domain.MirrorSnapshot) error { | |||
| 158 | + | pb := &storagepb.MirrorSnapshot{Url: c.URL, AllBranches: c.AllBranches, At: encodeTime(c.At)} | |||
| 159 | + | for _, ref := range c.Refs { | |||
| 160 | + | pb.Refs = append(pb.Refs, &storagepb.MirrorSnapshotRef{Name: ref.Name, Id: ref.ID.String()}) | |||
| 161 | + | } | |||
| 162 | + | return s.writeMirror(ctx, mirrorSnapshotPrefix+repo.String(), repo, pb) | |||
| 163 | + | } | |||
| 164 | + | func (s *Store) GetMirrorKey(ctx context.Context) ([]byte, time.Time, error) { | |||
| 165 | + | var pb storagepb.MirrorKey | |||
| 166 | + | err := s.readMirror(ctx, domain.MirrorKeyPath, &pb) | |||
| 167 | + | return pb.Sealed, decodeTime(pb.CreatedAt), err | |||
| 168 | + | } | |||
| 169 | + | func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, ifAbsent bool) error { | |||
| 170 | + | var b kv.Batch | |||
| 171 | + | key := []byte(domain.MirrorKeyPath) | |||
| 172 | + | if ifAbsent { | |||
| 173 | + | b.RequireAbsent(key) | |||
| 174 | + | } | |||
| 175 | + | b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)})) | |||
| 176 | + | _, err := s.db.Apply(ctx, &b) | |||
| 177 | + | if ifAbsent && errors.Is(err, kv.ErrConflict) { | |||
| 178 | + | return domain.ErrAlreadyExists | |||
| 179 | + | } | |||
| 180 | + | if err != nil { | |||
| 181 | + | return fmt.Errorf("store mirror key: %w", err) | |||
| 182 | + | } | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // RawMirrorKey exposes the stored protobuf for encryption-at-rest tests. | |||
| 187 | + | func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) { | |||
| 188 | + | item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath)) | |||
| 189 | + | return item.Value, ok | |||
| 190 | + | } | |||
A internal/adapters/kvstore/mirror_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestMirrorRoundTripsAndDelete(t *testing.T) { | |
| 16 | + | ctx := context.Background() | |
| 17 | + | s := openStore(t, t.TempDir()) | |
| 18 | + | repo := name(t, "mirror") | |
| 19 | + | g, err := s.GetGlobal(ctx) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.Equal(t, domain.MirrorGlobalConfig{}, g) | |
| 22 | + | _, err = s.GetRepoConfig(ctx, repo) | |
| 23 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 24 | + | _, err = s.GetStatus(ctx, repo) | |
| 25 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 26 | + | _, err = s.GetSnapshot(ctx, repo) | |
| 27 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 28 | + | _, _, err = s.GetMirrorKey(ctx) | |
| 29 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 30 | + | require.NoError(t, s.DeleteRepoConfig(ctx, repo)) | |
| 31 | + | require.ErrorIs(t, s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo}), domain.ErrNotFound) | |
| 32 | + | require.ErrorIs(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo}), domain.ErrNotFound) | |
| 33 | + | require.ErrorIs(t, s.PutSnapshot(ctx, repo, domain.MirrorSnapshot{}), domain.ErrNotFound) | |
| 34 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate})) | |
| 35 | + | g = domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.org:{repo}", Cron: "*/5 * * * *", AllBranches: true, UpdatedAt: created} | |
| 36 | + | require.NoError(t, s.PutGlobal(ctx, g)) | |
| 37 | + | gg, err := s.GetGlobal(ctx) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.Equal(t, g, gg) | |
| 40 | + | all := false | |
| 41 | + | c := domain.MirrorRepoConfig{Repo: repo, URL: "git@example.org:override", Cron: "0 * * * *", AllBranches: &all, Excluded: true, UpdatedAt: created} | |
| 42 | + | require.NoError(t, s.PutRepoConfig(ctx, c)) | |
| 43 | + | cc, err := s.GetRepoConfig(ctx, repo) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | require.Equal(t, c, cc) | |
| 46 | + | cs, err := s.ListRepoConfigs(ctx) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, []domain.MirrorRepoConfig{c}, cs) | |
| 49 | + | c.AllBranches = nil | |
| 50 | + | require.NoError(t, s.PutRepoConfig(ctx, c)) | |
| 51 | + | cc, err = s.GetRepoConfig(ctx, repo) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Nil(t, cc.AllBranches) | |
| 54 | + | st := domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, Reason: "retry", Detail: "network", ConsecutiveFailures: 3, LastAttemptAt: created, LastSuccessAt: created, NextRetryAt: created, PausedAt: created, UpdatedAt: created} | |
| 55 | + | require.NoError(t, s.PutStatus(ctx, st)) | |
| 56 | + | ss, err := s.GetStatus(ctx, repo) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Equal(t, st, ss) | |
| 59 | + | statuses, err := s.ListStatus(ctx) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, []domain.MirrorStatus{st}, statuses) | |
| 62 | + | id, err := domain.ParseObjectID("0123456789012345678901234567890123456789") | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | snap := domain.MirrorSnapshot{URL: c.URL, AllBranches: true, At: created, Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: id}}} | |
| 65 | + | require.NoError(t, s.PutSnapshot(ctx, repo, snap)) | |
| 66 | + | got, err := s.GetSnapshot(ctx, repo) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | require.Equal(t, snap, got) | |
| 69 | + | sealed := []byte("opaque sealed seed") | |
| 70 | + | require.NoError(t, s.PutMirrorKey(ctx, sealed, created, true)) | |
| 71 | + | require.ErrorIs(t, s.PutMirrorKey(ctx, []byte("other"), created, true), domain.ErrAlreadyExists) | |
| 72 | + | blob, at, err := s.GetMirrorKey(ctx) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | require.Equal(t, sealed, blob) | |
| 75 | + | require.Equal(t, created, at) | |
| 76 | + | raw, ok := s.RawMirrorKey(ctx) | |
| 77 | + | require.True(t, ok) | |
| 78 | + | require.False(t, bytes.Equal(raw, sealed), "raw record is a protobuf envelope, not bare key material") | |
| 79 | + | require.NoError(t, s.PutMirrorKey(ctx, []byte("rotated"), created, false)) | |
| 80 | + | blob, _, err = s.GetMirrorKey(ctx) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.Equal(t, []byte("rotated"), blob) | |
| 83 | + | require.NoError(t, s.Delete(ctx, repo)) | |
| 84 | + | _, err = s.GetRepoConfig(ctx, repo) | |
| 85 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 86 | + | _, err = s.GetStatus(ctx, repo) | |
| 87 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 88 | + | _, err = s.GetSnapshot(ctx, repo) | |
| 89 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 90 | + | _, _, err = s.GetMirrorKey(ctx) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | gg, err = s.GetGlobal(ctx) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.Equal(t, g, gg) | |
| 95 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate})) | |
| 96 | + | _, err = s.GetStatus(ctx, repo) | |
| 97 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestMirrorRoundTripsAndDelete(t *testing.T) { | |||
| 16 | + | ctx := context.Background() | |||
| 17 | + | s := openStore(t, t.TempDir()) | |||
| 18 | + | repo := name(t, "mirror") | |||
| 19 | + | g, err := s.GetGlobal(ctx) | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.Equal(t, domain.MirrorGlobalConfig{}, g) | |||
| 22 | + | _, err = s.GetRepoConfig(ctx, repo) | |||
| 23 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 24 | + | _, err = s.GetStatus(ctx, repo) | |||
| 25 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 26 | + | _, err = s.GetSnapshot(ctx, repo) | |||
| 27 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 28 | + | _, _, err = s.GetMirrorKey(ctx) | |||
| 29 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 30 | + | require.NoError(t, s.DeleteRepoConfig(ctx, repo)) | |||
| 31 | + | require.ErrorIs(t, s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo}), domain.ErrNotFound) | |||
| 32 | + | require.ErrorIs(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo}), domain.ErrNotFound) | |||
| 33 | + | require.ErrorIs(t, s.PutSnapshot(ctx, repo, domain.MirrorSnapshot{}), domain.ErrNotFound) | |||
| 34 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate})) | |||
| 35 | + | g = domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.org:{repo}", Cron: "*/5 * * * *", AllBranches: true, UpdatedAt: created} | |||
| 36 | + | require.NoError(t, s.PutGlobal(ctx, g)) | |||
| 37 | + | gg, err := s.GetGlobal(ctx) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | require.Equal(t, g, gg) | |||
| 40 | + | all := false | |||
| 41 | + | c := domain.MirrorRepoConfig{Repo: repo, URL: "git@example.org:override", Cron: "0 * * * *", AllBranches: &all, Excluded: true, UpdatedAt: created} | |||
| 42 | + | require.NoError(t, s.PutRepoConfig(ctx, c)) | |||
| 43 | + | cc, err := s.GetRepoConfig(ctx, repo) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | require.Equal(t, c, cc) | |||
| 46 | + | cs, err := s.ListRepoConfigs(ctx) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Equal(t, []domain.MirrorRepoConfig{c}, cs) | |||
| 49 | + | c.AllBranches = nil | |||
| 50 | + | require.NoError(t, s.PutRepoConfig(ctx, c)) | |||
| 51 | + | cc, err = s.GetRepoConfig(ctx, repo) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.Nil(t, cc.AllBranches) | |||
| 54 | + | st := domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, Reason: "retry", Detail: "network", ConsecutiveFailures: 3, LastAttemptAt: created, LastSuccessAt: created, NextRetryAt: created, PausedAt: created, UpdatedAt: created} | |||
| 55 | + | require.NoError(t, s.PutStatus(ctx, st)) | |||
| 56 | + | ss, err := s.GetStatus(ctx, repo) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | require.Equal(t, st, ss) | |||
| 59 | + | statuses, err := s.ListStatus(ctx) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Equal(t, []domain.MirrorStatus{st}, statuses) | |||
| 62 | + | id, err := domain.ParseObjectID("0123456789012345678901234567890123456789") | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | snap := domain.MirrorSnapshot{URL: c.URL, AllBranches: true, At: created, Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: id}}} | |||
| 65 | + | require.NoError(t, s.PutSnapshot(ctx, repo, snap)) | |||
| 66 | + | got, err := s.GetSnapshot(ctx, repo) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | require.Equal(t, snap, got) | |||
| 69 | + | sealed := []byte("opaque sealed seed") | |||
| 70 | + | require.NoError(t, s.PutMirrorKey(ctx, sealed, created, true)) | |||
| 71 | + | require.ErrorIs(t, s.PutMirrorKey(ctx, []byte("other"), created, true), domain.ErrAlreadyExists) | |||
| 72 | + | blob, at, err := s.GetMirrorKey(ctx) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | require.Equal(t, sealed, blob) | |||
| 75 | + | require.Equal(t, created, at) | |||
| 76 | + | raw, ok := s.RawMirrorKey(ctx) | |||
| 77 | + | require.True(t, ok) | |||
| 78 | + | require.False(t, bytes.Equal(raw, sealed), "raw record is a protobuf envelope, not bare key material") | |||
| 79 | + | require.NoError(t, s.PutMirrorKey(ctx, []byte("rotated"), created, false)) | |||
| 80 | + | blob, _, err = s.GetMirrorKey(ctx) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | require.Equal(t, []byte("rotated"), blob) | |||
| 83 | + | require.NoError(t, s.Delete(ctx, repo)) | |||
| 84 | + | _, err = s.GetRepoConfig(ctx, repo) | |||
| 85 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 86 | + | _, err = s.GetStatus(ctx, repo) | |||
| 87 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 88 | + | _, err = s.GetSnapshot(ctx, repo) | |||
| 89 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 90 | + | _, _, err = s.GetMirrorKey(ctx) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | gg, err = s.GetGlobal(ctx) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | require.Equal(t, g, gg) | |||
| 95 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate})) | |||
| 96 | + | _, err = s.GetStatus(ctx, repo) | |||
| 97 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 98 | + | } | |||
M internal/adapters/kvstore/repos.go +1 -0
| @@ -164,6 +164,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | |||
| 164 | 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | |
| 165 | 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | |
| 166 | 166 | keys = append(keys, settingsKeys(ctx, snap, name)...) | |
| 167 | + | keys = append(keys, mirrorKeys(ctx, snap, name)...) | |
| 167 | 168 | for len(keys) > deleteChunk { | |
| 168 | 169 | var b kv.Batch | |
| 169 | 170 | for _, k := range keys[:deleteChunk] { | |
| @@ -164,6 +164,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | |||||
| 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | ||
| 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | ||
| 166 | keys = append(keys, settingsKeys(ctx, snap, name)...) | 166 | keys = append(keys, settingsKeys(ctx, snap, name)...) | ||
| 167 | + | keys = append(keys, mirrorKeys(ctx, snap, name)...) | |||
| 167 | for len(keys) > deleteChunk { | 168 | for len(keys) > deleteChunk { | ||
| 168 | var b kv.Batch | 169 | var b kv.Batch | ||
| 169 | for _, k := range keys[:deleteChunk] { | 170 | for _, k := range keys[:deleteChunk] { | ||
A proto/ermite/storage/mirror.proto +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | syntax = "proto3"; | |
| 3 | + | package ermite.storage; | |
| 4 | + | import "google/protobuf/timestamp.proto"; | |
| 5 | + | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |
| 6 | + | message MirrorKey { | |
| 7 | + | bytes sealed = 1; | |
| 8 | + | google.protobuf.Timestamp created_at = 2; | |
| 9 | + | } | |
| 10 | + | message MirrorGlobal { | |
| 11 | + | bool enabled = 1; | |
| 12 | + | string url_template = 2; | |
| 13 | + | string cron = 3; | |
| 14 | + | bool all_branches = 4; | |
| 15 | + | google.protobuf.Timestamp updated_at = 5; | |
| 16 | + | } | |
| 17 | + | message MirrorRepo { | |
| 18 | + | string url = 1; | |
| 19 | + | string cron = 2; | |
| 20 | + | optional bool all_branches = 3; | |
| 21 | + | bool excluded = 4; | |
| 22 | + | google.protobuf.Timestamp updated_at = 5; | |
| 23 | + | } | |
| 24 | + | message MirrorState { | |
| 25 | + | string state = 1; | |
| 26 | + | string class = 2; | |
| 27 | + | string reason = 3; | |
| 28 | + | string detail = 4; | |
| 29 | + | int32 consecutive_failures = 5; | |
| 30 | + | google.protobuf.Timestamp last_attempt_at = 6; | |
| 31 | + | google.protobuf.Timestamp last_success_at = 7; | |
| 32 | + | google.protobuf.Timestamp next_retry_at = 8; | |
| 33 | + | google.protobuf.Timestamp paused_at = 9; | |
| 34 | + | google.protobuf.Timestamp updated_at = 10; | |
| 35 | + | } | |
| 36 | + | message MirrorSnapshotRef { | |
| 37 | + | string name = 1; | |
| 38 | + | string id = 2; | |
| 39 | + | } | |
| 40 | + | message MirrorSnapshot { | |
| 41 | + | string url = 1; | |
| 42 | + | repeated MirrorSnapshotRef refs = 2; | |
| 43 | + | bool all_branches = 3; | |
| 44 | + | google.protobuf.Timestamp at = 4; | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | syntax = "proto3"; | |||
| 3 | + | package ermite.storage; | |||
| 4 | + | import "google/protobuf/timestamp.proto"; | |||
| 5 | + | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |||
| 6 | + | message MirrorKey { | |||
| 7 | + | bytes sealed = 1; | |||
| 8 | + | google.protobuf.Timestamp created_at = 2; | |||
| 9 | + | } | |||
| 10 | + | message MirrorGlobal { | |||
| 11 | + | bool enabled = 1; | |||
| 12 | + | string url_template = 2; | |||
| 13 | + | string cron = 3; | |||
| 14 | + | bool all_branches = 4; | |||
| 15 | + | google.protobuf.Timestamp updated_at = 5; | |||
| 16 | + | } | |||
| 17 | + | message MirrorRepo { | |||
| 18 | + | string url = 1; | |||
| 19 | + | string cron = 2; | |||
| 20 | + | optional bool all_branches = 3; | |||
| 21 | + | bool excluded = 4; | |||
| 22 | + | google.protobuf.Timestamp updated_at = 5; | |||
| 23 | + | } | |||
| 24 | + | message MirrorState { | |||
| 25 | + | string state = 1; | |||
| 26 | + | string class = 2; | |||
| 27 | + | string reason = 3; | |||
| 28 | + | string detail = 4; | |||
| 29 | + | int32 consecutive_failures = 5; | |||
| 30 | + | google.protobuf.Timestamp last_attempt_at = 6; | |||
| 31 | + | google.protobuf.Timestamp last_success_at = 7; | |||
| 32 | + | google.protobuf.Timestamp next_retry_at = 8; | |||
| 33 | + | google.protobuf.Timestamp paused_at = 9; | |||
| 34 | + | google.protobuf.Timestamp updated_at = 10; | |||
| 35 | + | } | |||
| 36 | + | message MirrorSnapshotRef { | |||
| 37 | + | string name = 1; | |||
| 38 | + | string id = 2; | |||
| 39 | + | } | |||
| 40 | + | message MirrorSnapshot { | |||
| 41 | + | string url = 1; | |||
| 42 | + | repeated MirrorSnapshotRef refs = 2; | |||
| 43 | + | bool all_branches = 3; | |||
| 44 | + | google.protobuf.Timestamp at = 4; | |||
| 45 | + | } | |||