diff --git a/Makefile b/Makefile index 7d61532..850246c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ build: lint @echo "building binary..." @go build -o $(BIN_NAME) main.go && echo "$(BIN_NAME) built" -install: lint +install: @$(shell whereis $(BIN_NAME) | cut -d ' ' -f2 | xargs rm -f) @go install @echo "program installed: $(GOPATH)/bin/hmdeploy" diff --git a/docker/client.go b/docker/client.go index 1bf144a..7bc5165 100644 --- a/docker/client.go +++ b/docker/client.go @@ -1,18 +1,15 @@ package docker import ( + "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" - "slices" - "strconv" - "strings" "gitea.thegux.fr/hmdeploy/connection" "gitea.thegux.fr/hmdeploy/models" - "gitea.thegux.fr/hmdeploy/utils" ) const ( @@ -30,204 +27,19 @@ var ( ErrDockerClientExtractServicesParse = errors.New("parse error") ) -type Port struct { - In int - Out int -} - -type Service struct { - App string - Name string - Image struct { - Name string - Tag string - } - Ports []Port - Replicas int -} - -func (s Service) String() string { - return fmt.Sprintf( - "%-20s | %-20s | %-30s | %-10s | %-5d", - s.App, - s.Name, - s.Image.Name, - s.Image.Tag, - s.Replicas, - ) -} - -type Services []Service - -func (s Services) String() string { - data := []string{} - header := fmt.Sprintf( - "%-20s | %-20s | %-30s | %-10s | %-5s", - "Service name", - "App name", - "Image name", - "Image Tag", - "Replicas", - ) - hr := "" - for i := 0; i < len(header); i++ { - hr += "-" - } - data = append(data, hr) - data = append(data, header) - data = append(data, hr) - for idx := range s { - data = append(data, s[idx].String()) - } - return strings.Join(data, "\n") -} - -type serviceDetailsHeader map[string]int - -func (h serviceDetailsHeader) getPos(value string) int { - v, ok := h[value] - if !ok { - return -1 - } - return v -} - -// serviceFromString instantiates a new service from the `docker service ls` command output. -// TODO: it's ugly... To rework, if i'm not too lazy -func serviceFromString( //nolint:all // todo - header serviceDetailsHeader, - value string, -) (Service, error) { - var srv Service - - parts := strings.Split(value, " ") - parts = slices.DeleteFunc(parts, func(e string) bool { return e == "" }) - - if pos := header.getPos("name"); pos != -1 { - appParts := strings.Split(parts[pos], "_") - if len(appParts) >= appPartsLength { - srv.App = appParts[len(appParts)-1] - srv.Name = appParts[0] - } - } - - if pos := header.getPos("image"); pos != -1 { - imageParts := strings.Split(parts[pos], ":") - if len(imageParts) >= imagePartsLength { - srv.Image.Name = imageParts[0] - srv.Image.Tag = imageParts[1] - } - } - - if pos := header.getPos("replicas"); pos != -1 { - replicasParts := strings.Split(parts[pos], "/") - if len(replicasParts) >= replicasPartLength { - replicas, err := strconv.Atoi(replicasParts[1]) - if err != nil { - return srv, fmt.Errorf( - "%w, replicas conversion failed: %s", - ErrDockerClientExtractServicesParse, - string(replicasParts[1]), - ) - } - srv.Replicas = replicas - } - } - - srv.Ports = []Port{} - - if pos := header.getPos("ports"); pos != -1 && pos < len(parts) { - // ports parts must exceed the header pos so, to find missing ports - // the pos difference is computed in order to retrieve them - portsParts := []string{} - diff := len(parts) - pos - for i := 0; i < diff; i++ { - portsParts = append(portsParts, parts[pos+i]) - } - - for _, port := range portsParts { - servicePort := Port{} - // port in/out are displayed like "out->in" - portParts := strings.Split(port, "->") - if len(portParts) >= portPartsLength { - // out port is displayed like "ip:port" - portOutParts := strings.Split(portParts[0], ":") - if len(portOutParts) == portOutPartsLength { - portOut, err := utils.CatchAndConvertNumber(portOutParts[1]) - if err != nil { - return srv, fmt.Errorf( - "%w, out port conversion failed: %s", - ErrDockerClientExtractServicesParse, - string(portOutParts[1]), - ) - } - servicePort.Out = portOut - } - - portIn, err := utils.CatchAndConvertNumber(portParts[1]) - if err != nil { - return srv, fmt.Errorf( - "%w, in port conversion failed: %s", - ErrDockerClientExtractServicesParse, - string(portParts[1]), - ) - } - servicePort.In = portIn - - srv.Ports = append(srv.Ports, servicePort) - } - } - } - - return srv, nil -} - -// Parse the `docker service ls` header (ID NAME MODE REPLICAS IMAGE PORTS) -func parseServiceDetailsHeader(value string) serviceDetailsHeader { - header := serviceDetailsHeader{} - - parts := strings.Split(value, " ") - parts = slices.DeleteFunc(parts, func(e string) bool { return e == "" }) - - for idx, part := range parts { - header[part] = idx - } - - return header -} - -func parseServiceDetails(cmdOutput string) (Services, error) { - services := Services{} - var header serviceDetailsHeader - +func parseIDs(cmdOutput string) []string { + ids := []string{} bufLine := []rune{} - isHeaderCatched := false for _, c := range cmdOutput { if c == '\n' { - if !isHeaderCatched { - header = parseServiceDetailsHeader(strings.ToLower(string(bufLine))) - bufLine = bufLine[:0] - isHeaderCatched = true - continue - } - - service, err := serviceFromString(header, string(bufLine)) - if err != nil { - return services, fmt.Errorf( - "%w, err=%v", - ErrDockerClientExtractServicesParse, - err, - ) - } - services = append(services, service) + ids = append(ids, string(bufLine)) bufLine = bufLine[:0] continue } bufLine = append(bufLine, c) } - - return services, nil + return ids } type IClient interface { @@ -301,16 +113,49 @@ func NewRemoteClient(netInfo *models.HMNetInfo) (RemoteClient, error) { return rc, nil } -func (c *RemoteClient) ExtractServicesDetails() (Services, error) { - output, err := c.conn.Execute("docker service ls") +func (c *RemoteClient) getIDS() ([]string, error) { + output, err := c.conn.Execute("docker service ls -q") if err != nil { return nil, err } - services, err := parseServiceDetails(output) + return parseIDs(output), nil +} + +func (c *RemoteClient) getServiceDetails(id string) (Service, error) { + output, err := c.conn.Execute( + fmt.Sprintf( + `echo "{\"services\": [$(echo $(cmd=$(docker service ps %s --format json); cmd=$(echo $cmd | tr '} {' '},{'); echo $cmd))], \"details\": $(docker service inspect %s --format=json)}"`, + id, + id, + ), + ) + if err != nil { + return Service{}, err + } + + sc := Service{} + if err := json.Unmarshal([]byte(output), &sc); err != nil { + return sc, err + } + + return sc, nil +} + +func (c *RemoteClient) ExtractServicesDetails() (Services, error) { + ids, err := c.getIDS() if err != nil { return nil, err } + services := Services{} + for _, id := range ids { + srv, err := c.getServiceDetails(id) + if err != nil { + return nil, err + } + services = append(services, srv) + } + return services, nil } diff --git a/docker/models.go b/docker/models.go new file mode 100644 index 0000000..f82db47 --- /dev/null +++ b/docker/models.go @@ -0,0 +1,268 @@ +package docker + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +const nbImageParts = 2 + +type ServiceStatus string + +// docker engine service status +const ( + Pending ServiceStatus = "pending" + Restarting ServiceStatus = "preparing" + Starting = "starting" + Running = "running" + Failed = "failed" + Shutdown = "shutdown" + Rejected = "rejected" + Orphaned = "orphaned" + Complete = "complete" + New = "new" + Unknown = "unknown" +) + +var mapServiceStatus = map[string]ServiceStatus{ + "pending": Pending, + "restarting": Restarting, + "starting": Starting, + "running": Running, + "failed": Failed, + "shutdown": Shutdown, + "rejected": Rejected, + "orphaned": Orphaned, + "complete": Complete, + "new": New, +} + +func serviceStatusFromString(value string) ServiceStatus { + if v, ok := mapServiceStatus[strings.ToLower(value)]; ok { + return v + } + return Unknown +} + +func getReplicas(states []serviceState, nbReplicas int) []Replicas { + replicas := []Replicas{} + + // collect latest services for each replicas + rep := 1 + for idx := range states { + if rep > nbReplicas { + break + } + if states[idx].ReplicasNumber == rep { + replicas = append(replicas, Replicas{ + Pos: rep, + State: serviceStatusFromString(states[idx].Current), + Error: states[idx].Error, + }) + rep++ + } + } + + return replicas +} + +type Replicas struct { + State ServiceStatus + Error string + Pos int +} + +type ServicePort struct { + Target int `json:"TargetPort"` + Published int `json:"PublishedPort"` +} + +type serviceCommandInput struct { + States []serviceState `json:"services"` + Details []serviceDetails `json:"details"` +} + +type Service struct { + ID string + App string + Name string + Image struct { + Name string + Tag string + } + Replicas []Replicas + Ports []ServicePort +} + +func (s *Service) UnmarshalJSON(data []byte) error { + ci := serviceCommandInput{} + if err := json.Unmarshal(data, &ci); err != nil { + return err + } + + if len(ci.Details) == 0 { + return fmt.Errorf("service details must no be empty") + } + + nbReplicas := ci.Details[0].Spec.Mode.Replicated.Replicas + if len(ci.States) < nbReplicas { + return fmt.Errorf("must have %d replicas but have %d", nbReplicas, len(ci.States)) + } + + replicas := getReplicas(ci.States, nbReplicas) + + imageName := ci.Details[0].Spec.Labels.Image + imageNameParts := strings.Split(imageName, ":") + if len(imageNameParts) == nbImageParts { + imageName = imageNameParts[0] + } + + appName := ci.Details[0].Spec.Labels.Namespace + + *s = Service{ + ID: ci.Details[0].ID, + App: appName, + Name: strings.TrimPrefix(ci.Details[0].Spec.Name, appName+"_"), + Image: struct { + Name string + Tag string + }{ + Name: imageName, + Tag: ci.Details[0].Spec.Labels.Tag, + }, + Ports: ci.Details[0].Endpoint.Ports, + Replicas: replicas, + } + + return nil +} + +func (s Service) String() string { + data := []string{} + fl := fmt.Sprintf( + "%-20s | %-20s | %-30s | %-10s | %-5d", + s.App, + s.Name, + s.Image.Name, + s.Image.Tag, + len(s.Replicas), + ) + data = append(data, fl) + + for idx := range s.Replicas { + data = append(data, fmt.Sprintf( + "%-20s \\ %-20d | %-30s | %-30s", + "", + s.Replicas[idx].Pos, + s.Replicas[idx].State, + s.Replicas[idx].Error, + )) + } + + return strings.Join(data, "\n") +} + +type Services []Service + +func (s Services) GetIDs() []string { + ids := []string{} + for idx := range s { + ids = append(ids, s[idx].ID) + } + return ids +} + +func (s Services) String() string { + data := []string{} + header := fmt.Sprintf( + "%-20s | %-20s | %-30s | %-10s | %-5s", + "Service name", + "Name", + "Image name", + "Image Tag", + "Replicas", + ) + subheader := fmt.Sprintf( + "%-20s \\ %-20s | %-30s | %-30s", + "", + "Replicas", + "Status", + "Error", + ) + hr := "" + for i := 0; i < len(header); i++ { + hr += "*" + } + subhr := "" + for i := 0; i < len(header); i++ { + subhr += "-" + } + data = append(data, hr) + data = append(data, header) + data = append(data, subheader) + data = append(data, hr) + for idx := range s { + data = append(data, s[idx].String()) + data = append(data, subhr) + } + return strings.Join(data, "\n") +} + +type serviceDetails struct { + ID string `json:"ID"` + CreateAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Spec struct { + Name string `json:"Name"` + Labels struct { + Image string `json:"com.docker.stack.image"` + Tag string + Namespace string `json:"com.docker.stack.namespace"` + } `json:"Labels"` + Mode struct { + Replicated struct { + Replicas int `json:"Replicas"` + } `json:"Replicated"` + } `json:"Mode"` + } `json:"spec"` + Endpoint struct { + Ports []ServicePort `json:"Ports"` + } `json:"Endpoint"` +} + +type serviceStateInput serviceState + +type serviceState struct { + Name string `json:"Name"` + Current string `json:"CurrentState"` + Desired string `json:"DesiredState"` + Error string `json:"Error"` + ReplicasNumber int +} + +func (st *serviceState) UnmarshalJSON(data []byte) error { + state := serviceStateInput{} + if err := json.Unmarshal(data, &state); err != nil { + return err + } + + nameParts := strings.Split(state.Name, ".") + if len(nameParts) > 0 { + replicasNumber, err := strconv.Atoi(nameParts[1]) + if err != nil { + return err + } + state.ReplicasNumber = replicasNumber + } + + currentStateParts := strings.Split(state.Current, ",") + if len(currentStateParts) > 0 { + state.Current = currentStateParts[0] + } + + *st = (serviceState)(state) + return nil +}