Skip to content

Commit

Permalink
Add the subcommand "recreate-service"
Browse files Browse the repository at this point in the history
  • Loading branch information
abicky committed Jan 27, 2021
1 parent c2ec595 commit dbe6fab
Show file tree
Hide file tree
Showing 9 changed files with 672 additions and 38 deletions.
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,103 @@ make install

## Usage

### recreate-service

```
$ ecsmec recreate-service --help
This command creates a new service from the specified service with overrides,
and after the new service becomes stable, it deletes the old one.
Therefore, as necessary, you have to increase the capacity of the cluster the
service belongs to manually so that it has enough capacity for the new service
to place its tasks.
Usage:
ecsmec recreate-service [flags]
Examples:
You can change the placement strategy of the service "test" in the default cluster
by the following command:
ecsmec recreate-service --service test --overrides '{
"PlacementStrategy": [
{ "Field": "attribute:ecs.availability-zone", "Type": "spread" },
{ "Field": "CPU", "Type": "binpack" }
]
}'
In the same way, you can change the name of the service "test" in the default
cluster like below:
ecsmec recreate-service --service test --overrides '{
"ServiceName": "new-name"
}'
Flags:
--cluster CLUSTER The name of the target CLUSTER (default "default")
-h, --help help for recreate-service
--overrides JSON An JSON to override some fields of the new service (default "{}")
--service SERVICE The name of the target SERVICE (required)
Global Flags:
--profile string An AWS profile name in your credential file
--region string The AWS region
```

The option "overrides" is in the same format as the [CreateService API](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_CreateService.html) parameter, except that the first letter of each field is uppercase.
Although the [UpdateService API](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_UpdateService.html) supports updating the task placement strategies and constraints, the feature is still in preview and the command helps you to update the task placement strategies and constraints safely.

This command does the following operations to recreate the specified service:

1. Create a temporal service from the service with overrides
1. Delete the old service
1. Create a new service from the temporal service
1. Delete the temporal service

If the service name is overridden, the operations change as follow:

1. Create a new service from the service with overrides
1. Delete the old service


You need the following permissions to execute the command:

```
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecs:ListTasks"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecs:DescribeTasks"
],
"Resource": [
"arn:aws:ecs:<region>:<account-id>:task/<cluster>/*"
]
},
{
"Effect": "Allow",
"Action": [
"ecs:CreateService",
"ecs:DeleteService",
"ecs:DescribeServices",
"ecs:UpdateService"
],
"Resource": [
"arn:aws:ecs:<region>:<account-id>:service/<cluster>/*"
]
}
]
}
```

### reduce-cluster-capacity

