From af7e288e4a27313813666d57aabe8532ee9d401b Mon Sep 17 00:00:00 2001 From: Alejandro Lopez Date: Tue, 25 Apr 2023 15:09:20 +0300 Subject: [PATCH] [#270] Add IR epoch tick control call Signed-off-by: Alejandro Lopez --- cmd/frostfs-cli/modules/control/ir.go | 15 ++++++ .../modules/control/ir_tick_epoch.go | 43 ++++++++++++++++++ cmd/frostfs-cli/modules/control/root.go | 2 + pkg/innerring/initialization.go | 2 +- .../processors/netmap/process_epoch.go | 2 +- pkg/morph/client/netmap/new_epoch.go | 6 ++- pkg/morph/client/notary.go | 18 ++++++++ pkg/morph/client/static.go | 17 ++++++- pkg/services/control/ir/convert.go | 10 ++-- pkg/services/control/ir/rpc.go | 22 +++++++-- pkg/services/control/ir/server/calls.go | 31 +++++++++++-- pkg/services/control/ir/server/server.go | 10 ++-- pkg/services/control/ir/service.go | 12 +++++ pkg/services/control/ir/service.pb.go | Bin 14595 -> 23887 bytes pkg/services/control/ir/service.proto | 16 +++++++ pkg/services/control/ir/service_frostfs.pb.go | Bin 5262 -> 10125 bytes pkg/services/control/ir/service_grpc.pb.go | Bin 3953 -> 5434 bytes pkg/services/control/service.pb.go | Bin 100124 -> 100123 bytes pkg/services/control/service_grpc.pb.go | Bin 17524 -> 16653 bytes pkg/services/tree/service_grpc.pb.go | Bin 19139 -> 18231 bytes 20 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 cmd/frostfs-cli/modules/control/ir.go create mode 100644 cmd/frostfs-cli/modules/control/ir_tick_epoch.go diff --git a/cmd/frostfs-cli/modules/control/ir.go b/cmd/frostfs-cli/modules/control/ir.go new file mode 100644 index 000000000..e89dda076 --- /dev/null +++ b/cmd/frostfs-cli/modules/control/ir.go @@ -0,0 +1,15 @@ +package control + +import "github.com/spf13/cobra" + +var irCmd = &cobra.Command{ + Use: "ir", + Short: "Operations with inner ring nodes", + Long: "Operations with inner ring nodes", +} + +func initControlIRCmd() { + irCmd.AddCommand(tickEpochCmd) + + initControlIRTickEpochCmd() +} diff --git a/cmd/frostfs-cli/modules/control/ir_tick_epoch.go b/cmd/frostfs-cli/modules/control/ir_tick_epoch.go new file mode 100644 index 000000000..3e6af0081 --- /dev/null +++ b/cmd/frostfs-cli/modules/control/ir_tick_epoch.go @@ -0,0 +1,43 @@ +package control + +import ( + rawclient "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/client" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/key" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + ircontrol "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control/ir" + ircontrolsrv "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control/ir/server" + "github.com/spf13/cobra" +) + +var tickEpochCmd = &cobra.Command{ + Use: "tick-epoch", + Short: "Forces a new epoch", + Long: "Forces a new epoch via a notary request. It should be executed on other IR nodes as well.", + Run: tickEpoch, +} + +func initControlIRTickEpochCmd() { + initControlFlags(tickEpochCmd) +} + +func tickEpoch(cmd *cobra.Command, _ []string) { + pk := key.Get(cmd) + c := getClient(cmd, pk) + + req := new(ircontrol.TickEpochRequest) + req.SetBody(new(ircontrol.TickEpochRequest_Body)) + + err := ircontrolsrv.SignMessage(pk, req) + commonCmd.ExitOnErr(cmd, "could not sign request: %w", err) + + var resp *ircontrol.TickEpochResponse + err = c.ExecRaw(func(client *rawclient.Client) error { + resp, err = ircontrol.TickEpoch(client, req) + return err + }) + commonCmd.ExitOnErr(cmd, "rpc error: %w", err) + + verifyResponse(cmd, resp.GetSignature(), resp.GetBody()) + + cmd.Println("Epoch tick requested") +} diff --git a/cmd/frostfs-cli/modules/control/root.go b/cmd/frostfs-cli/modules/control/root.go index d3b656392..015676185 100644 --- a/cmd/frostfs-cli/modules/control/root.go +++ b/cmd/frostfs-cli/modules/control/root.go @@ -33,6 +33,7 @@ func init() { dropObjectsCmd, shardsCmd, synchronizeTreeCmd, + irCmd, ) initControlHealthCheckCmd() @@ -40,4 +41,5 @@ func init() { initControlDropObjectsCmd() initControlShardsCmd() initControlSynchronizeTreeCmd() + initControlIRCmd() } diff --git a/pkg/innerring/initialization.go b/pkg/innerring/initialization.go index 31602a48e..b035ba2fa 100644 --- a/pkg/innerring/initialization.go +++ b/pkg/innerring/initialization.go @@ -459,7 +459,7 @@ func (s *Server) initGRPCServer(cfg *viper.Viper) error { p.SetPrivateKey(*s.key) p.SetHealthChecker(s) - controlSvc := controlsrv.New(p, + controlSvc := controlsrv.New(p, s.netmapClient, controlsrv.WithAllowedKeys(authKeys), ) diff --git a/pkg/innerring/processors/netmap/process_epoch.go b/pkg/innerring/processors/netmap/process_epoch.go index ebf128f82..17e445b13 100644 --- a/pkg/innerring/processors/netmap/process_epoch.go +++ b/pkg/innerring/processors/netmap/process_epoch.go @@ -79,7 +79,7 @@ func (np *Processor) processNewEpochTick() { nextEpoch := np.epochState.EpochCounter() + 1 np.log.Debug(logs.NetmapNextEpoch, zap.Uint64("value", nextEpoch)) - err := np.netmapClient.NewEpoch(nextEpoch) + err := np.netmapClient.NewEpoch(nextEpoch, false) if err != nil { np.log.Error(logs.NetmapCantInvokeNetmapNewEpoch, zap.Error(err)) } diff --git a/pkg/morph/client/netmap/new_epoch.go b/pkg/morph/client/netmap/new_epoch.go index 0b4d31b1d..7a63f14d7 100644 --- a/pkg/morph/client/netmap/new_epoch.go +++ b/pkg/morph/client/netmap/new_epoch.go @@ -8,10 +8,14 @@ import ( // NewEpoch updates FrostFS epoch number through // Netmap contract call. -func (c *Client) NewEpoch(epoch uint64) error { +// If `force` is true, this call is normally initiated by a control +// service command and uses a control notary transaction internally +// to ensure all nodes produce the same transaction with high probability. +func (c *Client) NewEpoch(epoch uint64, force bool) error { prm := client.InvokePrm{} prm.SetMethod(newEpochMethod) prm.SetArgs(epoch) + prm.SetControlTX(force) if err := c.client.Invoke(prm); err != nil { return fmt.Errorf("could not invoke method (%s): %w", newEpochMethod, err) diff --git a/pkg/morph/client/notary.go b/pkg/morph/client/notary.go index 427554372..3e21911e1 100644 --- a/pkg/morph/client/notary.go +++ b/pkg/morph/client/notary.go @@ -886,6 +886,16 @@ func CalculateNotaryDepositAmount(c *Client, gasMul, gasDiv int64) (fixedn.Fixed // CalculateNonceAndVUB calculates nonce and ValidUntilBlock values // based on transaction hash. func (c *Client) CalculateNonceAndVUB(hash util.Uint256) (nonce uint32, vub uint32, err error) { + return c.calculateNonceAndVUB(hash, false) +} + +// CalculateNonceAndVUBControl calculates nonce and rounded ValidUntilBlock values +// based on transaction hash for use in control transactions. +func (c *Client) CalculateNonceAndVUBControl(hash util.Uint256) (nonce uint32, vub uint32, err error) { + return c.calculateNonceAndVUB(hash, true) +} + +func (c *Client) calculateNonceAndVUB(hash util.Uint256, roundBlockHeight bool) (nonce uint32, vub uint32, err error) { c.switchLock.RLock() defer c.switchLock.RUnlock() @@ -904,6 +914,14 @@ func (c *Client) CalculateNonceAndVUB(hash util.Uint256) (nonce uint32, vub uint return 0, 0, fmt.Errorf("could not get transaction height: %w", err) } + // For control transactions, we round down the block height to control the + // probability of all nodes producing the same transaction, since it depends + // on this value. + if roundBlockHeight { + inc := c.rpcActor.GetVersion().Protocol.MaxValidUntilBlockIncrement + height = height / inc * inc + } + return nonce, height + c.notary.txValidTime, nil } diff --git a/pkg/morph/client/static.go b/pkg/morph/client/static.go index afaf49f3d..910f78537 100644 --- a/pkg/morph/client/static.go +++ b/pkg/morph/client/static.go @@ -94,6 +94,12 @@ type InvokePrmOptional struct { // `validUntilBlock` values by all notification // receivers. hash *util.Uint256 + // controlTX controls whether the invoke method will use a rounded + // block height value, which is useful for control transactions which + // are required to be produced by all nodes with very high probability. + // It's only used by notary transactions and it affects only the + // computation of `validUntilBlock` values. + controlTX bool } // SetHash sets optional hash of the transaction. @@ -104,6 +110,11 @@ func (i *InvokePrmOptional) SetHash(hash util.Uint256) { i.hash = &hash } +// SetControlTX sets whether a control transaction will be used. +func (i *InvokePrmOptional) SetControlTX(b bool) { + i.controlTX = b +} + // Invoke calls Invoke method of Client with static internal script hash and fee. // Supported args types are the same as in Client. // @@ -126,7 +137,11 @@ func (s StaticClient) Invoke(prm InvokePrm) error { ) if prm.hash != nil { - nonce, vub, err = s.client.CalculateNonceAndVUB(*prm.hash) + if prm.controlTX { + nonce, vub, err = s.client.CalculateNonceAndVUBControl(*prm.hash) + } else { + nonce, vub, err = s.client.CalculateNonceAndVUB(*prm.hash) + } if err != nil { return fmt.Errorf("could not calculate nonce and VUB for notary alphabet invoke: %w", err) } diff --git a/pkg/services/control/ir/convert.go b/pkg/services/control/ir/convert.go index 01bc48724..c892c5b6c 100644 --- a/pkg/services/control/ir/convert.go +++ b/pkg/services/control/ir/convert.go @@ -14,18 +14,18 @@ func (w *requestWrapper) ToGRPCMessage() grpc.Message { return w.m } -type healthCheckResponseWrapper struct { - m *HealthCheckResponse +type responseWrapper[M grpc.Message] struct { + m M } -func (w *healthCheckResponseWrapper) ToGRPCMessage() grpc.Message { +func (w *responseWrapper[M]) ToGRPCMessage() grpc.Message { return w.m } -func (w *healthCheckResponseWrapper) FromGRPCMessage(m grpc.Message) error { +func (w *responseWrapper[M]) FromGRPCMessage(m grpc.Message) error { var ok bool - w.m, ok = m.(*HealthCheckResponse) + w.m, ok = m.(M) if !ok { return message.NewUnexpectedMessageType(m, w.m) } diff --git a/pkg/services/control/ir/rpc.go b/pkg/services/control/ir/rpc.go index a8b16b607..6b2234954 100644 --- a/pkg/services/control/ir/rpc.go +++ b/pkg/services/control/ir/rpc.go @@ -3,12 +3,14 @@ package control import ( "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/client" "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/common" + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/grpc" ) const serviceName = "ircontrol.ControlService" const ( rpcHealthCheck = "HealthCheck" + rpcTickEpoch = "TickEpoch" ) // HealthCheck executes ControlService.HealthCheck RPC. @@ -17,15 +19,29 @@ func HealthCheck( req *HealthCheckRequest, opts ...client.CallOption, ) (*HealthCheckResponse, error) { - wResp := &healthCheckResponseWrapper{ - m: new(HealthCheckResponse), + return sendUnary[HealthCheckRequest, HealthCheckResponse](cli, rpcHealthCheck, req, opts...) +} + +// TickEpoch executes ControlService.TickEpoch RPC. +func TickEpoch( + cli *client.Client, + req *TickEpochRequest, + opts ...client.CallOption, +) (*TickEpochResponse, error) { + return sendUnary[TickEpochRequest, TickEpochResponse](cli, rpcTickEpoch, req, opts...) +} + +func sendUnary[I, O grpc.Message](cli *client.Client, rpcName string, req *I, opts ...client.CallOption) (*O, error) { + var resp O + wResp := &responseWrapper[*O]{ + m: &resp, } wReq := &requestWrapper{ m: req, } - err := client.SendUnary(cli, common.CallMethodInfoUnary(serviceName, rpcHealthCheck), wReq, wResp, opts...) + err := client.SendUnary(cli, common.CallMethodInfoUnary(serviceName, rpcName), wReq, wResp, opts...) if err != nil { return nil, err } diff --git a/pkg/services/control/ir/server/calls.go b/pkg/services/control/ir/server/calls.go index 986da90f1..56e2e3f79 100644 --- a/pkg/services/control/ir/server/calls.go +++ b/pkg/services/control/ir/server/calls.go @@ -2,6 +2,7 @@ package control import ( "context" + "fmt" control "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control/ir" "google.golang.org/grpc/codes" @@ -12,12 +13,10 @@ import ( // // If request is not signed with a key from white list, permission error returns. func (s *Server) HealthCheck(_ context.Context, req *control.HealthCheckRequest) (*control.HealthCheckResponse, error) { - // verify request if err := s.isValidRequest(req); err != nil { return nil, status.Error(codes.PermissionDenied, err.Error()) } - // create and fill response resp := new(control.HealthCheckResponse) body := new(control.HealthCheckResponse_Body) @@ -25,7 +24,33 @@ func (s *Server) HealthCheck(_ context.Context, req *control.HealthCheckRequest) body.SetHealthStatus(s.prm.healthChecker.HealthStatus()) - // sign the response + if err := SignMessage(&s.prm.key.PrivateKey, resp); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return resp, nil +} + +// TickEpoch forces a new epoch. +// +// If request is not signed with a key from white list, permission error returns. +func (s *Server) TickEpoch(_ context.Context, req *control.TickEpochRequest) (*control.TickEpochResponse, error) { + if err := s.isValidRequest(req); err != nil { + return nil, status.Error(codes.PermissionDenied, err.Error()) + } + + resp := new(control.TickEpochResponse) + resp.SetBody(new(control.TickEpochResponse_Body)) + + epoch, err := s.netmapClient.Epoch() + if err != nil { + return nil, fmt.Errorf("getting current epoch: %w", err) + } + + if err := s.netmapClient.NewEpoch(epoch+1, true); err != nil { + return nil, fmt.Errorf("forcing new epoch: %w", err) + } + if err := SignMessage(&s.prm.key.PrivateKey, resp); err != nil { return nil, status.Error(codes.Internal, err.Error()) } diff --git a/pkg/services/control/ir/server/server.go b/pkg/services/control/ir/server/server.go index c75c1504e..dc00809a6 100644 --- a/pkg/services/control/ir/server/server.go +++ b/pkg/services/control/ir/server/server.go @@ -2,6 +2,8 @@ package control import ( "fmt" + + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/morph/client/netmap" ) // Server is an entity that serves @@ -10,7 +12,8 @@ import ( // To gain access to the service, any request must be // signed with a key from the white list. type Server struct { - prm Prm + prm Prm + netmapClient *netmap.Client allowedKeys [][]byte } @@ -29,7 +32,7 @@ func panicOnPrmValue(n string, v any) { // Forms white list from all keys specified via // WithAllowedKeys option and a public key of // the parameterized private key. -func New(prm Prm, opts ...Option) *Server { +func New(prm Prm, netmapClient *netmap.Client, opts ...Option) *Server { // verify required parameters switch { case prm.healthChecker == nil: @@ -44,7 +47,8 @@ func New(prm Prm, opts ...Option) *Server { } return &Server{ - prm: prm, + prm: prm, + netmapClient: netmapClient, allowedKeys: append(o.allowedKeys, prm.key.PublicKey().Bytes()), } diff --git a/pkg/services/control/ir/service.go b/pkg/services/control/ir/service.go index dc04e4904..1aaec2c87 100644 --- a/pkg/services/control/ir/service.go +++ b/pkg/services/control/ir/service.go @@ -20,3 +20,15 @@ func (x *HealthCheckResponse) SetBody(v *HealthCheckResponse_Body) { x.Body = v } } + +func (x *TickEpochRequest) SetBody(v *TickEpochRequest_Body) { + if x != nil { + x.Body = v + } +} + +func (x *TickEpochResponse) SetBody(v *TickEpochResponse_Body) { + if x != nil { + x.Body = v + } +} diff --git a/pkg/services/control/ir/service.pb.go b/pkg/services/control/ir/service.pb.go index 44ea2dd66b9845430168835b038bd25cf7ca40a0..84acdfc82dba1a3a8fb147772098614fea461ea8 100644 GIT binary patch delta 2734 zcmb_eOHUhD6qarL8XG?V8$$dT3^sm^?XhQU;|5yNrnD(ZFp1hCD?ty!b*20vN#@SE)~wDP(KXlbjr z+rC$;M073z>u80f^KZ2By8EzW(7~8PTkY%0s~R;uV%?-j-?n-lJ^wMsv?`6TMJn(I zTb{DA_Aq6=VJ~5PUiS;N_GOz$S*`7>oyjPhkmYIU_>bG)ax`ro@Ho5_#tV))Ri=Se zuXCJ=c+(l#XLZ0A&a=2@KeNuRG!^w~*P1p(m{pe!ea7UIgk>cwkTJnW86*Bp5ra>V zQIs9&0WwdMZI$$EWE4a)X5^Uk4CT$jlFO@RjBwfI&?~4@u;uOrAIn+@dP+{1#GRY| z=TZ&$B;W}W#NejeN|JQ;xZ#fbD4ANkJ@Bk&h)nJ7B$$01ndW?MnCl5c#M?zoQ%^Tc zdUY>l(}XhCOGc$!dEP(=y-nH3G!-?n@N__cdtSX>RwO}+Z3jF1lm-De{k^cv`i7qn zmZaN7CB#HoskDfP0zb2-u~0Lq;dl2ilzdJmE0a$W@2F=O+c3NT^T1h-*Txh$F$nzAG>m=*3EPGeVl`lZ4zG z|E)rj6?4lSm6XU_>9mYo6&_<_e~^fS`%gX=(tvTJLVNoEJ%WHukO>QNUyGOnE(JnF z^&0_E#lp5?So=kIFA!`glA|f15$Fz+pe>x<>(Rk=j}=@&2QF>8I;zhH%W8Et1XJMz z{1JYCgL*A0*IRI5c0LrXz4+sCHko5*m!}sO%FB!MvBr4D7_8qocrz-O!hR6MNF9Iy z??gC!<6h$qL5?%Sorqs8=HMI7Z=zt~dc;v=S%gU+03%b6ksGinpGo2g)kq;wrYR5{ zjtZ(Qj75*A2H@T3ISncge?}+N*(l6MyfDKhHTc}YRj#1H=K*#iesIO2ZtQcyPG}Bl z<@i2-zq$-&o`Xe@2NosBlGZuyO7|DuuGTKS%S5_f8DvIL?5N7GaZxQ?Y`U z%m&~_?6eV^kQjUdt;6*+x!Hx%YI#z=ZMdPwwg|qPWCR`&`XL^V8S%X%uOta3bzQ{b z-SzLOJ#vhZM+Br$;Q1V%g0JFESdVv@clj0uay-AgsE3c@W>eiUfa{S4n{`N)vb0c? zBz%Gu9)(1XL^`V{5_dHeoqzZ(aEr&O6*l!P_NzyH85Zp(I3s+Hp9CK90o+Q7ct?9g e`ipBs_zrJJTx;_2aZ+ZCD4sZ(fAb&Aab+&*9w5d;8nm^Q8|x zz1~*j0f#`Ji%)V8bwNOENW{UY4S6I8t3yKT+B%y3N8V!egvVjnH%9Pd_zdHNM_7yo zaS`b9cH{G$@T9RM-$bTg@lI1Oq)1jz%GwHX{Znfsr7N^BFz`!NP_V`VRLSU6!9(kw z;6oFuldti!(1XNOSYmG7^$mO0ZM?F&d3seRQ-~UI{F&;dxMnN(ZjWHr4*6okRyY|i zZ4&V3<>?AY*ux!7T90kwJ5JJZY^1}uUmW%E%R~roiUUZg1K!u-GS3?d`qCO6O~1uP zDbDHmTynT9TGKJ?%p`fk#C|!v=3M8AiPg#=Ea!$u$Ed2TQELqE93mQwGmOP@Qp_^e zS;#^;f2p@aH_IuJFx_B!qcYJ+!wAkRSMjk@5Hn27U2->`-AYS2)~n^rJWACesMVm< z&ZrSHM_bB2BzRg){Eu?TU0HS?H(i%LQaedAiTArIbfSK@{q3{!yZCm;q5K~8g%)Rc IbF~ZZFQQDZ0RR91 diff --git a/pkg/services/control/ir/service.proto b/pkg/services/control/ir/service.proto index 5f99be16b..5862e8fbd 100644 --- a/pkg/services/control/ir/service.proto +++ b/pkg/services/control/ir/service.proto @@ -10,6 +10,8 @@ option go_package = "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/ir/ service ControlService { // Performs health check of the IR node. rpc HealthCheck (HealthCheckRequest) returns (HealthCheckResponse); + // Forces a new epoch to be signaled by the IR node with high probability. + rpc TickEpoch (TickEpochRequest) returns (TickEpochResponse); } // Health check request. @@ -41,3 +43,17 @@ message HealthCheckResponse { // Body signature. Signature signature = 2; } + +message TickEpochRequest { + message Body{} + + Body body = 1; + Signature signature = 2; +} + +message TickEpochResponse { + message Body{} + + Body body = 1; + Signature signature = 2; +} diff --git a/pkg/services/control/ir/service_frostfs.pb.go b/pkg/services/control/ir/service_frostfs.pb.go index f6dd94b3a069f356e35cfe5156dfdc2f8e04aecc..d480f0b50dd02c8435a4ad406cc546da526d4696 100644 GIT binary patch delta 232 zcmeCv?DgN!Co)-*tCKS%GdbI}AU`={vohl@Mg({A3#JnY-sauRVoXTfJFHWXxRa|n z?h3#S3Q8?3O)V~&d`~zXp=0tL&SOZtwOm@8eHbS)3L@z$F38U-PM!Ro=^#SkW^F!k cB#Sot3QR@fPJSzN521GQHQ{_DUVw-l0B>Vf!vFvP delta 7 OcmeD6@6+7SCjtNrW&-8_ diff --git a/pkg/services/control/ir/service_grpc.pb.go b/pkg/services/control/ir/service_grpc.pb.go index bdcac73e5ddb46b37ccf397a083b108b77ef8498..700d340ca8705a58a9a71d5a146e192633a02397 100644 GIT binary patch delta 623 zcmew;w@Yh69HY_3__K^m`udYMvWRV7!qmqY#;LEb;Fe#MoLa1qsF0Uhu8>-gpPZpk zlCO}Is!*Jno|l-Dnxc?YsZf%Us^A%3)auQ=yxdGOoYh>MoSeR?B^miCeu=rM TRtidR6_aoCDIwXy!Ji2LtlZT` delta 126 zcmdm`^-*p@9Ha5Z__K_h$@zK3B?=l`oRbqoqy^kcb8>uBOEU6P{1S6h6>KN-vuQAK zX>QhHTEV!vlck*zq>2w!)#hCG8H}6faON^@zQUctj77!dc>b%KcMF|oocvHE5&&9^ BE2sbf diff --git a/pkg/services/control/service.pb.go b/pkg/services/control/service.pb.go index d713bb38d3c269ac81a6e90c3b94f3573b98c833..a126ce16d0c5a115340e39b37b8ab6427d7e989a 100644 GIT binary patch delta 20 bcmbQ!$2Pl6(ByhPiApRa7JQL3RGQy&<3Em z8d%SCVUTH3{FQmh8AbVdnN_JFMX9Mun;S$F8Nr72vG9QvT8cpobIU0$&Tvjl&Pat= zS|&aNtj?B|9jMN4bAyDjAXot8-bS7B$x)hun^UwtfJ~E_9O5Xjxkaat39JI-PLPT@ zdV-sm=|2S9hvHa}q`slRW;3H_AW1c#>Z-*DOhCFZTnv<}*qmz41Th-qYLIRoOX1Bj hR*xY@BRd@^xp|(=6R^Z&klR73_3U^yYubCW0swyhk39eY delta 919 zcmeBeV*Jv=))XxJt>nk`< zUMMKM*@3Bz0~FAFs9Gma6v~|(!Mz6*zSyKz@vH#_EjFnayelRHLlm2oBL4*!)=U86%cp*bFq230oMvH>jA*Yb>%^ i&g2~+m2rC?<3yb}Mx%}0M;JF7vf6S^7GxG?aZE{3n*2~8Yw|a~B_M&xx0txVd<}sa zK)w`^{RYHDd3Q?INv<0kk12Z330ntStPscTFiDISm1#GI0h*zm&39#+1wk?pS0|X1Oy<|;-27Yr0wYKi>~x^u=47KgAOWe(2_`OJlc0_V$(fr! zhbRWQ9we~Kl9dId8_C5WgEMXUHmBH~1gnKP8zg?qo_q5dhlgNKqB;;{n7eBYD*(+^ BdLV0FMhJtgDONc^AQEI9l z7uSS1M&pg~M;MKh^Ye;J6g0RvLx2)ct?`a2De-QlIXS+mB^miCeu=rM3J733xj{sP z4X8wki*xb^XE6~hD#2=afNGp70}@LzKq?dUg~hR`1eyz0Jo$qGyAG-=eDljvaXJI6 zi5;W_WcULYDFHOY!3qT2Q%n5vQ&JJm*k~>$g2hmX8+--1(cAzE6`#!F5-eIE4wpsK z;8;+QQ;AhI+^6eA+0ZlrUFl!olb?=NAH->I#5kFN9-V71A%x~pSODrv@i1{|ZdPQn z=aj=AnUgQ@Wles_y#y3{e5j^@rFeK}fT9bVlm_1fd1S+}#L?ypd~%GS7{Sz^JGn++ z-{d4!F@7|kPHx~f+T11hok_o8HIJrwu zWb#@G?#*uE*O)*-i|Jy&&7D%sf`r2JylKhg-P)X+7wBAI#1^O^c}YF~%>w#o8L@>d zP=0f^;RQxQK|2|w@VN1FY{9$vylEv1QNaf?