move command struct in separate file

This commit is contained in:
rmanach 2025-04-29 10:51:47 +02:00
parent b9d594ea37
commit 5330eac174
3 changed files with 310 additions and 197 deletions

View File

@ -7,7 +7,7 @@ build: lint
@echo "building binary..." @echo "building binary..."
@go build -o $(BIN_NAME) main.go && echo "$(BIN_NAME) built" @go build -o $(BIN_NAME) main.go && echo "$(BIN_NAME) built"
install: lint install:
@$(shell whereis $(BIN_NAME) | cut -d ' ' -f2 | xargs rm -f) @$(shell whereis $(BIN_NAME) | cut -d ' ' -f2 | xargs rm -f)
@go install @go install
@echo "program installed: $(GOPATH)/bin/hmdeploy" @echo "program installed: $(GOPATH)/bin/hmdeploy"

View File

@ -1,18 +1,15 @@
package docker package docker
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strconv"
"strings"
"gitea.thegux.fr/hmdeploy/connection" "gitea.thegux.fr/hmdeploy/connection"
"gitea.thegux.fr/hmdeploy/models" "gitea.thegux.fr/hmdeploy/models"
"gitea.thegux.fr/hmdeploy/utils"
) )
const ( const (
@ -30,204 +27,19 @@ var (
ErrDockerClientExtractServicesParse = errors.New("parse error") ErrDockerClientExtractServicesParse = errors.New("parse error")
) )
type Port struct { func parseIDs(cmdOutput string) []string {
In int ids := []string{}
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
bufLine := []rune{} bufLine := []rune{}
isHeaderCatched := false
for _, c := range cmdOutput { for _, c := range cmdOutput {
if c == '\n' { if c == '\n' {
if !isHeaderCatched { ids = append(ids, string(bufLine))
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)
bufLine = bufLine[:0] bufLine = bufLine[:0]
continue continue
} }
bufLine = append(bufLine, c) bufLine = append(bufLine, c)
} }
return ids
return services, nil
} }
type IClient interface { type IClient interface {
@ -301,16 +113,49 @@ func NewRemoteClient(netInfo *models.HMNetInfo) (RemoteClient, error) {
return rc, nil return rc, nil
} }
func (c *RemoteClient) ExtractServicesDetails() (Services, error) { func (c *RemoteClient) getIDS() ([]string, error) {
output, err := c.conn.Execute("docker service ls") output, err := c.conn.Execute("docker service ls -q")
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err 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 return services, nil
} }

268
docker/models.go Normal file
View File

@ -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
}