```
Expand Down
76 changes: 76 additions & 0 deletions cmd/recreateservice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

import (
"encoding/json"
"strings"

"github.com/aws/aws-sdk-go/service/ecs"
"github.com/spf13/cobra"

"github.com/abicky/ecsmec/internal/service"
)

var recreateServiceCmd *cobra.Command

func init() {
cmd := &cobra.Command{
Use: "recreate-service",
Short: "Recreate a service with overrides",
Long: `This command creates a new service from the specified service with overrides,
and after the new service becomes stable, it deletes the old one.
Therefore, as necessary, you have to increase the capacity of the cluster the
service belongs to manually so that it has enough capacity for the new service
to place its tasks.`,
Example: ` You can change the placement strategy of the service "test" in the default cluster
by the following command:
ecsmec recreate-service --service test --overrides '{
"PlacementStrategy": [
{ "Field": "attribute:ecs.availability-zone", "Type": "spread" },
{ "Field": "CPU", "Type": "binpack" }
]
}'
In the same way, you can change the name of the service "test" in the default
cluster like below:
ecsmec recreate-service --service test --overrides '{
"ServiceName": "new-name"
}'
`,
RunE: recreateService,
}
rootCmd.AddCommand(cmd)

cmd.Flags().String("cluster", "default", "The name of the target `CLUSTER`")

cmd.Flags().String("service", "", "The name of the target `SERVICE` (required)")
cmd.MarkFlagRequired("service")

cmd.Flags().String("overrides", "{}", "An `JSON` to override some fields of the new service")

recreateServiceCmd = cmd
}

func recreateService(cmd *cobra.Command, args []string) error {
cluster, _ := recreateServiceCmd.Flags().GetString("cluster")
serviceName, _ := recreateServiceCmd.Flags().GetString("service")
overrides, _ := recreateServiceCmd.Flags().GetString("overrides")

var overrideDef service.Definition
decoder := json.NewDecoder(strings.NewReader(overrides))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&overrideDef); err != nil {
return newRuntimeError("failed to parse \"overrides\": %w", err)
}

sess, err := newSession()
if err != nil {
return newRuntimeError("failed to initialize a session: %w", err)
}

if err := service.NewService(ecs.New(sess)).Recreate(cluster, serviceName, overrideDef); err != nil {
return newRuntimeError("failed to recreate the service: %w", err)
}
return nil
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ go 1.15
require (
github.com/aws/aws-sdk-go v1.36.28
github.com/golang/mock v1.4.4
github.com/imdario/mergo v0.0.0-00010101000000-000000000000
github.com/spf13/cobra v1.1.1
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
)

replace github.com/imdario/mergo => github.com/abicky/mergo v0.3.12-0.20210127171018-7c7592023899
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/abicky/mergo v0.3.12-0.20210127171018-7c7592023899 h1:NwO67PPpUhZTbVkREk0epqzM/11n+vuBAw4JbrYMNCQ=
github.com/abicky/mergo v0.3.12-0.20210127171018-7c7592023899/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
Expand All @@ -34,6 +36,7 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
Expand Down Expand Up @@ -92,9 +95,11 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
Expand All @@ -105,8 +110,10 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
Expand All @@ -130,6 +137,7 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
Expand Down Expand Up @@ -212,6 +220,7 @@ golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
Expand All @@ -238,6 +247,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
Expand Down Expand Up @@ -284,14 +294,18 @@ google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiq
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
Expand Down
46 changes: 8 additions & 38 deletions internal/capacity/autoscalinggroup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,39 +14,9 @@ import (
"github.com/abicky/ecsmec/internal/const/autoscalingconst"
"github.com/abicky/ecsmec/internal/sliceutil"
"github.com/abicky/ecsmec/internal/testing/mocks"
"github.com/abicky/ecsmec/internal/testing/testutil"
)

func inOrder(calls ...*gomock.Call) *gomock.Call {
gomock.InOrder(calls...)
return calls[len(calls)-1]
}

func matchSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}

aMap := make(map[string]int, len(a))
for _, s := range a {
aMap[s]++
}
for _, s := range b {
if _, ok := aMap[s]; !ok {
return false
}
aMap[s]--
if aMap[s] == 0 {
delete(aMap, s)
}
}

if len(aMap) > 0 {
return false
}

return true
}

func createReservation(instance *autoscaling.Instance, launchTime time.Time) *ec2.Reservation {
return createReservations([]*autoscaling.Instance{instance}, launchTime)[0]
}
Expand Down Expand Up @@ -92,7 +62,7 @@ func expectLaunchNewInstances(
}
}

return inOrder(
return testutil.InOrder(
asMock.EXPECT().DescribeAutoScalingGroups(gomock.Any()).Return(&autoscaling.DescribeAutoScalingGroupsOutput{
AutoScalingGroups: []*autoscaling.Group{
{
Expand Down Expand Up @@ -182,7 +152,7 @@ func expectTerminateInstances(
) *gomock.Call {
t.Helper()

return inOrder(
return testutil.InOrder(
ec2Mock.EXPECT().DescribeInstances(gomock.Any()).Return(&ec2.DescribeInstancesOutput{
Reservations: append(reservationsToTerminate, reservationsToKeep...),
}, nil),
Expand All @@ -195,7 +165,7 @@ func expectTerminateInstances(
want[i] = *instance.InstanceId
}
got := aws.StringValueSlice(input.InstanceIds)
if !matchSlice(want, got) {
if !testutil.MatchSlice(want, got) {
t.Errorf("input.InstanceIds = %v; want %v", got, want)
}
}),
Expand All @@ -206,7 +176,7 @@ func expectTerminateInstances(
want[i] = *instance.InstanceId
}
got := aws.StringValueSlice(input.InstanceIds)
if !matchSlice(want, got) {
if !testutil.MatchSlice(want, got) {
t.Errorf("input.InstanceIds = %v; want %v", got, want)
}
}),
Expand Down Expand Up @@ -235,7 +205,7 @@ func expectRestoreState(
) *gomock.Call {
t.Helper()

return inOrder(
return testutil.InOrder(
asMock.EXPECT().UpdateAutoScalingGroup(gomock.Any()).Do(func(input *autoscaling.UpdateAutoScalingGroupInput) {
if input.DesiredCapacity != nil {
t.Errorf("DesiredCapacity = %d; want nil", *input.DesiredCapacity)
Expand Down Expand Up @@ -759,10 +729,10 @@ func TestAutoScalingGroup_ReduceCapacity(t *testing.T) {
for i, instance := range instancesToTerminate {
instanceIdsToTerminate[i] = *instance.InstanceId
}
if !matchSlice(detachedInstanceIds, instanceIdsToTerminate) {
if !testutil.MatchSlice(detachedInstanceIds, instanceIdsToTerminate) {
t.Errorf("detachedInstanceIds = %v; want %v", detachedInstanceIds, instanceIdsToTerminate)
}
if !matchSlice(terminatedInstanceIds, instanceIdsToTerminate) {
if !testutil.MatchSlice(terminatedInstanceIds, instanceIdsToTerminate) {
t.Errorf("terminatedInstanceIds = %v; want %v", terminatedInstanceIds, instanceIdsToTerminate)
}

Expand Down
Loading

0 comments on commit dbe6fab

Please sign in to comment.