├── assets ├── logo.png ├── nodes.png └── nodes-zh.png ├── main.go ├── Dockerfile ├── config ├── parse_env.go └── config.go ├── api ├── v1 │ ├── model │ │ ├── image_req.go │ │ ├── error_code.go │ │ ├── network_req.go │ │ ├── options.go │ │ ├── response.go │ │ ├── types.go │ │ └── container_req.go │ └── handler │ │ ├── image.go │ │ ├── volume.go │ │ ├── network.go │ │ ├── container.go │ │ └── v1.go ├── middleware.go ├── factory │ └── base.go ├── server.go └── router.go ├── deploy └── deploy.md ├── .gitignore ├── CHANGELOG.md ├── ErrorCode.md ├── pkg └── utils │ ├── fs.go │ ├── signal.go │ ├── time.go │ ├── callable.go │ └── sys.go ├── internal ├── docker │ └── docker_cli.go ├── client │ └── request.go └── schedule │ ├── scheduler.go │ └── task.go ├── config.yaml ├── .github └── workflows │ ├── build-develop.yml │ └── build-master.yml ├── README.zh.md ├── README.md ├── model ├── host.go └── container.go ├── controller ├── network.go ├── image.go ├── controller.go └── container.go ├── go.mod ├── app └── app.go ├── LICENSE ├── service └── agent.go └── go.sum /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humpback/humpback-agent/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humpback/humpback-agent/HEAD/assets/nodes.png -------------------------------------------------------------------------------- /assets/nodes-zh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humpback/humpback-agent/HEAD/assets/nodes-zh.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "humpback-agent/app" 6 | ) 7 | 8 | func main() { 9 | app.Bootstrap(context.Background()) 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN mkdir -p /workspace 4 | 5 | COPY ./config.yaml /workspace 6 | 7 | COPY ./humpback-agent /workspace/ 8 | 9 | WORKDIR /workspace 10 | 11 | CMD ["./humpback-agent"] 12 | -------------------------------------------------------------------------------- /config/parse_env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/caarlos0/env/v11" 4 | 5 | func ParseConfigFromEnv(appConfig *AppConfig) error { 6 | if err := env.Parse(appConfig); err != nil { 7 | return err 8 | } 9 | return nil 10 | } 11 | -------------------------------------------------------------------------------- /api/v1/model/image_req.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type GetImageRequest struct { 4 | ImageId string `json:"imageId"` 5 | } 6 | 7 | type QueryImageRequest struct{} 8 | type PushImageRequest struct{} 9 | type PullImageRequest struct { 10 | Image string `json:"image"` 11 | All bool `json:"all"` 12 | Platform string `json:"platform"` 13 | ServerAddress string 14 | UserName string 15 | Password string 16 | } 17 | type DeleteImageRequest struct{} 18 | -------------------------------------------------------------------------------- /deploy/deploy.md: -------------------------------------------------------------------------------- 1 | ```shell 2 | 3 | docker run -d \ 4 | --name=humpback-agent \ 5 | --net=host \ 6 | --restart=always \ 7 | --privileged \ 8 | -v /etc/localtime:/etc/localtime \ 9 | -v /var/run/docker.sock:/var/run/docker.sock \ 10 | -v /var/lib/docker:/var/lib/docker \ 11 | -e HUMPBACK_AGENT_API_BIND=0.0.0.0:8018 \ 12 | -e HUMPBACK_SERVER_HOST={server-address}:{server-backend-port} \ 13 | -e HUMPBACK_VOLUMES_ROOT_DIRECTORY=/var/lib/docker \ 14 | docker.io/humpbacks/humpback-agent:develop 15 | ``` -------------------------------------------------------------------------------- /api/v1/handler/image.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func (handler *V1Handler) GetImageHandleFunc(c *gin.Context) { 6 | } 7 | 8 | func (handler *V1Handler) QueryImageHandleFunc(c *gin.Context) { 9 | 10 | } 11 | 12 | func (handler *V1Handler) PushImageHandleFunc(c *gin.Context) { 13 | 14 | } 15 | 16 | func (handler *V1Handler) PullImageHandleFunc(c *gin.Context) { 17 | 18 | } 19 | 20 | func (handler *V1Handler) DeleteImageHandleFunc(c *gin.Context) { 21 | 22 | } 23 | -------------------------------------------------------------------------------- /api/v1/handler/volume.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func (handler *V1Handler) GetVolumeHandleFunc(c *gin.Context) { 6 | } 7 | 8 | func (handler *V1Handler) QueryVolumeHandleFunc(c *gin.Context) { 9 | 10 | } 11 | 12 | func (handler *V1Handler) CreateVolumeHandleFunc(c *gin.Context) { 13 | 14 | } 15 | 16 | func (handler *V1Handler) UpdateVolumeHandleFunc(c *gin.Context) { 17 | 18 | } 19 | 20 | func (handler *V1Handler) DeleteVolumeHandleFunc(c *gin.Context) { 21 | 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | vendor/ 26 | **/.idea/** 27 | .idea 28 | humpback-agent 29 | humpback-agent.exe 30 | humpback_agent 31 | humpback_agent.exe 32 | humpback_agent_linux 33 | .vscode -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | version 1.2.4 2 | --- 3 | - Add DOCKER_AGENT_IPADDR env, Register to cluster ipaddr, default 0.0.0.0 auto bind local ipaddr. 4 | 5 | version 1.1.2 (not released) 6 | --- 7 | - Change github.com/docker/engine-api(Deprecated) to github.com/docker/docker/client 8 | 9 | version 1.1.1 10 | --- 11 | - Remove network mode's validate when create container. 12 | 13 | version 1.3.0 14 | --- 15 | - Add container `--log-opt` property. 16 | 17 | version 1.3.3 18 | --- 19 | - Supported docker compose containers. 20 | 21 | version 1.3.4 22 | --- 23 | - Supported edit single mode and cluster mode containers. 24 | -------------------------------------------------------------------------------- /ErrorCode.md: -------------------------------------------------------------------------------- 1 | # Container 2 | > `20001` 3 | Container already created, but cannot start 4 | 5 | > `20002` 6 | Get container info failed when upgrade container 7 | 8 | > `20003` 9 | Rename container failed when upgrade or update container 10 | 11 | > `20004` 12 | Try pull image failed when upgrade container 13 | 14 | > `20005` 15 | Stop container failed when upgrade or update container 16 | 17 | > `20006` 18 | Cannot create new container when upgrade container 19 | 20 | > `20007` 21 | Cannot start new container when upgrade container 22 | 23 | > `20008` 24 | Cannot delete old container when upgrade container, but image is upgrade succeed 25 | 26 | > `21001` 27 | Try pull image failed when create or update container. -------------------------------------------------------------------------------- /pkg/utils/fs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func WriteFileWithDir(filePath string, data []byte, perm os.FileMode) error { 10 | // 获取文件夹路径 11 | dir := filepath.Dir(filePath) 12 | // 递归创建文件夹(如果不存在) 13 | if err := os.MkdirAll(dir, 0755); err != nil { 14 | return err 15 | } 16 | // 写入文件 17 | return os.WriteFile(filePath, data, perm) 18 | } 19 | 20 | func FileExists(filePath string) (bool, error) { 21 | // 使用 os.Stat 获取文件信息 22 | info, err := os.Stat(filePath) 23 | if err == nil { 24 | // 文件存在 25 | return !info.IsDir(), nil // 确保是文件而不是目录 26 | } 27 | if errors.Is(err, os.ErrNotExist) { 28 | // 文件不存在 29 | return false, nil 30 | } 31 | // 其他错误(如权限问题) 32 | return false, err 33 | } 34 | -------------------------------------------------------------------------------- /api/v1/model/error_code.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | const ( 4 | //System error codes 5 | ServerInternalErrorCode = "SYS90000" 6 | ServerInternalErrorMsg = "internal server error" 7 | RequestArgsErrorCode = "SYS90001" 8 | RequestArgsErrorMsg = "request args invalid" 9 | //Container error codes 10 | ContainerNotFoundCode = "CNT10000" 11 | ContainerCreateErrorCode = "CNT10001" 12 | ContainerDeleteErrorCode = "CNT10002" 13 | ContainerLogsErrorCode = "CNT10003" 14 | ContainerGetErrorCode = "CNT10004" 15 | ContainerStatsErrorCode = "CNT10005" 16 | //Image error codes 17 | ImageNotFoundCode = "IMG10000" 18 | ImagePullErrorCode = "IMG10001" 19 | //Network error codes 20 | NetworkNotFoundCode = "NET10000" 21 | NetworkCreateErrorCode = "NET10001" 22 | NetworkDeleteErrorCode = "NET10002" 23 | ) 24 | -------------------------------------------------------------------------------- /pkg/utils/signal.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | ) 8 | 9 | type SignalExitFunc func() 10 | 11 | func ProcessWaitForSignal(exitFunc SignalExitFunc) { 12 | SIGNAL_WAIT: 13 | signalCh := make(chan os.Signal, 1) 14 | signal.Notify(signalCh, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL) 15 | for { 16 | select { 17 | case sigVal := <-signalCh: 18 | { 19 | switch sigVal { 20 | case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL: 21 | if exitFunc != nil { 22 | exitFunc() 23 | } 24 | close(signalCh) 25 | return 26 | case syscall.SIGHUP: 27 | close(signalCh) 28 | goto SIGNAL_WAIT 29 | default: 30 | close(signalCh) 31 | goto SIGNAL_WAIT 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/docker/docker_cli.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "github.com/docker/docker/client" 5 | "humpback-agent/config" 6 | ) 7 | 8 | func BuildDockerClient(config *config.DockerConfig) (*client.Client, error) { 9 | opts := []client.Opt{ 10 | client.WithHost(config.Host), 11 | //client.WithHTTPClient(&http.Client{ 12 | // Timeout: config.DockerTimeoutOpts.Connection, 13 | //}), 14 | } 15 | 16 | if config.DockerTLSOpts.Enabled { 17 | opts = append(opts, client.WithTLSClientConfig(config.DockerTLSOpts.CAPath, config.DockerTLSOpts.CertPath, config.DockerTLSOpts.KeyPath)) 18 | } 19 | 20 | if config.AutoNegotiate { 21 | opts = append(opts, client.WithAPIVersionNegotiation()) // Humpback 使用 docker sdk 与 docker daemon 自动协商版本 22 | } else { 23 | opts = append(opts, client.WithVersion(config.Version)) 24 | } 25 | return client.NewClientWithOpts(opts...) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/utils/time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func HumanDuration(d time.Duration) string { 9 | if seconds := int(d.Seconds()); seconds < 1 { 10 | return "Less than a second" 11 | } else if seconds == 1 { 12 | return "Up 1 second" 13 | } else if seconds < 60 { 14 | return fmt.Sprintf("Up %d seconds", seconds) 15 | } else if minutes := int(d.Minutes()); minutes == 1 { 16 | return "About a minute" 17 | } else if minutes < 60 { 18 | return fmt.Sprintf("Up %d minutes", minutes) 19 | } else if hours := int(d.Hours() + 0.5); hours == 1 { 20 | return "About an hour" 21 | } else if hours < 48 { 22 | return fmt.Sprintf("Up %d hours", hours) 23 | } else if hours < 24*7*2 { 24 | return fmt.Sprintf("Up %d days", hours/24) 25 | } else if hours < 24*30*2 { 26 | return fmt.Sprintf("Up %d weeks", hours/24/7) 27 | } else if hours < 24*365*2 { 28 | return fmt.Sprintf("Up %d months", hours/24/30) 29 | } 30 | return fmt.Sprintf("Up %d years", int(d.Hours())/24/365) 31 | } 32 | -------------------------------------------------------------------------------- /api/v1/model/network_req.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | type GetNetworkRequest struct { 8 | NetworkId string `json:"networkId"` 9 | } 10 | 11 | type CreateNetworkRequest struct { 12 | NetworkName string `json:"networkName"` 13 | Driver string `json:"driver"` 14 | Scope string `json:"scope"` 15 | } 16 | 17 | func BindCreateNetworkRequest(c *gin.Context) (*CreateNetworkRequest, *ErrorResult) { 18 | request := &CreateNetworkRequest{} 19 | if err := c.ShouldBindJSON(request); err != nil { 20 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 21 | } 22 | return request, nil 23 | } 24 | 25 | type DeleteNetworkRequest struct { 26 | NetworkId string `json:"networkId"` 27 | Scope string `json:"scope"` 28 | } 29 | 30 | func BindDeleteNetworkRequest(c *gin.Context) (*DeleteNetworkRequest, *ErrorResult) { 31 | networkId := c.Param("networkId") 32 | scope := c.Query("scope") 33 | return &DeleteNetworkRequest{ 34 | NetworkId: networkId, 35 | Scope: scope, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /api/v1/handler/network.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | v1model "humpback-agent/api/v1/model" 6 | "net/http" 7 | ) 8 | 9 | func (handler *V1Handler) GetNetworkHandleFunc(c *gin.Context) { 10 | } 11 | 12 | func (handler *V1Handler) QueryNetworkHandleFunc(c *gin.Context) { 13 | 14 | } 15 | 16 | func (handler *V1Handler) CreateNetworkHandleFunc(c *gin.Context) { 17 | request, err := v1model.BindCreateNetworkRequest(c) 18 | if err != nil { 19 | c.JSON(err.StatusCode, err) 20 | return 21 | } 22 | 23 | handler.taskChan <- &V1Task{NetworkCreateTask, request} 24 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 25 | } 26 | 27 | func (handler *V1Handler) UpdateNetworkHandleFunc(c *gin.Context) { 28 | 29 | } 30 | 31 | func (handler *V1Handler) DeleteNetworkHandleFunc(c *gin.Context) { 32 | request, err := v1model.BindDeleteNetworkRequest(c) 33 | if err != nil { 34 | c.JSON(err.StatusCode, err) 35 | return 36 | } 37 | 38 | handler.taskChan <- &V1Task{NetworkDeleteTask, request} 39 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 40 | } 41 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | #API 2 | api: 3 | port: 8018 4 | hostIp: 5 | mode: debug 6 | middlewares: ["cors", "recovery", "logger"] 7 | versions: ["/v1"] 8 | 9 | # 服务器配置 10 | server: 11 | host: 172.30.112.1:8101 12 | registerToken: OR9dfc2kTTD5it51 13 | health: 14 | interval: 30s 15 | timeout: 15s 16 | 17 | volumes: 18 | rootDirectory: /opt/app/docker 19 | 20 | #Docker客户端配置 21 | docker: 22 | host: unix:///var/run/docker.sock # 或者 "tcp://localhost:2375" 23 | version: "1.41" # 指定 Docker API 版本 24 | autoNegotiate: true # 是否启用API自动协商 25 | timeout: 26 | connection: 10s # 连接超时 27 | request: 120s # 请求超时 28 | tls: 29 | enabled: false 30 | caPath: "/path/to/ca.pem" 31 | certPath: "/path/to/cert.pem" 32 | keyPath: "/path/to/key.pem" 33 | insecureSkipVerify: false 34 | registry: 35 | default: "registry.example.com" # 默认镜像仓库 36 | userName: "user" 37 | password: "password" 38 | 39 | #日志配置 40 | logger: 41 | logFile: null 42 | level: info 43 | format: json 44 | maxSize: 20971520 # 20 MB 45 | maxBackups: 3 # 保留的旧日志文件数量 46 | maxAge: 7 # 保留天数 47 | compress: false # 是否压缩旧日志 -------------------------------------------------------------------------------- /.github/workflows/build-develop.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | branches: 8 | - develop 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.24' 22 | 23 | - name: Install dependencies and build backend 24 | run: | 25 | go mod download 26 | CGO_ENABLED=0 go build -o humpback-agent 27 | 28 | - name: Login Docker Hub 29 | uses: docker/login-action@v3 30 | with: 31 | username: ${{ secrets.DOCKER_USERNAME }} 32 | password: ${{ secrets.DOCKER_PASSWORD }} 33 | 34 | - name: Docker meta 35 | id: meta 36 | uses: docker/metadata-action@v5 37 | with: 38 | images: humpbacks/humpback-agent 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v6 42 | with: 43 | context: . 44 | file: ./Dockerfile 45 | push: true 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/build-master.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.24' 22 | 23 | - name: Install dependencies and build backend 24 | run: | 25 | go mod download 26 | CGO_ENABLED=0 go build -o humpback-agent 27 | 28 | - name: Login Docker Hub 29 | uses: docker/login-action@v3 30 | with: 31 | username: ${{ secrets.DOCKER_USERNAME }} 32 | password: ${{ secrets.DOCKER_PASSWORD }} 33 | 34 | - name: Docker meta 35 | id: meta 36 | uses: docker/metadata-action@v5 37 | with: 38 | images: humpbacks/humpback-agent 39 | tags: | 40 | type=ref,event=tag 41 | type=raw,value=latest 42 | 43 | - name: Build and push 44 | uses: docker/build-push-action@v6 45 | with: 46 | context: . 47 | file: ./Dockerfile 48 | push: true 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /api/middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-contrib/cors" 5 | "github.com/gin-gonic/gin" 6 | "strings" 7 | ) 8 | 9 | var defaultMiddlewares = map[string]gin.HandlerFunc{ 10 | "cors": cors.New(cors.Config{ 11 | AllowOrigins: []string{"*"}, 12 | AllowMethods: []string{"*"}, 13 | AllowHeaders: []string{"*"}, 14 | }), 15 | "recovery": gin.Recovery(), 16 | "logger": gin.Logger(), 17 | } 18 | 19 | func MiddlewareCors() gin.HandlerFunc { 20 | return cors.New(cors.Config{ 21 | AllowOrigins: []string{"*"}, 22 | AllowMethods: []string{"*"}, 23 | AllowHeaders: []string{"*"}, 24 | }) 25 | } 26 | 27 | func MiddlewareLogger() gin.HandlerFunc { 28 | return gin.Logger() 29 | } 30 | 31 | func MiddlewareRecovery() gin.HandlerFunc { 32 | return gin.Recovery() 33 | } 34 | 35 | func Middleware(middleware string) gin.HandlerFunc { 36 | middleFunc, ret := defaultMiddlewares[middleware] 37 | if !ret { 38 | return nil 39 | } 40 | return middleFunc 41 | } 42 | 43 | func Middlewares(middleware ...string) []gin.HandlerFunc { 44 | middlewares := []gin.HandlerFunc{} 45 | for _, name := range middleware { 46 | name = strings.TrimSpace(strings.ToLower(name)) 47 | if middleFunc, ret := defaultMiddlewares[name]; ret { 48 | middlewares = append(middlewares, middleFunc) 49 | } 50 | } 51 | return middlewares 52 | } 53 | 54 | func AllMiddlewares() []gin.HandlerFunc { 55 | middlewares := []gin.HandlerFunc{} 56 | for _, middlewareFunc := range defaultMiddlewares { 57 | middlewares = append(middlewares, middlewareFunc) 58 | } 59 | return middlewares 60 | } 61 | -------------------------------------------------------------------------------- /api/factory/base.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "humpback-agent/controller" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | ErrRegisterHandlerVersionInvalid = errors.New("register handler version invalid") 12 | ErrRegisterHandlerVersionAlreadyExist = errors.New("register handler version already registered") 13 | ErrConstructHandlerNotImplemented = errors.New("construct handler not implemented") 14 | ) 15 | 16 | type Initializer interface { 17 | SetRouter(version string, engine *gin.Engine) 18 | } 19 | 20 | type BaseHandler struct { 21 | Initializer 22 | controller.ControllerInterface 23 | } 24 | 25 | type HandlerInterface any 26 | 27 | type ConstructHandlerFunc func(controller controller.ControllerInterface) HandlerInterface 28 | 29 | var handlers = map[string]ConstructHandlerFunc{} 30 | 31 | func InjectHandler(version string, constructFunc ConstructHandlerFunc) error { 32 | version = strings.ToLower(strings.TrimSpace(version)) 33 | if version == "" { 34 | return ErrRegisterHandlerVersionInvalid 35 | } 36 | 37 | if _, ret := handlers[version]; ret { 38 | return ErrRegisterHandlerVersionAlreadyExist 39 | } 40 | 41 | handlers[version] = constructFunc 42 | return nil 43 | } 44 | 45 | func HandlerConstruct(version string, controller controller.ControllerInterface) (any, error) { 46 | version = strings.ToLower(strings.TrimSpace(version)) 47 | constructFunc, ret := handlers[version] 48 | if !ret { 49 | return nil, ErrConstructHandlerNotImplemented 50 | } 51 | 52 | handler := constructFunc(controller) 53 | return handler, nil 54 | } 55 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # humpback-agent 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/docker/docker)](https://golang.org/) 4 | [![Docker](https://img.shields.io/badge/docker-pull-blue?logo=docker)](https://hub.docker.com/r/humpbacks/humpback-agent) 5 | [![Base: Moby](https://img.shields.io/badge/Base-Moby-2496ED?logo=docker&logoColor=white)](https://github.com/moby/moby) 6 | [![Release](https://img.shields.io/badge/release-v2.0.0-blue)](https://github.com/humpback/humpback-agent/releases/tag/v2.0.0) 7 | 8 | ![Humpback logo](/assets/logo.png) 9 | 10 | Humpback的服务执行程序,为Humpback提供容器操作和Cron执行。 11 | 12 | ## 语言 13 | 14 | - [English](README.md) 15 | - [中文](README.zh.md) 16 | 17 | ## 特征 18 | 19 | - 心跳汇报。 20 | - 容器操作。 21 | - 支持Cron. 22 | 23 | ## 快速开始 24 | 25 | * [Humpback Guides](https://humpback.github.io/humpback) 26 | 27 | ## 安装 28 | 29 | Humpback Agent默认会监听8018端口用于接收Humpback Server的调用。 30 | 31 | ```bash 32 | 33 | docker run -d \ 34 | --name=humpback-agent \ 35 | --net=host \ 36 | --restart=always \ 37 | --privileged \ 38 | -v /var/run/docker.sock:/var/run/docker.sock \ 39 | -v /var/lib/docker:/var/lib/docker \ 40 | -e HUMPBACK_SERVER_REGISTER_TOKEN={token} \ 41 | -e HUMPBACK_SERVER_HOST={server-address}:8101 \ 42 | -e HUMPBACK_VOLUMES_ROOT_DIRECTORY=/var/lib/docker \ 43 | humpbacks/humpback-agent 44 | 45 | ``` 46 | 47 | 请注意:将{server-address}替换为部署Humpback Server的真实IP地址。 48 | 49 | ## 使用 50 | 51 | 安装完成后,将当前机器IP地址添加到**机器管理**页面,待状态变为**在线**后即可进行调度使用。 52 | 53 | ![Nodes](/assets/nodes-zh.png) 54 | 55 | ## 许可证 56 | 57 | Humpback 根据 [Apache Licence 2.0](http://www.apache.org/licenses/LICENSE-2.0.html) 获得许可。 58 | -------------------------------------------------------------------------------- /api/v1/model/options.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ContainerLogger struct { 4 | Driver string `json:"driver"` // 日志驱动 5 | Options map[string]string `json:"options"` // 日志选项 6 | } 7 | 8 | // Network 配置 9 | type ContainerNetwork struct { 10 | Mode string `json:"mode"` // 网络模式(bridge, host, none) 11 | Hostname string `json:"hostname"` // 主机名 12 | DomainName string `json:"domainName"` // 域名 13 | MacAddress string `json:"macAddress"` // MAC 地址 14 | IPv4Address string `json:"ipv4Address"` // IPv4 地址 15 | IPv6Address string `json:"ipv6Address"` // IPv6 地址 16 | DNS []string `json:"dns"` // DNS 服务器 17 | Hosts map[string]string `json:"hosts"` // /etc/hosts 文件条目 18 | } 19 | 20 | // Runtime 配置 21 | type ContainerRuntime struct { 22 | Privileged bool `json:"privileged"` // 是否启用特权模式 23 | Init bool `json:"init"` // 是否使用 init 进程 24 | Runtime string `json:"runtime"` // 运行时(default, runc) 25 | Devices []string `json:"devices"` // 设备映射(hostDevice:containerDevice) 26 | } 27 | 28 | // Sysctls 配置 29 | type ContainerSysctl map[string]string // 系统控制参数 30 | 31 | // Resource Limits 配置 32 | type ContainerResource struct { 33 | MemoryReserve int64 `json:"memoryReserve"` // 内存保留(字节) 34 | MemoryLimit int64 `json:"memoryLimit"` // 内存限制(字节) 35 | CPUQuota int64 `json:"cpuQuota"` // CPU 配额(微秒) 36 | Enable bool `json:"enable"` // 是否启用 GPU 37 | } 38 | 39 | // Capabilities 配置 40 | type ContainerCapability struct { 41 | Add []string `json:"add"` // 添加的 Capabilities 42 | Drop []string `json:"drop"` // 删除的 Capabilities 43 | } 44 | -------------------------------------------------------------------------------- /internal/client/request.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | func GetRequest(client *http.Client, url string, token string) ([]byte, error) { 12 | req, err := http.NewRequest("GET", url, nil) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | if token != "" { 18 | req.Header.Set("Authorization", "Bearer "+token) 19 | } 20 | 21 | resp, err := client.Do(req) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | defer resp.Body.Close() 27 | if resp.StatusCode != http.StatusOK { 28 | return nil, fmt.Errorf("response status error %d", resp.StatusCode) 29 | } 30 | return io.ReadAll(resp.Body) 31 | } 32 | 33 | func PostRequest(client *http.Client, url string, payload any, token string) (string, error) { 34 | data, err := json.Marshal(payload) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | // fmt.Println(string(data)) 40 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | req.Header.Set("Content-Type", "application/json") 46 | if token != "" { 47 | req.Header.Set("Authorization", "Bearer "+token) 48 | } 49 | resp, err := client.Do(req) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | defer resp.Body.Close() 55 | 56 | if resp.StatusCode != http.StatusOK { 57 | return "", fmt.Errorf("response status error %d", resp.StatusCode) 58 | } 59 | 60 | var regResp struct { 61 | Token string `json:"token"` 62 | } 63 | if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { 64 | return "", fmt.Errorf("failed to parse registration response: %w", err) 65 | } 66 | 67 | return regResp.Token, nil 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # humpback-agent 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/docker/docker)](https://golang.org/) 4 | [![Docker](https://img.shields.io/badge/docker-pull-blue?logo=docker)](https://hub.docker.com/r/humpbacks/humpback-agent) 5 | [![Base: Moby](https://img.shields.io/badge/Base-Moby-2496ED?logo=docker&logoColor=white)](https://github.com/moby/moby) 6 | [![Release](https://img.shields.io/badge/release-v2.0.0-blue)](https://github.com/humpback/humpback-agent/releases/tag/v2.0.0) 7 | 8 | ![Humpback logo](/assets/logo.png) 9 | 10 | The service executor of Humpback, which provides container operations and cron execution for Humpback. 11 | 12 | ## language 13 | 14 | - [English](README.md) 15 | - [中文](README.zh.md) 16 | 17 | ## Feature 18 | 19 | - Heartbeat debriefing. 20 | - Container operations。 21 | - Support for cron. 22 | 23 | ## Getting Started 24 | 25 | * [Humpback Guides](https://humpback.github.io/humpback) 26 | 27 | ## Installing 28 | 29 | By default, Humpback Agent will expose a API server over port `8018` for receiving Humpback Server call. 30 | 31 | ```bash 32 | 33 | docker run -d \ 34 | --name=humpback-agent \ 35 | --net=host \ 36 | --restart=always \ 37 | --privileged \ 38 | -v /var/run/docker.sock:/var/run/docker.sock \ 39 | -v /var/lib/docker:/var/lib/docker \ 40 | -e HUMPBACK_SERVER_REGISTER_TOKEN={token} \ 41 | -e HUMPBACK_SERVER_HOST={server-address}:8101 \ 42 | -e HUMPBACK_VOLUMES_ROOT_DIRECTORY=/var/lib/docker \ 43 | humpbacks/humpback-agent 44 | 45 | ``` 46 | 47 | Please replace `{server-address}` to the Humbpack Server IP. 48 | 49 | ## Usage 50 | 51 | After the installation is completed, add the current machine IP address to the **Nodes** page, and you can schedule it after the status changes to **Healthy**. 52 | 53 | ![Nodes](/assets/nodes.png) 54 | 55 | ## Licence 56 | 57 | Humpback Server is licensed under the [Apache Licence 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). 58 | -------------------------------------------------------------------------------- /model/host.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "fmt" 7 | "os" 8 | "runtime" 9 | "strconv" 10 | 11 | "humpback-agent/pkg/utils" 12 | ) 13 | 14 | type HostInfo struct { 15 | Hostname string `json:"hostName"` 16 | OSInformation string `json:"osInformation"` 17 | KernelVersion string `json:"kernelVersion"` 18 | TotalCPU int `json:"totalCPU"` 19 | UsedCPU float32 `json:"usedCPU"` 20 | CPUUsage float32 `json:"cpuUsage"` 21 | TotalMemory uint64 `json:"totalMemory"` 22 | UsedMemory uint64 `json:"usedMemory"` 23 | MemoryUsage float32 `json:"memoryUsage"` 24 | HostIPs []string `json:"hostIPs"` 25 | HostPort int `json:"hostPort"` 26 | } 27 | 28 | type DockerEngineInfo struct { 29 | Version string `json:"version"` 30 | APIVersion string `json:"apiVersion"` 31 | RootDirectory string `json:"rootDirectory"` 32 | StorageDriver string `json:"storageDriver"` 33 | LoggingDriver string `json:"loggingDriver"` 34 | VolumePlugins []string `json:"volumePlugins"` 35 | NetworkPlugins []string `json:"networkPlugins"` 36 | } 37 | 38 | type HostHealthRequest struct { 39 | HostInfo HostInfo `json:"hostInfo"` 40 | DockerEngine DockerEngineInfo `json:"dockerEngine"` 41 | Containers []*ContainerInfo `json:"containers"` 42 | } 43 | 44 | type CertificateBundle struct { 45 | Cert *x509.Certificate 46 | PrivKey *ecdsa.PrivateKey 47 | CertPool *x509.CertPool // CA证书池 48 | CertPEM []byte // PEM编码的证书 49 | KeyPEM []byte // PEM编码的私钥 50 | } 51 | 52 | func GetHostInfo(hostIpStr, portStr string) HostInfo { 53 | hostname, _ := os.Hostname() 54 | port, _ := strconv.Atoi(portStr) 55 | var hostIps []string 56 | if hostIpStr != "" { 57 | hostIps = []string{hostIpStr} 58 | } else { 59 | hostIps = utils.HostIPs() 60 | } 61 | osInfo := fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH) 62 | kernelVersion := utils.HostKernelVersion() 63 | totalCPU, usedCPU, cpuUsage := utils.HostCPU() 64 | totalMEM, usedMEM, memUsage := utils.HostMemory() 65 | return HostInfo{ 66 | Hostname: hostname, 67 | OSInformation: osInfo, 68 | KernelVersion: kernelVersion, 69 | TotalCPU: totalCPU, 70 | UsedCPU: usedCPU, 71 | CPUUsage: cpuUsage, 72 | TotalMemory: totalMEM, 73 | UsedMemory: usedMEM, 74 | MemoryUsage: memUsage, 75 | HostIPs: hostIps, 76 | HostPort: port, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/schedule/scheduler.go: -------------------------------------------------------------------------------- 1 | package schedule 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | 8 | "github.com/docker/docker/client" 9 | "github.com/robfig/cron/v3" 10 | ) 11 | 12 | const ( 13 | HumpbackJobRulesLabel = "HUMPBACK_JOB_RULES" 14 | HumpbackJobAlwaysPullLabel = "HUMPBACK_JOB_ALWAYS_PULL" 15 | HumpbackJobMaxTimeoutLabel = "HUMPBACK_JOB_MAX_TIMEOUT" 16 | HumpbackJobImageAuth = "HUMPBACK_JOB_IMAGE_AUTH" 17 | ) 18 | 19 | const ( 20 | MaxKillContainerTimeoutSeconds = 5 21 | ) 22 | 23 | type TaskSchedulerInterface interface { 24 | Start() 25 | Stop() 26 | AddContainer(containerId string, name string, image string, alwaysPull bool, rules []string, authStr string, timeout time.Duration) error 27 | RemoveContainer(containerId string) error 28 | } 29 | 30 | type TaskScheduler struct { 31 | sync.RWMutex 32 | c *cron.Cron 33 | client *client.Client 34 | tasks map[cron.EntryID]*Task //entryId, *task 35 | } 36 | 37 | func NewJobScheduler(client *client.Client) TaskSchedulerInterface { 38 | return &TaskScheduler{ 39 | c: cron.New(), 40 | client: client, 41 | tasks: make(map[cron.EntryID]*Task), 42 | } 43 | } 44 | 45 | func (scheduler *TaskScheduler) Start() { 46 | scheduler.c.Start() 47 | } 48 | 49 | func (scheduler *TaskScheduler) Stop() { 50 | scheduler.c.Stop() 51 | } 52 | 53 | func (scheduler *TaskScheduler) AddContainer(containerId string, name string, image string, alwaysPull bool, rules []string, authStr string, timeout time.Duration) error { 54 | scheduler.Lock() 55 | defer scheduler.Unlock() 56 | //同名的容器不能反复进入调度器, 因为可能是dockerEvent捕获到了task内部因AlwaysPull导致的容器替换reCreate 57 | for _, task := range scheduler.tasks { 58 | if task.Name == name { 59 | return fmt.Errorf("container %s already exists in scheduler", containerId) 60 | } 61 | } 62 | 63 | for _, rule := range rules { 64 | task := NewTask(containerId, name, image, alwaysPull, timeout, rule, authStr, scheduler.client) 65 | entryId, err := scheduler.c.AddFunc(rule, func() { 66 | task.Execute() //根据rule定时执行这个任务 67 | }) 68 | 69 | if err != nil { 70 | return err 71 | } 72 | scheduler.tasks[entryId] = task 73 | } 74 | return nil 75 | } 76 | 77 | func (scheduler *TaskScheduler) RemoveContainer(containerId string) error { 78 | scheduler.Lock() 79 | defer scheduler.Unlock() 80 | for entryId, task := range scheduler.tasks { 81 | if task.ContainerId == containerId { 82 | scheduler.c.Remove(entryId) 83 | delete(scheduler.tasks, entryId) 84 | } 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "humpback-agent/config" 8 | "humpback-agent/controller" 9 | "humpback-agent/model" 10 | "net/http" 11 | "time" 12 | 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type APIServer struct { 17 | svc *http.Server 18 | // tlsConfig *conf.TLSConfig 19 | shutdown bool 20 | } 21 | 22 | func NewAPIServer(controller controller.ControllerInterface, config *config.APIConfig, certBundle *model.CertificateBundle, token string, tokenChan chan string) (*APIServer, error) { 23 | 24 | router := NewRouter(controller, config, token, tokenChan) 25 | server := &APIServer{ 26 | svc: &http.Server{ 27 | Addr: fmt.Sprintf(":%s", config.Port), 28 | Handler: router, 29 | WriteTimeout: 90 * time.Second, 30 | ReadTimeout: 30 * time.Second, 31 | IdleTimeout: 60 * time.Second, 32 | }, 33 | shutdown: false, 34 | } 35 | 36 | if certBundle != nil { 37 | cert, _ := tls.X509KeyPair(certBundle.CertPEM, certBundle.KeyPEM) 38 | config := &tls.Config{ 39 | Certificates: []tls.Certificate{cert}, 40 | RootCAs: certBundle.CertPool, 41 | ClientAuth: tls.RequireAndVerifyClientCert, 42 | ClientCAs: certBundle.CertPool, 43 | } 44 | server.svc.TLSConfig = config 45 | } 46 | return server, nil 47 | } 48 | 49 | func (server *APIServer) Startup(ctx context.Context) error { 50 | server.shutdown = false 51 | startCtx, cancel := context.WithTimeout(ctx, time.Second*3) 52 | defer cancel() 53 | var err error 54 | errCh := make(chan error) 55 | go func(errCh chan<- error) { 56 | // if server.tlsConfig != nil { 57 | // logger.INFO("[API] server https TLS enabled.", server.svc.Addr) 58 | // e = server.svc.ListenAndServeTLS(server.tlsConfig.ServerCert, server.tlsConfig.ServerKey) 59 | // } else { 60 | e := server.svc.ListenAndServeTLS("", "") 61 | //} 62 | if !server.shutdown { 63 | errCh <- e 64 | } 65 | }(errCh) 66 | select { 67 | case <-startCtx.Done(): 68 | logrus.Infof("[API] Server listening on [%s]...", server.svc.Addr) 69 | case err = <-errCh: 70 | if err != nil && !server.shutdown { 71 | logrus.Error("[API] Server listening failed.") 72 | } 73 | } 74 | close(errCh) 75 | return err 76 | } 77 | 78 | func (server *APIServer) Stop(ctx context.Context) error { 79 | logrus.Info("[API] Server stopping...") 80 | shutdownCtx, cancel := context.WithTimeout(ctx, time.Second*15) 81 | defer cancel() 82 | server.shutdown = true 83 | return server.svc.Shutdown(shutdownCtx) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/utils/callable.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/docker/docker/api/types/container" 5 | "github.com/docker/go-connections/nat" 6 | "reflect" 7 | ) 8 | 9 | // 辅助函数: 环境变量转换为 Docker SDK 格式 10 | func MapToEnv(envMap map[string]string) []string { 11 | var env []string 12 | for k, v := range envMap { 13 | env = append(env, k+"="+v) 14 | } 15 | return env 16 | } 17 | 18 | // 辅助函数:将端口映射转换为 Docker SDK 格式 19 | func MapPorts(portMap map[string]string) nat.PortMap { 20 | bindings := make(nat.PortMap) 21 | for hostPort, containerPort := range portMap { 22 | bindings[nat.Port(containerPort)] = []nat.PortBinding{ 23 | { 24 | HostIP: "0.0.0.0", 25 | HostPort: hostPort, 26 | }, 27 | } 28 | } 29 | return bindings 30 | } 31 | 32 | // 辅助函数:将卷映射转换为 Docker SDK 格式 33 | func MapToBinds(volumeMap map[string]string) []string { 34 | var binds []string 35 | for hostPath, containerPath := range volumeMap { 36 | binds = append(binds, hostPath+":"+containerPath) 37 | } 38 | return binds 39 | } 40 | 41 | // 辅助函数:将设备映射转换为 Docker SDK 格式 42 | func MapToDevices(devices []string) []container.DeviceMapping { 43 | var deviceMappings []container.DeviceMapping 44 | for _, device := range devices { 45 | deviceMappings = append(deviceMappings, container.DeviceMapping{ 46 | PathOnHost: device, 47 | PathInContainer: device, 48 | CgroupPermissions: "rwm", 49 | }) 50 | } 51 | return deviceMappings 52 | } 53 | 54 | func RemoveDuplicatesElement[T comparable](s []T) []T { 55 | result := make([]T, 0) 56 | m := make(map[T]bool) 57 | for _, v := range s { 58 | if _, ok := m[v]; !ok { 59 | result = append(result, v) 60 | m[v] = true 61 | } 62 | } 63 | return result 64 | } 65 | 66 | func Contains(obj interface{}, target interface{}) bool { 67 | targetValue := reflect.ValueOf(target) 68 | switch reflect.TypeOf(target).Kind() { 69 | case reflect.Slice, reflect.Array: 70 | for i := 0; i < targetValue.Len(); i++ { 71 | if targetValue.Index(i).Interface() == obj { 72 | return true 73 | } 74 | } 75 | case reflect.Map: 76 | if targetValue.MapIndex(reflect.ValueOf(obj)).IsValid() { 77 | return true 78 | } 79 | } 80 | return false 81 | } 82 | 83 | func MergeSlice[T comparable](dest, new []T) []T { 84 | uniqueMap := make(map[T]struct{}) 85 | for _, v := range dest { 86 | uniqueMap[v] = struct{}{} 87 | } 88 | 89 | for _, v := range new { 90 | if _, exists := uniqueMap[v]; !exists { 91 | uniqueMap[v] = struct{}{} 92 | dest = append(dest, v) 93 | } 94 | } 95 | return dest 96 | } 97 | 98 | func RemoveFromSlice[T comparable](dest, rem []T) []T { 99 | removeMap := make(map[T]struct{}) 100 | for _, v := range rem { 101 | removeMap[v] = struct{}{} 102 | } 103 | 104 | result := make([]T, 0, len(dest)) 105 | for _, v := range dest { 106 | if _, exists := removeMap[v]; !exists { 107 | result = append(result, v) 108 | } 109 | } 110 | return result 111 | } 112 | -------------------------------------------------------------------------------- /api/v1/model/response.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | MSG_SUCCEED = "succeed" 10 | MSG_FAILED = "failed" 11 | ) 12 | 13 | type FAQResponse struct { 14 | APIVersion string `json:"apiVersion"` 15 | Timestamp int64 `json:"timestamp"` 16 | } 17 | 18 | type APIResponse struct { 19 | Code int `json:"code"` 20 | Message string `json:"message"` 21 | Data any `json:"data,omitempty"` 22 | } 23 | 24 | type ErrorResult struct { 25 | StatusCode int `json:"statusCode"` 26 | Code string `json:"code"` 27 | ErrMsg string `json:"errMsg"` 28 | } 29 | 30 | func RequestErrorResult(code string, errMsg string) *ErrorResult { 31 | return &ErrorResult{ 32 | StatusCode: http.StatusBadRequest, 33 | Code: code, 34 | ErrMsg: errMsg, 35 | } 36 | } 37 | 38 | func NotFoundErrorResult(code string, errMsg string) *ErrorResult { 39 | return &ErrorResult{ 40 | StatusCode: http.StatusNotFound, 41 | Code: code, 42 | ErrMsg: errMsg, 43 | } 44 | } 45 | 46 | func InternalErrorResult(code string, errMsg string) *ErrorResult { 47 | return &ErrorResult{ 48 | StatusCode: http.StatusInternalServerError, 49 | Code: code, 50 | ErrMsg: errMsg, 51 | } 52 | } 53 | 54 | type StdResult struct { 55 | Msg string `json:"msg"` 56 | Error *ErrorResult `json:"error,omitempty"` 57 | } 58 | 59 | func StdSucceedResult() *StdResult { 60 | return &StdResult{ 61 | Msg: "succeed", 62 | } 63 | } 64 | 65 | func StdAcceptResult() *StdResult { 66 | return &StdResult{ 67 | Msg: "accepted", 68 | } 69 | } 70 | 71 | func StdInternalErrorResult(code string, errMsg string) *StdResult { 72 | return &StdResult{ 73 | Error: InternalErrorResult(code, errMsg), 74 | } 75 | } 76 | 77 | type ObjectResult struct { 78 | ObjectId string `json:"objectId,omitempty"` 79 | Object any `json:"object,omitempty"` 80 | Msg string `json:"msg,omitempty"` 81 | Error *ErrorResult `json:"error,omitempty"` 82 | } 83 | 84 | func ResultWithObjectId(objectId string) *ObjectResult { 85 | return &ObjectResult{ 86 | ObjectId: objectId, 87 | } 88 | } 89 | 90 | func ResultWithObject(object any) *ObjectResult { 91 | return &ObjectResult{ 92 | Object: object, 93 | } 94 | } 95 | 96 | func ResultMessageResult(msg string) *ObjectResult { 97 | return &ObjectResult{ 98 | Msg: msg, 99 | } 100 | } 101 | 102 | func ObjectRequestErrorResult(code string, errMsg string) *ObjectResult { 103 | return &ObjectResult{ 104 | Error: RequestErrorResult(code, errMsg), 105 | } 106 | } 107 | 108 | func ObjectNotFoundErrorResult(code string, errMsg string) *ObjectResult { 109 | slog.Error("not found error: ", "code", code, "msg", errMsg) 110 | return &ObjectResult{ 111 | Error: NotFoundErrorResult(code, errMsg), 112 | } 113 | } 114 | 115 | func ObjectInternalErrorResult(code string, errMsg string) *ObjectResult { 116 | slog.Error("internal error: ", "code", code, "msg", errMsg) 117 | return &ObjectResult{ 118 | Error: InternalErrorResult(code, errMsg), 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /api/router.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "humpback-agent/api/factory" 5 | "humpback-agent/config" 6 | "humpback-agent/controller" 7 | "log/slog" 8 | "net/http" 9 | "sync/atomic" 10 | 11 | "github.com/sirupsen/logrus" 12 | 13 | "github.com/gin-contrib/pprof" 14 | "github.com/gin-gonic/gin" 15 | 16 | _ "humpback-agent/api/v1/handler" 17 | ) 18 | 19 | type IRouter interface { 20 | //ServeHTTP used to handle the http requests 21 | ServeHTTP(w http.ResponseWriter, r *http.Request) 22 | } 23 | 24 | type Router struct { 25 | engine *gin.Engine 26 | handlers map[string]any 27 | } 28 | 29 | type TokenService struct { 30 | token atomic.Value 31 | } 32 | 33 | func engineMode(modeConfig string) string { 34 | mode := "debug" 35 | if modeConfig == "release" { 36 | mode = gin.ReleaseMode 37 | } else if modeConfig == "test" { 38 | mode = gin.TestMode 39 | } else { 40 | mode = gin.DebugMode 41 | } 42 | return mode 43 | } 44 | 45 | func tokenAuthMiddleware(c *gin.Context) { 46 | 47 | token := c.GetHeader("Authorization") 48 | if token == "" { 49 | c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) 50 | c.Abort() 51 | return 52 | } 53 | 54 | // 验证Bearer token格式 55 | if len(token) < 7 || token[:7] != "Bearer " { 56 | c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) 57 | c.Abort() 58 | return 59 | } 60 | 61 | tokenString := token[7:] 62 | 63 | ts := c.MustGet("tokenService").(*TokenService) 64 | if ts.token.Load().(string) != tokenString { 65 | c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) 66 | c.Abort() 67 | return 68 | } 69 | 70 | c.Next() 71 | } 72 | 73 | func NewRouter(controller controller.ControllerInterface, config *config.APIConfig, token string, tokenChan chan string) IRouter { 74 | gin.SetMode(engineMode(config.Mode)) 75 | engine := gin.New() 76 | if gin.IsDebugging() { 77 | pprof.Register(engine) 78 | } 79 | middlewares := Middlewares(config.Middlewares...) 80 | engine.Use(middlewares...) 81 | 82 | tokenService := &TokenService{} 83 | tokenService.token.Store(token) 84 | 85 | go func() { 86 | for newToken := range tokenChan { 87 | slog.Info("[API] Update token") 88 | tokenService.token.Store(newToken) 89 | } 90 | }() 91 | 92 | engine.Use(func(c *gin.Context) { 93 | c.Set("tokenService", tokenService) 94 | c.Next() 95 | }) 96 | engine.Use(tokenAuthMiddleware) 97 | 98 | engineHandlers := map[string]any{} 99 | for _, version := range config.Versions { 100 | routerHandler, err := factory.HandlerConstruct(version, controller) 101 | if err != nil { 102 | logrus.Errorf("[API] Router construct %s handler error, %s", version, err.Error()) 103 | continue 104 | } 105 | engineHandlers[version] = routerHandler 106 | } 107 | 108 | router := &Router{ 109 | engine: engine, 110 | handlers: engineHandlers, 111 | } 112 | router.initRouter() 113 | return router 114 | } 115 | 116 | func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { 117 | router.engine.ServeHTTP(w, r) 118 | } 119 | 120 | func (router *Router) initRouter() { 121 | for version, routerHandler := range router.handlers { 122 | if routerHandler != nil { 123 | routerHandler.(factory.Initializer).SetRouter(version, router.engine) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /api/v1/model/types.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type NetworkMode string 4 | 5 | var ( 6 | NetworkModeHost NetworkMode = "host" 7 | NetworkModeBridge NetworkMode = "bridge" 8 | NetworkModeCustom NetworkMode = "custom" 9 | ) 10 | 11 | type RestartPolicyMode string 12 | 13 | var ( 14 | RestartPolicyModeNo RestartPolicyMode = "no" 15 | RestartPolicyModeAlways RestartPolicyMode = "always" 16 | RestartPolicyModeOnFail RestartPolicyMode = "on-failure" 17 | RestartPolicyModeUnlessStopped RestartPolicyMode = "unless-stopped" 18 | ) 19 | 20 | type NetworkInfo struct { 21 | Mode NetworkMode `json:"mode"` // custom模式需要创建网络 22 | Hostname string `json:"hostname"` // bridge及custom模式时可设置,用户容器的hostname 23 | NetworkName string `json:"networkName"` //custom模式使用 24 | UseMachineHostname bool `json:"useMachineHostname"` 25 | Ports []*PortInfo `json:"ports"` 26 | } 27 | 28 | type PortInfo struct { 29 | HostPort uint `json:"hostPort"` 30 | ContainerPort uint `json:"containerPort"` 31 | Protocol string `json:"protocol"` 32 | } 33 | 34 | type RestartPolicy struct { 35 | Mode RestartPolicyMode `json:"mode"` 36 | MaxRetryCount int `json:"maxRetryCount"` 37 | } 38 | 39 | type ScheduleInfo struct { 40 | Timeout string `json:"timeout"` 41 | Rules []string `json:"rules"` 42 | } 43 | 44 | type Capabilities struct { 45 | CapAdd []string `json:"capAdd"` 46 | CapDrop []string `json:"capDrop"` 47 | } 48 | 49 | type Resources struct { 50 | Memory uint64 `json:"memory"` 51 | MemoryReservation uint64 `json:"memoryReservation"` 52 | MaxCpuUsage uint64 `json:"maxCpuUsage"` 53 | } 54 | 55 | type LogConfig struct { 56 | Type string `json:"type"` 57 | Config map[string]string `json:"config"` 58 | } 59 | 60 | type ServiceVolumeType string 61 | 62 | var ( 63 | ServiceVolumeTypeBind ServiceVolumeType = "bind" 64 | ServiceVolumeTypeVolume ServiceVolumeType = "volume" 65 | ) 66 | 67 | const ( 68 | ContainerLabelServiceId = "Humpback-ServiceId" 69 | ContainerLabelServiceName = "Humpback-ServiceName" 70 | ContainerLabelGroupId = "Humpback-GroupId" 71 | ) 72 | 73 | type ServiceVolume struct { 74 | Type ServiceVolumeType `json:"type"` 75 | Target string `json:"target"` 76 | Source string `json:"source"` 77 | Readonly bool `json:"readOnly"` 78 | } 79 | 80 | type ContainerMeta struct { 81 | RegistryDomain string `json:"registryDomain"` 82 | Image string `json:"image"` 83 | AlwaysPull bool `json:"alwaysPull"` 84 | Command string `json:"command"` 85 | Envs []string `json:"env"` 86 | Labels map[string]string `json:"labels"` 87 | Volumes []*ServiceVolume `json:"volumes"` 88 | Network *NetworkInfo `json:"network"` 89 | RestartPolicy *RestartPolicy `json:"restartPolicy"` 90 | Capabilities *Capabilities `json:"capabilities"` 91 | LogConfig *LogConfig `json:"logConfig"` 92 | Resources *Resources `json:"resources"` 93 | Privileged bool `json:"privileged"` 94 | } 95 | 96 | type RegistryAuth struct { 97 | ServerAddress string `json:"serverAddress"` 98 | RegistryUsername string `json:"registryUsername"` 99 | RegistryPassword string `json:"registryPassword"` 100 | } 101 | -------------------------------------------------------------------------------- /controller/network.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | v1model "humpback-agent/api/v1/model" 6 | 7 | "github.com/docker/docker/api/types/network" 8 | "github.com/docker/docker/client" 9 | "github.com/docker/docker/errdefs" 10 | ) 11 | 12 | type NetworkControllerInterface interface { 13 | BaseController() ControllerInterface 14 | Get(ctx context.Context, request *v1model.GetNetworkRequest) *v1model.ObjectResult 15 | Create(ctx context.Context, request *v1model.CreateNetworkRequest) *v1model.ObjectResult 16 | Delete(ctx context.Context, request *v1model.DeleteNetworkRequest) *v1model.ObjectResult 17 | } 18 | 19 | type NetworkController struct { 20 | baseController ControllerInterface 21 | client *client.Client 22 | } 23 | 24 | func NewNetworkController(baseController ControllerInterface, client *client.Client) NetworkControllerInterface { 25 | return &NetworkController{ 26 | baseController: baseController, 27 | client: client, 28 | } 29 | } 30 | 31 | func (controller *NetworkController) BaseController() ControllerInterface { 32 | return controller.baseController 33 | } 34 | 35 | func (controller *NetworkController) Get(ctx context.Context, request *v1model.GetNetworkRequest) *v1model.ObjectResult { 36 | var networkBody network.Inspect 37 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 38 | var networkErr error 39 | networkBody, networkErr = controller.client.NetworkInspect(ctx, request.NetworkId, network.InspectOptions{}) 40 | return networkErr 41 | }); err != nil { 42 | if errdefs.IsNotFound(err) { 43 | return v1model.ObjectNotFoundErrorResult(v1model.NetworkNotFoundCode, err.Error()) 44 | } 45 | return v1model.ObjectInternalErrorResult(v1model.ServerInternalErrorCode, err.Error()) 46 | } 47 | return v1model.ResultWithObject(networkBody) 48 | } 49 | 50 | func (controller *NetworkController) Create(ctx context.Context, request *v1model.CreateNetworkRequest) *v1model.ObjectResult { 51 | ret := controller.Get(ctx, &v1model.GetNetworkRequest{NetworkId: request.NetworkName}) 52 | if ret.Error != nil { 53 | if ret.Error.Code != v1model.NetworkNotFoundCode { 54 | return ret 55 | } 56 | } 57 | 58 | if ret.Error == nil { 59 | return v1model.ResultWithObjectId(ret.Object.(network.Inspect).ID) 60 | } 61 | 62 | var networkInfo network.CreateResponse 63 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 64 | var createdErr error 65 | networkInfo, createdErr = controller.client.NetworkCreate(ctx, request.NetworkName, network.CreateOptions{ 66 | Driver: request.Driver, 67 | Scope: request.Scope, 68 | }) 69 | return createdErr 70 | }); err != nil { 71 | return v1model.ObjectInternalErrorResult(v1model.NetworkCreateErrorCode, err.Error()) 72 | } 73 | return v1model.ResultWithObjectId(networkInfo.ID) 74 | } 75 | 76 | func (controller *NetworkController) Delete(ctx context.Context, request *v1model.DeleteNetworkRequest) *v1model.ObjectResult { 77 | var networkId string 78 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 79 | networkBody, inspectErr := controller.client.NetworkInspect(ctx, request.NetworkId, network.InspectOptions{Scope: request.Scope}) 80 | if inspectErr != nil { 81 | return inspectErr 82 | } 83 | networkId = networkBody.ID 84 | return controller.client.NetworkRemove(ctx, networkId) 85 | }); err != nil { 86 | return v1model.ObjectInternalErrorResult(v1model.NetworkDeleteErrorCode, err.Error()) 87 | } 88 | return v1model.ResultWithObjectId(networkId) 89 | } 90 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module humpback-agent 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/caarlos0/env/v11 v11.3.1 7 | github.com/docker/docker v27.4.1+incompatible 8 | github.com/docker/go-connections v0.5.0 9 | github.com/gin-contrib/cors v1.7.3 10 | github.com/gin-contrib/pprof v1.5.2 11 | github.com/gin-gonic/gin v1.10.0 12 | github.com/google/uuid v1.6.0 13 | github.com/robfig/cron/v3 v3.0.1 14 | github.com/shirou/gopsutil/v4 v4.24.12 15 | github.com/sirupsen/logrus v1.9.3 16 | golang.org/x/sys v0.28.0 17 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | github.com/Microsoft/go-winio v0.4.14 // indirect 23 | github.com/bytedance/sonic v1.12.6 // indirect 24 | github.com/bytedance/sonic/loader v0.2.1 // indirect 25 | github.com/cloudwego/base64x v0.1.4 // indirect 26 | github.com/cloudwego/iasm v0.2.0 // indirect 27 | github.com/containerd/log v0.1.0 // indirect 28 | github.com/distribution/reference v0.6.0 // indirect 29 | github.com/docker/go-units v0.5.0 // indirect 30 | github.com/ebitengine/purego v0.8.1 // indirect 31 | github.com/felixge/httpsnoop v1.0.4 // indirect 32 | github.com/gabriel-vasile/mimetype v1.4.7 // indirect 33 | github.com/gin-contrib/sse v0.1.0 // indirect 34 | github.com/go-logr/logr v1.4.2 // indirect 35 | github.com/go-logr/stdr v1.2.2 // indirect 36 | github.com/go-ole/go-ole v1.2.6 // indirect 37 | github.com/go-playground/locales v0.14.1 // indirect 38 | github.com/go-playground/universal-translator v0.18.1 // indirect 39 | github.com/go-playground/validator/v10 v10.23.0 // indirect 40 | github.com/goccy/go-json v0.10.4 // indirect 41 | github.com/gogo/protobuf v1.3.2 // indirect 42 | github.com/json-iterator/go v1.1.12 // indirect 43 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 44 | github.com/leodido/go-urn v1.4.0 // indirect 45 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/moby/docker-image-spec v1.3.1 // indirect 48 | github.com/moby/term v0.5.2 // indirect 49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 50 | github.com/modern-go/reflect2 v1.0.2 // indirect 51 | github.com/morikuni/aec v1.0.0 // indirect 52 | github.com/opencontainers/go-digest v1.0.0 // indirect 53 | github.com/opencontainers/image-spec v1.1.0 // indirect 54 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 57 | github.com/tklauser/go-sysconf v0.3.12 // indirect 58 | github.com/tklauser/numcpus v0.6.1 // indirect 59 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 60 | github.com/ugorji/go/codec v1.2.12 // indirect 61 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 62 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 63 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 64 | go.opentelemetry.io/otel v1.33.0 // indirect 65 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect 66 | go.opentelemetry.io/otel/metric v1.33.0 // indirect 67 | go.opentelemetry.io/otel/sdk v1.33.0 // indirect 68 | go.opentelemetry.io/otel/trace v1.33.0 // indirect 69 | golang.org/x/arch v0.12.0 // indirect 70 | golang.org/x/crypto v0.31.0 // indirect 71 | golang.org/x/net v0.33.0 // indirect 72 | golang.org/x/text v0.21.0 // indirect 73 | golang.org/x/time v0.9.0 // indirect 74 | google.golang.org/protobuf v1.36.1 // indirect 75 | gotest.tools/v3 v3.5.1 // indirect 76 | ) 77 | -------------------------------------------------------------------------------- /api/v1/handler/container.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | v1model "humpback-agent/api/v1/model" 6 | "net/http" 7 | ) 8 | 9 | func (handler *V1Handler) GetContainerHandleFunc(c *gin.Context) { 10 | request, err := v1model.BindGetContainerRequest(c) 11 | if err != nil { 12 | c.JSON(err.StatusCode, err) 13 | return 14 | } 15 | 16 | result := handler.Container().Get(c.Request.Context(), request) 17 | if result.Error != nil { 18 | c.JSON(result.Error.StatusCode, result.Error) 19 | return 20 | } 21 | c.JSON(http.StatusOK, result.Object) 22 | } 23 | 24 | func (handler *V1Handler) QueryContainerHandleFunc(c *gin.Context) { 25 | request, err := v1model.BindQueryContainerRequest(c) 26 | if err != nil { 27 | c.JSON(err.StatusCode, err) 28 | return 29 | } 30 | 31 | result := handler.Container().List(c.Request.Context(), request) 32 | if result.Error != nil { 33 | c.JSON(result.Error.StatusCode, result.Error) 34 | return 35 | } 36 | c.JSON(http.StatusOK, result.Object) 37 | } 38 | 39 | func (handler *V1Handler) GetContainerLogsHandleFunc(c *gin.Context) { 40 | request, err := v1model.BindGetContainerLogsRequest(c) 41 | if err != nil { 42 | c.JSON(err.StatusCode, err) 43 | return 44 | } 45 | 46 | result := handler.Container().Logs(c.Request.Context(), request) 47 | c.JSON(http.StatusOK, result.Object) 48 | } 49 | 50 | func (handler *V1Handler) GetContainerStatsHandleFunc(c *gin.Context) { 51 | request, err := v1model.BindGetContainerStatsRequest(c) 52 | if err != nil { 53 | c.JSON(err.StatusCode, err) 54 | return 55 | } 56 | 57 | result := handler.Container().Stats(c.Request.Context(), request) 58 | c.JSON(http.StatusOK, result.Object) 59 | } 60 | 61 | func (handler *V1Handler) CreateContainerHandleFunc(c *gin.Context) { 62 | request, err := v1model.BindCreateContainerRequest(c) 63 | if err != nil { 64 | c.JSON(err.StatusCode, err) 65 | return 66 | } 67 | 68 | handler.taskChan <- &V1Task{ContainerCreateTask, request} 69 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 70 | } 71 | 72 | func (handler *V1Handler) UpdateContainerHandleFunc(c *gin.Context) { 73 | } 74 | 75 | func (handler *V1Handler) DeleteContainerHandleFunc(c *gin.Context) { 76 | request, err := v1model.BindDeleteContainerRequest(c) 77 | if err != nil { 78 | c.JSON(err.StatusCode, err) 79 | return 80 | } 81 | 82 | handler.taskChan <- &V1Task{ContainerDeleteTask, request} 83 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 84 | } 85 | 86 | func (handler *V1Handler) RestartContainerHandleFunc(c *gin.Context) { 87 | request, err := v1model.BindRestartContainerRequest(c) 88 | if err != nil { 89 | c.JSON(err.StatusCode, err) 90 | return 91 | } 92 | 93 | handler.taskChan <- &V1Task{ContainerRestartTask, request} 94 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 95 | } 96 | 97 | func (handler *V1Handler) StartContainerHandleFunc(c *gin.Context) { 98 | request, err := v1model.BindStartContainerRequest(c) 99 | if err != nil { 100 | c.JSON(err.StatusCode, err) 101 | return 102 | } 103 | 104 | handler.taskChan <- &V1Task{ContainerStartTask, request} 105 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 106 | } 107 | 108 | func (handler *V1Handler) StopContainerHandleFunc(c *gin.Context) { 109 | request, err := v1model.BindStopContainerRequest(c) 110 | if err != nil { 111 | c.JSON(err.StatusCode, err) 112 | return 113 | } 114 | 115 | handler.taskChan <- &V1Task{ContainerStopTask, request} 116 | c.JSON(http.StatusAccepted, v1model.StdAcceptResult()) 117 | } 118 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | var ( 12 | ErrAPIConfigInvalid = errors.New("api config invalid") 13 | ErrDockerConfigInvalid = errors.New("docker config invalid") 14 | 15 | defaultLogConfig = &LoggerConfig{ 16 | LogFile: "", 17 | Level: "info", 18 | Format: "json", 19 | MaxSize: 20971520, 20 | MaxBackups: 3, 21 | MaxAge: 7, 22 | Compress: false, 23 | } 24 | 25 | defaultVolumesPath = "/var/lib/humpback/volumes" 26 | ) 27 | 28 | type APIConfig struct { 29 | Port string `json:"port" yaml:"port" env:"HUMPBACK_AGENT_API_PORT"` 30 | HostIP string `json:"hostIp" yaml:"hostIp" env:"HUMPBACK_AGENT_API_HOST_IP"` 31 | Mode string `json:"mode" yaml:"mode" env:"HUMPBACK_AGENT_API_MODE"` 32 | Middlewares []string `json:"middlewares" yaml:"middlewares"` 33 | Versions []string `json:"versions" yaml:"versions"` 34 | // AccessToken string `json:"accessToken" yaml:"accessToken"` 35 | } 36 | 37 | type LoggerConfig struct { 38 | LogFile string `json:"logFile" yaml:"logFile"` 39 | Level string `json:"level" yaml:"level"` 40 | Format string `json:"format" yaml:"format"` 41 | MaxSize int `json:"maxSize" yaml:"maxSize"` 42 | MaxAge int `json:"maxAge" yaml:"maxAge"` 43 | MaxBackups int `json:"maxBackups" yaml:"maxBackups"` 44 | Compress bool `json:"compress" yaml:"compress"` 45 | } 46 | 47 | type DockerTimeoutOpts struct { 48 | Connection time.Duration `json:"connection" yaml:"connection"` 49 | Request time.Duration `json:"request" yaml:"request"` 50 | } 51 | 52 | type DockerTLSOpts struct { 53 | Enabled bool `json:"enabled" yaml:"enabled"` 54 | CAPath string `json:"caPath" yaml:"caPath"` 55 | CertPath string `json:"certPath" yaml:"certPath"` 56 | KeyPath string `json:"keyPath" yaml:"keyPath"` 57 | InsecureSkipVerify bool `json:"insecureSkipVerify" yaml:"insecureSkipVerify"` 58 | } 59 | 60 | type DockerRegistryOpts struct { 61 | Default string `json:"default" yaml:"default"` 62 | UserName string `json:"userName" yaml:"userName"` 63 | Password string `json:"password" yaml:"password"` 64 | } 65 | 66 | type DockerConfig struct { 67 | Host string `json:"host" yaml:"host" env:"HUMPBACK_DOCKER_HOST"` 68 | Version string `json:"version" yaml:"version" env:"HUMPBACK_DOCKER_VERSION"` 69 | AutoNegotiate bool `json:"autoNegotiate" yaml:"autoNegotiate" env:"HUMPBACK_DOCKER_AUTO_NEGOTIATE"` 70 | DockerTimeoutOpts DockerTimeoutOpts `json:"timeout" yaml:"timeout"` 71 | DockerTLSOpts DockerTLSOpts `json:"tls" yaml:"tls"` 72 | DockerRegistryOpts DockerRegistryOpts `json:"registry" yaml:"registry"` 73 | } 74 | 75 | type ServerHealthConfig struct { 76 | Interval time.Duration `json:"interval" yaml:"interval" env:"HUMPBACK_SERVER_HEALTH_INTERVAL"` 77 | Timeout time.Duration `json:"timeout" yaml:"timeout" env:"HUMPBACK_SERVER_HEALTH_TIMEOUT"` 78 | } 79 | 80 | type ServerConfig struct { 81 | Host string `json:"host" yaml:"host" env:"HUMPBACK_SERVER_HOST"` 82 | RegisterToken string `json:"registerToken" yaml:"registerToken" env:"HUMPBACK_SERVER_REGISTER_TOKEN"` 83 | Health ServerHealthConfig `json:"health" yaml:"health"` 84 | } 85 | 86 | type VolumesConfig struct { 87 | RootDirectory string `json:"rootDirectory" yaml:"rootDirectory" env:"HUMPBACK_VOLUMES_ROOT_DIRECTORY"` 88 | } 89 | 90 | type AppConfig struct { 91 | *APIConfig `json:"api" yaml:"api"` 92 | *ServerConfig `json:"server" yaml:"server"` 93 | *VolumesConfig `json:"volumes" yaml:"volumes"` 94 | *DockerConfig `json:"docker" yaml:"docker"` 95 | *LoggerConfig `json:"logger" yaml:"logger"` 96 | } 97 | 98 | func NewAppConfig(configPath string) (*AppConfig, error) { 99 | data, err := os.ReadFile(configPath) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | appConfig := AppConfig{} 105 | if err = yaml.Unmarshal(data, &appConfig); err != nil { 106 | return nil, err 107 | } 108 | 109 | if err = ParseConfigFromEnv(&appConfig); err != nil { 110 | return nil, err 111 | } 112 | 113 | if appConfig.APIConfig == nil { 114 | return nil, ErrAPIConfigInvalid 115 | } 116 | 117 | if appConfig.VolumesConfig == nil || appConfig.VolumesConfig.RootDirectory == "" { 118 | appConfig.VolumesConfig = &VolumesConfig{ 119 | RootDirectory: defaultVolumesPath, 120 | } 121 | } 122 | 123 | if appConfig.DockerConfig == nil { 124 | return nil, ErrDockerConfigInvalid 125 | } 126 | 127 | if appConfig.LoggerConfig == nil { 128 | appConfig.LoggerConfig = defaultLogConfig 129 | } 130 | return &appConfig, nil 131 | } 132 | -------------------------------------------------------------------------------- /controller/image.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | v1model "humpback-agent/api/v1/model" 8 | 9 | "github.com/docker/docker/api/types/image" 10 | "github.com/docker/docker/api/types/registry" 11 | "github.com/docker/docker/client" 12 | "github.com/docker/docker/errdefs" 13 | ) 14 | 15 | type ImageInternalControllerInterface interface { 16 | AttemptPull(ctx context.Context, imageId string, alwaysPull bool, auth v1model.RegistryAuth) *v1model.ObjectResult 17 | } 18 | 19 | type ImageControllerInterface interface { 20 | ImageInternalControllerInterface 21 | BaseController() ControllerInterface 22 | Get(ctx context.Context, request *v1model.GetImageRequest) *v1model.ObjectResult 23 | List(ctx context.Context, request *v1model.QueryImageRequest) *v1model.ObjectResult 24 | Push(ctx context.Context, request *v1model.PushImageRequest) *v1model.ObjectResult 25 | Pull(ctx context.Context, request *v1model.PullImageRequest) *v1model.ObjectResult 26 | Delete(ctx context.Context, request *v1model.DeleteImageRequest) *v1model.ObjectResult 27 | } 28 | 29 | type ImageController struct { 30 | baseController ControllerInterface 31 | client *client.Client 32 | } 33 | 34 | func NewImageController(baseController ControllerInterface, client *client.Client) ImageControllerInterface { 35 | return &ImageController{ 36 | baseController: baseController, 37 | client: client, 38 | } 39 | } 40 | 41 | func (controller *ImageController) BaseController() ControllerInterface { 42 | return controller.baseController 43 | } 44 | 45 | func (controller *ImageController) AttemptPull(ctx context.Context, imageId string, alwaysPull bool, auth v1model.RegistryAuth) *v1model.ObjectResult { 46 | pullImage := alwaysPull 47 | if !pullImage { 48 | imageResult := controller.BaseController().Image().Get(ctx, &v1model.GetImageRequest{ImageId: imageId}) 49 | if imageResult.Error != nil { 50 | if imageResult.Error.Code == v1model.ImageNotFoundCode { 51 | pullImage = true 52 | } 53 | } else { 54 | pullImage = false //本地已存在 55 | } 56 | } 57 | 58 | if pullImage { //拉取镜像(如果需要) 59 | if ret := controller.BaseController().Image().Pull(ctx, &v1model.PullImageRequest{Image: imageId, ServerAddress: auth.ServerAddress, UserName: auth.RegistryUsername, Password: auth.RegistryPassword}); ret.Error != nil { 60 | return ret 61 | } 62 | } 63 | return v1model.ResultWithObject(imageId) 64 | } 65 | 66 | func (controller *ImageController) Get(ctx context.Context, request *v1model.GetImageRequest) *v1model.ObjectResult { 67 | imageInfo, _, err := controller.client.ImageInspectWithRaw(ctx, request.ImageId) 68 | if err != nil { 69 | if errdefs.IsNotFound(err) { 70 | return v1model.ObjectNotFoundErrorResult(v1model.ImageNotFoundCode, err.Error()) 71 | } 72 | return v1model.ObjectInternalErrorResult(v1model.ImagePullErrorCode, err.Error()) 73 | } 74 | return v1model.ResultWithObject(imageInfo) 75 | } 76 | 77 | func (controller *ImageController) List(ctx context.Context, request *v1model.QueryImageRequest) *v1model.ObjectResult { 78 | return nil 79 | } 80 | 81 | func (controller *ImageController) Push(ctx context.Context, request *v1model.PushImageRequest) *v1model.ObjectResult { 82 | return nil 83 | } 84 | 85 | func (controller *ImageController) Pull(ctx context.Context, request *v1model.PullImageRequest) *v1model.ObjectResult { 86 | 87 | authStr := "" 88 | if request.UserName != "" && request.Password != "" { 89 | 90 | authConfig := registry.AuthConfig{ 91 | Username: request.UserName, 92 | Password: request.Password, 93 | ServerAddress: request.ServerAddress, 94 | } 95 | 96 | authBytes, _ := json.Marshal(authConfig) 97 | authStr = base64.URLEncoding.EncodeToString(authBytes) 98 | } 99 | 100 | pullOptions := image.PullOptions{ 101 | All: request.All, 102 | Platform: request.Platform, 103 | RegistryAuth: authStr, 104 | } 105 | 106 | out, err := controller.client.ImagePull(context.Background(), request.Image, pullOptions) 107 | if err != nil { 108 | return v1model.ObjectNotFoundErrorResult(v1model.ImagePullErrorCode, err.Error()) 109 | } 110 | 111 | defer out.Close() 112 | 113 | // wait pull image 114 | for { 115 | _, err := out.Read(make([]byte, 1024)) 116 | if err != nil { 117 | break 118 | } 119 | } 120 | 121 | imageInfo, _, err := controller.client.ImageInspectWithRaw(ctx, request.Image) 122 | if err != nil { 123 | return v1model.ObjectInternalErrorResult(v1model.ImagePullErrorCode, err.Error()) 124 | } 125 | return v1model.ResultWithObject(imageInfo.ID) 126 | } 127 | 128 | func (controller *ImageController) Delete(ctx context.Context, request *v1model.DeleteImageRequest) *v1model.ObjectResult { 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /pkg/utils/sys.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net" 7 | "runtime" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/shirou/gopsutil/v4/cpu" 12 | "github.com/shirou/gopsutil/v4/mem" 13 | "golang.org/x/sys/unix" 14 | ) 15 | 16 | func HostCPU() (int, float32, float32) { 17 | totalCPU := runtime.NumCPU() 18 | // 获取 CPU 使用率 19 | percent, err := cpu.Percent(0, false) 20 | if err != nil { 21 | return totalCPU, 0.0, 0.0 22 | } 23 | // 计算 CPU 使用率 24 | cpuUsage := float32(math.Round(percent[0]*100) / 100) // CPU 使用率保留两位小数 25 | // 计算 UsedCPU 使用个数 26 | usedCPU := float32(totalCPU) * cpuUsage / 100 27 | return totalCPU, usedCPU, cpuUsage 28 | } 29 | 30 | func HostMemory() (uint64, uint64, float32) { 31 | // 获取内存信息 32 | memInfo, err := mem.VirtualMemory() 33 | if err != nil { 34 | return 0, 0.0, 0.0 35 | } 36 | 37 | totalMEM := memInfo.Total 38 | usedMEM := memInfo.Used 39 | memUsage := float32(math.Round(memInfo.UsedPercent*100) / 100) // 内存使用率保留两位小数 40 | return totalMEM, usedMEM, memUsage 41 | } 42 | 43 | func HostKernelVersion() string { 44 | var utsname unix.Utsname 45 | if err := unix.Uname(&utsname); err != nil { 46 | return "unknown" 47 | } 48 | 49 | n := 0 50 | for i, b := range utsname.Release { 51 | if b == 0 { 52 | break 53 | } 54 | n = i + 1 55 | } 56 | 57 | kernelVersion := "" 58 | if n > 0 { 59 | kernelVersion = string(utsname.Release[:n]) 60 | } else { 61 | kernelVersion = string(utsname.Release[:]) 62 | } 63 | return string(kernelVersion) 64 | } 65 | 66 | func HostIPs() []string { 67 | interfaces, err := net.Interfaces() 68 | if err != nil { 69 | fmt.Println("Failed to get network interfaces:", err) 70 | return nil 71 | } 72 | 73 | var ipAddresses []string 74 | for _, iface := range interfaces { 75 | // 跳过回环接口和未启用的接口 76 | if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { 77 | continue 78 | } 79 | 80 | // 跳过 Docker 虚拟网卡 81 | if DockerInterface(iface.Name) { 82 | continue 83 | } 84 | 85 | addresses, err := iface.Addrs() 86 | if err != nil { 87 | fmt.Printf("Failed to get addresses for interface %s: %v\n", iface.Name, err) 88 | continue 89 | } 90 | 91 | for _, addr := range addresses { 92 | // 检查是否为 IPv4 地址 93 | if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { 94 | if ipNet.IP.To4() != nil { 95 | ipAddresses = append(ipAddresses, ipNet.IP.String()) 96 | } 97 | } 98 | } 99 | } 100 | 101 | return ipAddresses 102 | } 103 | 104 | // 获取机器的真实有用 IP 地址(优先选择外部网络地址) 105 | func HostIP() string { 106 | ipAddresses := HostIPs() 107 | if len(ipAddresses) == 0 { 108 | return "" 109 | } 110 | 111 | // 优先选择外部网络地址 112 | for _, ip := range ipAddresses { 113 | if isExternalIP(ip) { 114 | return ip 115 | } 116 | } 117 | // 如果没有外部网络地址,返回第一个地址 118 | return ipAddresses[0] 119 | } 120 | 121 | // 判断是否为 Docker 虚拟网卡 122 | func DockerInterface(ifaceName string) bool { 123 | // Docker 虚拟网卡通常以以下前缀开头 124 | dockerPrefixes := []string{"docker", "veth", "br-", "cni", "flannel"} 125 | for _, prefix := range dockerPrefixes { 126 | if len(ifaceName) >= len(prefix) && ifaceName[:len(prefix)] == prefix { 127 | return true 128 | } 129 | } 130 | return false 131 | } 132 | 133 | // 判断是否为外部网络地址 134 | func isExternalIP(ip string) bool { 135 | // 外部网络地址通常为 192.168.x.x、10.x.x.x 或 172.16.x.x 136 | if len(ip) >= 7 && ip[:7] == "192.168" { 137 | return true 138 | } 139 | if len(ip) >= 3 && ip[:3] == "10." { 140 | return true 141 | } 142 | if len(ip) >= 4 && ip[:4] == "172." { 143 | return true 144 | } 145 | return false 146 | } 147 | 148 | func ContainerName(name string) string { 149 | if strings.HasPrefix(name, "/") { 150 | return name[1:] 151 | } 152 | return name 153 | } 154 | 155 | func BytesToGB(size any) float32 { 156 | var ret float32 157 | switch v := size.(type) { 158 | case int: 159 | ret = float32(v) / 1024 / 1024 / 1024 160 | case int64: 161 | ret = float32(v) / 1024 / 1024 / 1024 162 | case uint64: 163 | ret = float32(v) / 1024 / 1024 / 1024 164 | case float32: 165 | ret = v / 1024 / 1024 / 1024 166 | case float64: 167 | ret = float32(v / 1024 / 1024 / 1024) 168 | } 169 | return float32(math.Round(float64(ret)*100) / 100) 170 | } 171 | 172 | func BindPort(bind string) int { 173 | if bind == "" { 174 | return 0 175 | } 176 | 177 | // 如果字符串包含 ":",说明可能是 IP:Port 或 :Port 格式 178 | if strings.Contains(bind, ":") { 179 | // 按 ":" 分割字符串 180 | parts := strings.Split(bind, ":") 181 | // 取最后一个部分作为端口 182 | portStr := parts[len(parts)-1] 183 | // 将端口字符串转换为 int 184 | port, err := strconv.Atoi(portStr) 185 | if err != nil { 186 | return 0 187 | } 188 | return port 189 | } 190 | 191 | // 如果字符串不包含 ":",说明是纯端口号 192 | port, err := strconv.Atoi(bind) 193 | if err != nil { 194 | return 0 195 | } 196 | return port 197 | } 198 | -------------------------------------------------------------------------------- /api/v1/handler/v1.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "humpback-agent/api/factory" 7 | v1model "humpback-agent/api/v1/model" 8 | "humpback-agent/controller" 9 | "humpback-agent/model" 10 | "net/http" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | const ( 17 | APIVersion = "/v1" 18 | ) 19 | 20 | func init() { 21 | _ = factory.InjectHandler(APIVersion, NewV1Handler) 22 | } 23 | 24 | type V1TaskType int 25 | 26 | const ( 27 | ContainerCreateTask V1TaskType = 1 28 | ContainerDeleteTask V1TaskType = 2 29 | ContainerStartTask V1TaskType = 3 30 | ContainerStopTask V1TaskType = 4 31 | ContainerRestartTask V1TaskType = 5 32 | NetworkCreateTask V1TaskType = 6 33 | NetworkDeleteTask V1TaskType = 7 34 | ) 35 | 36 | type V1Task struct { 37 | TaskType V1TaskType 38 | TaskBody any 39 | } 40 | 41 | type V1Handler struct { 42 | factory.BaseHandler 43 | apiVersion string 44 | taskChan chan *V1Task 45 | } 46 | 47 | func NewV1Handler(controller controller.ControllerInterface) factory.HandlerInterface { 48 | return &V1Handler{ 49 | BaseHandler: factory.BaseHandler{ 50 | ControllerInterface: controller, 51 | }, 52 | taskChan: make(chan *V1Task), 53 | } 54 | } 55 | 56 | func (handler *V1Handler) watchTasks() { 57 | 58 | for task := range handler.taskChan { 59 | switch task.TaskType { 60 | case ContainerCreateTask: 61 | container := handler.Container() 62 | go container.Create(context.Background(), task.TaskBody.(*v1model.CreateContainerRequest)) 63 | case ContainerDeleteTask: 64 | container := handler.Container() 65 | request := task.TaskBody.(*v1model.DeleteContainerRequest) 66 | containerMeta := model.ContainerMeta{ 67 | ContainerName: request.ContainerName, 68 | IsDelete: true, 69 | } 70 | container.BaseController().FailureChan() <- containerMeta 71 | go container.Delete(context.Background(), request) 72 | case ContainerRestartTask: 73 | container := handler.Container() 74 | go container.Restart(context.Background(), task.TaskBody.(*v1model.RestartContainerRequest)) 75 | case ContainerStartTask: 76 | container := handler.Container() 77 | go container.Start(context.Background(), task.TaskBody.(*v1model.StartContainerRequest)) 78 | case ContainerStopTask: 79 | container := handler.Container() 80 | go container.Stop(context.Background(), task.TaskBody.(*v1model.StopContainerRequest)) 81 | case NetworkCreateTask: 82 | network := handler.Network() 83 | go network.Create(context.Background(), task.TaskBody.(*v1model.CreateNetworkRequest)) 84 | case NetworkDeleteTask: 85 | network := handler.Network() 86 | go network.Delete(context.Background(), task.TaskBody.(*v1model.DeleteNetworkRequest)) 87 | } 88 | } 89 | } 90 | 91 | func (handler *V1Handler) SetRouter(version string, engine *gin.Engine) { 92 | handler.apiVersion = version 93 | routerRouter := engine.Group(fmt.Sprintf("api/%s", handler.apiVersion)) 94 | { 95 | routerRouter.GET("/faq", handler.faqHandleFunc) 96 | //container router 97 | containerRouter := routerRouter.Group("container") 98 | { 99 | containerRouter.GET(":containerId", handler.GetContainerHandleFunc) 100 | containerRouter.POST("list", handler.QueryContainerHandleFunc) 101 | containerRouter.POST("", handler.CreateContainerHandleFunc) 102 | containerRouter.PUT("", handler.UpdateContainerHandleFunc) 103 | containerRouter.DELETE(":containerId", handler.DeleteContainerHandleFunc) 104 | containerRouter.POST(":containerId/restart", handler.RestartContainerHandleFunc) 105 | containerRouter.POST(":containerId/start", handler.StartContainerHandleFunc) 106 | containerRouter.POST(":containerId/stop", handler.StopContainerHandleFunc) 107 | containerRouter.GET(":containerId/logs", handler.GetContainerLogsHandleFunc) 108 | containerRouter.GET(":containerId/stats", handler.GetContainerStatsHandleFunc) 109 | } 110 | 111 | //image router 112 | imageRouter := routerRouter.Group("image") 113 | { 114 | imageRouter.GET(":imageId", handler.GetImageHandleFunc) 115 | imageRouter.POST("query", handler.QueryImageHandleFunc) 116 | imageRouter.POST("push", handler.PushImageHandleFunc) 117 | imageRouter.POST("pull", handler.PullImageHandleFunc) 118 | imageRouter.DELETE(":imageId", handler.DeleteImageHandleFunc) 119 | } 120 | 121 | //volume router 122 | volumeRouter := routerRouter.Group("volume") 123 | { 124 | volumeRouter.GET(":volumeId", handler.GetVolumeHandleFunc) 125 | volumeRouter.POST("query", handler.QueryVolumeHandleFunc) 126 | volumeRouter.POST("", handler.CreateVolumeHandleFunc) 127 | volumeRouter.PUT("", handler.UpdateVolumeHandleFunc) 128 | volumeRouter.DELETE(":volumeId", handler.DeleteVolumeHandleFunc) 129 | } 130 | 131 | //network router 132 | networkRouter := routerRouter.Group("network") 133 | { 134 | networkRouter.GET(":networkId", handler.GetNetworkHandleFunc) 135 | networkRouter.POST("query", handler.QueryNetworkHandleFunc) 136 | networkRouter.POST("", handler.CreateNetworkHandleFunc) 137 | networkRouter.PUT("", handler.UpdateNetworkHandleFunc) 138 | networkRouter.DELETE(":networkId", handler.DeleteNetworkHandleFunc) 139 | } 140 | } 141 | go handler.watchTasks() 142 | } 143 | 144 | func (handler *V1Handler) faqHandleFunc(c *gin.Context) { 145 | c.JSON(http.StatusOK, &v1model.FAQResponse{ 146 | APIVersion: handler.apiVersion, 147 | Timestamp: time.Now().UnixMilli(), 148 | }) 149 | } 150 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "encoding/json" 9 | "encoding/pem" 10 | "errors" 11 | "flag" 12 | "fmt" 13 | "humpback-agent/config" 14 | "humpback-agent/model" 15 | "humpback-agent/pkg/utils" 16 | "humpback-agent/service" 17 | "io" 18 | "log/slog" 19 | "net/http" 20 | "os" 21 | "path/filepath" 22 | 23 | "github.com/sirupsen/logrus" 24 | "gopkg.in/natefinch/lumberjack.v2" 25 | ) 26 | 27 | func loadConfig(configPath string) (*config.AppConfig, error) { 28 | logrus.Info("Loading server config....") 29 | appConfig, err := config.NewAppConfig(configPath) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | logrus.Info("-----------------HUMPBACK AGENT CONFIG-----------------") 35 | logrus.Infof("API Bind: %s:%s", appConfig.APIConfig.HostIP, appConfig.APIConfig.Port) 36 | logrus.Infof("API Versions: %v", appConfig.APIConfig.Versions) 37 | logrus.Infof("API Middlewares: %v", appConfig.APIConfig.Middlewares) 38 | // logrus.Infof("API Access Token: %s", appConfig.APIConfig.AccessToken) 39 | logrus.Infof("Docker Host: %s", appConfig.DockerConfig.Host) 40 | logrus.Infof("Docker Version: %s", appConfig.DockerConfig.Version) 41 | logrus.Infof("Docker AutoNegotiate: %v", appConfig.DockerConfig.AutoNegotiate) 42 | logrus.Info("-------------------------------------------------------") 43 | return appConfig, nil 44 | } 45 | 46 | func initLogger(loggerConfig *config.LoggerConfig) error { 47 | logDir := filepath.Dir(loggerConfig.LogFile) 48 | if err := os.MkdirAll(logDir, 0755); err != nil { 49 | return err 50 | } 51 | 52 | lumberjackLogger := &lumberjack.Logger{ 53 | Filename: loggerConfig.LogFile, 54 | MaxSize: loggerConfig.MaxSize / 1024 / 1024, 55 | MaxBackups: loggerConfig.MaxBackups, 56 | MaxAge: loggerConfig.MaxAge, 57 | Compress: loggerConfig.Compress, 58 | } 59 | 60 | logrus.SetOutput(io.MultiWriter(os.Stdout, lumberjackLogger)) 61 | level, err := logrus.ParseLevel(loggerConfig.Level) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | logrus.SetLevel(level) 67 | if loggerConfig.Format == "json" { 68 | logrus.SetFormatter(&logrus.JSONFormatter{}) 69 | } else { 70 | logrus.SetFormatter(&logrus.TextFormatter{}) 71 | } 72 | return nil 73 | } 74 | 75 | func Bootstrap(ctx context.Context) { 76 | configFile := flag.String("f", "./config.yaml", "application configuration file path.") 77 | // 解析命令行参数 78 | flag.Parse() 79 | 80 | logrus.Info("Humpback Agent starting....") 81 | appConfig, err := loadConfig(*configFile) 82 | if err != nil { 83 | logrus.Errorf("Load application config error, %s", err.Error()) 84 | return 85 | } 86 | 87 | if err := initLogger(appConfig.LoggerConfig); err != nil { 88 | logrus.Errorf("Init application logger error, %s", err.Error()) 89 | return 90 | } 91 | 92 | certBundle, token, err := RegisterWithMaster(appConfig) 93 | if err != nil { 94 | logrus.Errorf("Register with master error, %s", err.Error()) 95 | return 96 | } 97 | 98 | agentService, err := service.NewAgentService(ctx, appConfig, certBundle, token) 99 | if err != nil { 100 | logrus.Errorf("Init application agent service error, %s", err.Error()) 101 | return 102 | } 103 | 104 | defer func() { 105 | agentService.Shutdown(ctx) 106 | logrus.Info("Humpback Agent shutdown.") 107 | }() 108 | 109 | logrus.Info("Humpback Agent started.") 110 | utils.ProcessWaitForSignal(nil) 111 | } 112 | 113 | // RegisterWithMaster 向Master注册并获取证书 114 | func RegisterWithMaster(appConfig *config.AppConfig) (*model.CertificateBundle, string, error) { 115 | client := &http.Client{ 116 | Transport: &http.Transport{ 117 | TLSClientConfig: &tls.Config{ 118 | InsecureSkipVerify: true, // 第一次连接跳过验证 119 | }, 120 | }, 121 | } 122 | 123 | var hostIps []string 124 | if appConfig.HostIP != "" { 125 | hostIps = []string{appConfig.HostIP} 126 | } else { 127 | hostIps = utils.HostIPs() 128 | } 129 | 130 | // 创建注册请求 131 | reqBody := struct { 132 | IpAddress []string `json:"hostIPs"` 133 | Token string `json:"token"` 134 | }{IpAddress: hostIps, Token: appConfig.ServerConfig.RegisterToken} 135 | 136 | reqBytes, _ := json.Marshal(reqBody) 137 | fmt.Printf("Register request body: %s\n", string(reqBytes)) 138 | url := fmt.Sprintf("https://%s/api/register", appConfig.ServerConfig.Host) 139 | req, _ := http.NewRequest("POST", url, bytes.NewReader(reqBytes)) 140 | req.Header.Set("Content-Type", "application/json") 141 | 142 | // 发送请求 143 | resp, err := client.Do(req) 144 | if err != nil { 145 | return nil, "", fmt.Errorf("registration request failed: %w", err) 146 | } 147 | defer resp.Body.Close() 148 | 149 | if resp.StatusCode != http.StatusOK { 150 | body, _ := io.ReadAll(resp.Body) 151 | return nil, "", fmt.Errorf("registration failed: %s", string(body)) 152 | } 153 | 154 | // 解析响应 155 | var regResp struct { 156 | CertPEM string `json:"certPem"` 157 | KeyPEM string `json:"keyPem"` 158 | Token string `json:"token"` 159 | CAPEM string `json:"caPem"` 160 | } 161 | if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { 162 | return nil, "", fmt.Errorf("failed to parse registration response: %w", err) 163 | } 164 | 165 | // 创建证书包 166 | certBlock, _ := pem.Decode([]byte(regResp.CertPEM)) 167 | if certBlock == nil { 168 | return nil, "", errors.New("invalid certificate format") 169 | } 170 | cert, err := x509.ParseCertificate(certBlock.Bytes) 171 | if err != nil { 172 | return nil, "", fmt.Errorf("failed to parse certificate: %w", err) 173 | } 174 | 175 | keyBlock, _ := pem.Decode([]byte(regResp.KeyPEM)) 176 | if keyBlock == nil { 177 | return nil, "", errors.New("invalid key format") 178 | } 179 | 180 | privKey, err := x509.ParseECPrivateKey(keyBlock.Bytes) 181 | if err != nil { 182 | return nil, "", fmt.Errorf("failed to parse private key: %w", err) 183 | } 184 | 185 | caBlock, _ := pem.Decode([]byte(regResp.CAPEM)) 186 | if caBlock == nil { 187 | return nil, "", errors.New("invalid CA certificate format") 188 | } 189 | caCert, err := x509.ParseCertificate(caBlock.Bytes) 190 | if err != nil { 191 | return nil, "", fmt.Errorf("failed to parse CA certificate: %w", err) 192 | } 193 | 194 | certPool := x509.NewCertPool() 195 | certPool.AddCert(caCert) 196 | 197 | certBundle := &model.CertificateBundle{ 198 | Cert: cert, 199 | PrivKey: privKey, 200 | CertPool: certPool, 201 | CertPEM: []byte(regResp.CertPEM), 202 | KeyPEM: []byte(regResp.KeyPEM), 203 | } 204 | 205 | currentToken := regResp.Token 206 | 207 | slog.Info("Worker registered with master successfully") 208 | return certBundle, currentToken, nil 209 | } 210 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | v1model "humpback-agent/api/v1/model" 7 | "humpback-agent/model" 8 | "humpback-agent/pkg/utils" 9 | "net" 10 | "path/filepath" 11 | "regexp" 12 | "time" 13 | 14 | "github.com/docker/docker/client" 15 | "github.com/docker/docker/libnetwork/portallocator" 16 | "github.com/google/uuid" 17 | "github.com/sirupsen/logrus" 18 | 19 | "math/rand/v2" 20 | ) 21 | 22 | // 编译正则表达式 23 | var re = regexp.MustCompile(`\{([^}]+)\}`) 24 | 25 | type GetConfigValueFunc func(configNames []string) (map[string][]byte, error) 26 | 27 | type InternalController interface { 28 | WithTimeout(ctx context.Context, callback func(context.Context) error) error 29 | DockerEngine(ctx context.Context) (*model.DockerEngineInfo, error) 30 | GetConfigNamesWithVolumes(volumes []*v1model.ServiceVolume) map[string]string 31 | BuildVolumesWithConfigNames(configNames map[string]string) (map[string]string, error) 32 | ConfigValues(ctx context.Context, configNames []string) (map[string][]byte, error) 33 | AllocPort(proto string) (int, error) 34 | FailureChan() chan model.ContainerMeta 35 | } 36 | 37 | type ControllerInterface interface { 38 | InternalController 39 | Image() ImageControllerInterface 40 | Container() ContainerControllerInterface 41 | Network() NetworkControllerInterface 42 | } 43 | 44 | type BaseController struct { 45 | client *client.Client 46 | volumesRootDirectory string 47 | reqTimeout time.Duration 48 | getConfigFunc GetConfigValueFunc 49 | image ImageControllerInterface 50 | container ContainerControllerInterface 51 | network NetworkControllerInterface 52 | failureChan chan model.ContainerMeta 53 | } 54 | 55 | func NewController(client *client.Client, getConfigFunc GetConfigValueFunc, volumesRootDirectory string, reqTimeout time.Duration, failureChan chan model.ContainerMeta) ControllerInterface { 56 | baseController := &BaseController{ 57 | client: client, 58 | volumesRootDirectory: volumesRootDirectory, 59 | reqTimeout: reqTimeout, 60 | getConfigFunc: getConfigFunc, 61 | failureChan: failureChan, 62 | } 63 | 64 | baseController.image = NewImageController(baseController, client) 65 | baseController.container = NewContainerController(baseController, client) 66 | baseController.network = NewNetworkController(baseController, client) 67 | return baseController 68 | } 69 | 70 | func (controller *BaseController) WithTimeout(ctx context.Context, callback func(context.Context) error) error { 71 | ctx, cancel := context.WithTimeout(ctx, controller.reqTimeout) 72 | defer cancel() 73 | return callback(ctx) 74 | } 75 | 76 | func (controller *BaseController) DockerEngine(ctx context.Context) (*model.DockerEngineInfo, error) { 77 | var engineInfo model.DockerEngineInfo 78 | serverVersion, getErr := controller.client.ServerVersion(ctx) 79 | if getErr != nil { 80 | return nil, getErr 81 | } 82 | 83 | dockerInfo, infoErr := controller.client.Info(ctx) 84 | if infoErr != nil { 85 | return nil, infoErr 86 | } 87 | 88 | engineInfo.Version = dockerInfo.ServerVersion 89 | engineInfo.APIVersion = serverVersion.APIVersion 90 | engineInfo.RootDirectory = dockerInfo.DockerRootDir 91 | engineInfo.StorageDriver = dockerInfo.Driver 92 | engineInfo.LoggingDriver = dockerInfo.LoggingDriver 93 | engineInfo.VolumePlugins = dockerInfo.Plugins.Volume 94 | engineInfo.NetworkPlugins = dockerInfo.Plugins.Network 95 | return &engineInfo, nil 96 | } 97 | 98 | func (controller *BaseController) GetConfigNamesWithVolumes(volumes []*v1model.ServiceVolume) map[string]string { 99 | configPair := map[string]string{} 100 | for _, volume := range volumes { 101 | if volume.Type == v1model.ServiceVolumeTypeBind { //先只实现bind类型 102 | matches := re.FindStringSubmatch(volume.Source) 103 | if len(matches) > 1 { 104 | if fileName := filepath.Base(volume.Target); fileName != "" { 105 | configPair[matches[1]] = fileName 106 | } 107 | } 108 | } 109 | } 110 | return configPair 111 | } 112 | 113 | func (controller *BaseController) ConfigValues(ctx context.Context, configNames []string) (map[string][]byte, error) { 114 | if controller.getConfigFunc != nil { 115 | return controller.getConfigFunc(configNames) 116 | } 117 | return nil, fmt.Errorf("no setting config value getter") 118 | } 119 | 120 | func (controller *BaseController) AllocPort(proto string) (int, error) { 121 | if proto == "" { 122 | proto = "tcp" 123 | } 124 | pa := portallocator.Get() 125 | begin := pa.Begin 126 | end := pa.End 127 | retry := 0 128 | maxRetry := 5 129 | port := 0 130 | isFree := false 131 | 132 | for !isFree && retry < maxRetry { 133 | port = rand.IntN(end-begin) + begin 134 | isFree = isPortFree(port) 135 | retry++ 136 | } 137 | 138 | if !isFree { 139 | return 0, fmt.Errorf("no free port found in %d-%d", begin, end) 140 | } 141 | 142 | logrus.Infof("Alloc port %d for %s", port, proto) 143 | return port, nil 144 | 145 | } 146 | 147 | func isPortFree(port int) bool { 148 | listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) 149 | if err != nil { 150 | return false 151 | } 152 | listener.Close() 153 | return true 154 | } 155 | 156 | func (controller *BaseController) BuildVolumesWithConfigNames(configNames map[string]string) (map[string]string, error) { 157 | configPaths := map[string]string{} 158 | if len(configNames) > 0 { 159 | names := []string{} 160 | for name, _ := range configNames { 161 | names = append(names, name) 162 | } 163 | 164 | configValues, err := controller.ConfigValues(context.Background(), names) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | var vid uuid.UUID 170 | for configName, data := range configValues { 171 | vid, err = uuid.NewUUID() 172 | if err != nil { 173 | return nil, err 174 | } 175 | if fileName, ret := configNames[configName]; ret { 176 | filePath := fmt.Sprintf("%s/%s/_data/%s", controller.volumesRootDirectory, vid.String(), fileName) 177 | if err = utils.WriteFileWithDir(filePath, []byte(data), 0755); err != nil { 178 | return nil, err 179 | } 180 | configPaths[configName] = filePath 181 | } 182 | } 183 | } 184 | return configPaths, nil 185 | } 186 | 187 | func (controller *BaseController) Image() ImageControllerInterface { 188 | return controller.image 189 | } 190 | 191 | func (controller *BaseController) Container() ContainerControllerInterface { 192 | return controller.container 193 | } 194 | 195 | func (controller *BaseController) Network() NetworkControllerInterface { 196 | return controller.network 197 | } 198 | 199 | func (controller *BaseController) FailureChan() chan model.ContainerMeta { 200 | return controller.failureChan 201 | } 202 | -------------------------------------------------------------------------------- /internal/schedule/task.go: -------------------------------------------------------------------------------- 1 | package schedule 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "strings" 10 | "time" 11 | 12 | "github.com/docker/docker/api/types/network" 13 | "github.com/docker/docker/api/types/registry" 14 | 15 | "github.com/docker/docker/api/types/container" 16 | "github.com/docker/docker/api/types/image" 17 | "github.com/docker/docker/client" 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | type Task struct { 22 | ContainerId string 23 | Name string 24 | Image string 25 | AlwaysPull bool 26 | Timeout time.Duration 27 | Rule string 28 | client *client.Client 29 | executing bool 30 | Auth string 31 | } 32 | 33 | func NewTask(containerId string, name string, image string, alwaysPull bool, timeout time.Duration, rule string, authStr string, client *client.Client) *Task { 34 | logrus.Infof("container %s task [%s] created.", name, rule) 35 | return &Task{ 36 | ContainerId: containerId, 37 | Name: name, 38 | Image: image, 39 | AlwaysPull: alwaysPull, 40 | Timeout: timeout, 41 | Rule: rule, 42 | client: client, 43 | executing: false, 44 | Auth: authStr, 45 | } 46 | } 47 | 48 | func (task *Task) Execute() { 49 | if task.executing { 50 | logrus.Warnf("container %s task [%s] currently executing", task.Name, task.Rule) 51 | return 52 | } 53 | 54 | logrus.Infof("container %s task [%s] start executing", task.Name, task.Rule) 55 | 56 | task.executing = true 57 | reCreate := false 58 | if task.AlwaysPull { //检查镜像是否需要重启拉取 59 | currentImageId, err := task.getImageId() 60 | if err != nil { 61 | task.executing = false 62 | return 63 | } 64 | 65 | newImageId, err := task.pullImage() 66 | if err != nil { 67 | logrus.Errorf("container %s task [%s] pull image execute error, %v", task.Name, task.Rule, err) 68 | task.executing = false 69 | return 70 | } 71 | 72 | if currentImageId != newImageId { 73 | reCreate = true 74 | } 75 | } 76 | if task.Timeout <= 0 { 77 | if err := task.startContainer(context.Background(), reCreate); err != nil { 78 | logrus.Errorf("container %s task [%s] start container execute error, %v", task.Name, task.Rule, err) 79 | } 80 | task.executing = false 81 | return 82 | } 83 | 84 | // 设置任务的最大执行时间(超时时间) 85 | ctx, cancel := context.WithTimeout(context.Background(), task.Timeout) 86 | defer func() { 87 | cancel() 88 | task.executing = false 89 | }() 90 | 91 | // 启动容器 92 | if err := task.startContainer(ctx, reCreate); err != nil { 93 | logrus.Errorf("container %s task [%s] start container execute error, %v", task.Name, task.Rule, err) 94 | return 95 | } 96 | 97 | logrus.Infof("container %s task [%s] start succeed.", task.Name, task.Rule) 98 | select { 99 | case <-task.waitForContainerExit(ctx): // 等待容器完成任务 100 | logrus.Infof("container %s task [%s] executed.", task.Name, task.Rule) 101 | case <-ctx.Done(): // 容器执行超时 102 | logrus.Infof("container %s task [%s] executing timeout.", task.Name, task.Rule) 103 | task.stopContainer() 104 | } 105 | } 106 | 107 | func (task *Task) getImageId() (string, error) { 108 | imageInfo, _, err := task.client.ImageInspectWithRaw(context.Background(), task.Image) 109 | if err != nil { 110 | return "", err 111 | } 112 | return imageInfo.ID, nil 113 | } 114 | 115 | func (task *Task) pullImage() (string, error) { 116 | 117 | authStr := "" 118 | if task.Auth != "" { 119 | 120 | decodedBytes, err := base64.StdEncoding.DecodeString(task.Auth) 121 | if err != nil { 122 | return "", err 123 | } 124 | decodedStr := string(decodedBytes) 125 | 126 | // 按 ^^ 分割字符串 127 | parts := strings.Split(decodedStr, "^^") 128 | if len(parts) != 3 { 129 | return "", errors.New("image auth config invalid") 130 | } 131 | 132 | username := parts[0] 133 | password := parts[1] 134 | address := parts[2] 135 | 136 | authConfig := registry.AuthConfig{ 137 | Username: username, 138 | Password: password, 139 | ServerAddress: address, 140 | } 141 | 142 | authBytes, _ := json.Marshal(authConfig) 143 | authStr = base64.URLEncoding.EncodeToString(authBytes) 144 | } 145 | 146 | pullOptions := image.PullOptions{ 147 | All: false, 148 | RegistryAuth: authStr, 149 | } 150 | 151 | out, err := task.client.ImagePull(context.Background(), task.Image, pullOptions) 152 | if err != nil { 153 | return "", err 154 | } 155 | 156 | out.Close() 157 | return task.getImageId() 158 | } 159 | 160 | func (task *Task) startContainer(ctx context.Context, reCreate bool) error { 161 | if reCreate { 162 | if err := task.reCreateContainer(); err != nil { 163 | return err 164 | } 165 | } 166 | return task.client.ContainerStart(ctx, task.ContainerId, container.StartOptions{}) 167 | } 168 | 169 | func (task *Task) waitForContainerExit(ctx context.Context) <-chan struct{} { 170 | done := make(chan struct{}) 171 | go func() { 172 | defer close(done) 173 | statusCh, errCh := task.client.ContainerWait(ctx, task.ContainerId, container.WaitConditionNotRunning) 174 | select { 175 | case err := <-errCh: 176 | logrus.Errorf("container %s task [%s] exit error: %v", task.Name, task.Rule, err) 177 | case <-statusCh: 178 | // 容器已退出 179 | } 180 | }() 181 | return done 182 | } 183 | 184 | func (task *Task) stopContainer() error { 185 | timeout := MaxKillContainerTimeoutSeconds // 停止容器的超时时间, sdk单位为秒 186 | err := task.client.ContainerStop(context.Background(), task.ContainerId, container.StopOptions{ 187 | Signal: "SIGKILL", 188 | Timeout: &timeout, 189 | }) 190 | 191 | if err != nil { 192 | logrus.Errorf("container %s task [%s] stop error: %s", task.Name, task.Rule, err.Error()) 193 | } 194 | return err 195 | } 196 | 197 | func (task *Task) reCreateContainer() error { 198 | originContainerInfo, err := task.client.ContainerInspect(context.Background(), task.ContainerId) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | discardContainerName := fmt.Sprintf("%s-%d-discard", originContainerInfo.Name, time.Now().Unix()) 204 | //先将当前容器名称修改为废弃名称 205 | if err := task.client.ContainerRename(context.Background(), task.ContainerId, discardContainerName); err != nil { 206 | logrus.Errorf("container %s rename to %s error, always pull recreate give up. %s.", originContainerInfo.Name, discardContainerName, err.Error()) 207 | return err 208 | } 209 | 210 | networkingConfig := &network.NetworkingConfig{ 211 | EndpointsConfig: originContainerInfo.NetworkSettings.Networks, 212 | } 213 | 214 | containerInfo, err := task.client.ContainerCreate(context.Background(), originContainerInfo.Config, originContainerInfo.HostConfig, networkingConfig, nil, originContainerInfo.Name) 215 | if err != nil { 216 | logrus.Errorf("container %s always pull recreate error, %s.", originContainerInfo.Name, err.Error()) 217 | task.client.ContainerRename(context.Background(), task.ContainerId, originContainerInfo.Name) //老容器还原名称 218 | return err 219 | } 220 | 221 | //删除老容器 222 | task.ContainerId = containerInfo.ID 223 | task.client.ContainerRemove(context.Background(), originContainerInfo.ID, container.RemoveOptions{Force: true}) 224 | logrus.Infof("container %s recreated succeed.", originContainerInfo.Name) 225 | return nil 226 | } 227 | -------------------------------------------------------------------------------- /api/v1/model/container_req.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type GetContainerRequest struct { 11 | ContainerId string 12 | } 13 | 14 | func BindGetContainerRequest(c *gin.Context) (*GetContainerRequest, *ErrorResult) { 15 | containerId := c.Param("containerId") 16 | return &GetContainerRequest{ 17 | ContainerId: containerId, 18 | }, nil 19 | } 20 | 21 | /* 22 | e.g QueryContainerRequest 23 | 24 | { 25 | "size": false, 26 | "all": true, 27 | "filters": { 28 | "status": "running", 29 | "label": "architecture=aarch64" 30 | } 31 | } 32 | */ 33 | type QueryContainerRequest struct { 34 | Size bool `json:"size"` //是否返回带磁盘大小信息 35 | All bool `json:"all"` //是否包括已停止的容器 36 | Latest bool `json:"latest"` //是否只返回最近创建的容器 37 | Since string `json:"since"` //只返回在指定容器之后创建的容器 38 | Before string `json:"before"` //只返回在指定容器之前创建的容器 39 | Limit int `json:"limit"` //返回容器的最大数量 40 | Filters map[string]string `json:"filters"` //过滤容器的条件(filters.Add("status", "running") or filters.Add("label", "env=prod")) 41 | } 42 | 43 | func BindQueryContainerRequest(c *gin.Context) (*QueryContainerRequest, *ErrorResult) { 44 | request := QueryContainerRequest{} 45 | if err := c.Bind(&request); err != nil { 46 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 47 | } 48 | return &request, nil 49 | } 50 | 51 | type CreateContainerRequest struct { 52 | ContainerName string `json:"containerName"` 53 | ServiceName string `json:"serviceName"` 54 | ServiceId string `json:"serviceId"` 55 | GroupId string `json:"groupId"` 56 | ManualExec bool `json:"manualExec"` 57 | RegistryAuth RegistryAuth `json:"registryAuth"` 58 | ErrorMsg string 59 | *ContainerMeta `json:",inline"` 60 | *ScheduleInfo `json:",inline"` 61 | } 62 | 63 | func BindCreateContainerRequest(c *gin.Context) (*CreateContainerRequest, *ErrorResult) { 64 | request := &CreateContainerRequest{} 65 | if err := c.ShouldBindJSON(request); err != nil { 66 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 67 | } 68 | return request, nil 69 | } 70 | 71 | //type CreateContainerRequest struct { 72 | // Name string `json:"name"` // 容器名称 73 | // Image string `json:"image"` // 镜像名称 74 | // OnlyCreate bool `json:"onlyCreate"` // 创建后立即启动 75 | // AlwaysPull bool `json:"alwaysPull"` // 是否总是拉取镜像 76 | // AutoRemove bool `json:"autoRemove"` // 是否自动删除容器 77 | // PortMap map[string]string `json:"portMap"` // 端口映射(hostPort:containerPort) 78 | // PublishAll bool `json:"publishAll"` // 是否将所有暴露的端口映射到随机主机端口 79 | // Command []string `json:"command"` // 命令 80 | // Entrypoint []string `json:"entrypoint"` // 入口点 81 | // WorkingDir string `json:"workingDir"` // 工作目录 82 | // Interactive bool `json:"interactive"` // 是否启用交互模式(-i) 83 | // TTY bool `json:"tty"` // 是否启用 TTY(-t) 84 | // Env map[string]string `json:"env"` // 环境变量 85 | // Labels map[string]string `json:"labels"` // 标签 86 | // RestartPolicy string `json:"restartPolicy"` // 重启策略(never, always, on-failure, unless-stopped) 87 | // Logger *ContainerLogger `json:"logger"` // Logger 配置 88 | // Network *ContainerNetwork `json:"network"` // Network 配置 89 | // Runtime *ContainerRuntime `json:"runtime"` // Runtime 配置 90 | // Sysctls *ContainerSysctl `json:"sysctls"` // Sysctls 配置 91 | // Resources *ContainerResource `json:"resources"` // Resource Limits 配置 92 | // Cap *ContainerCapability `json:"cap"` // Capabilities 配置 93 | //} 94 | 95 | type UpdateContainerRequest struct{} 96 | 97 | type DeleteContainerRequest struct { 98 | ContainerId string `json:"containerId"` 99 | ContainerName string `json:"containerName"` 100 | Force bool `json:"force"` 101 | } 102 | 103 | func BindDeleteContainerRequest(c *gin.Context) (*DeleteContainerRequest, *ErrorResult) { 104 | force := false 105 | forceQuery := c.Query("force") 106 | if strings.TrimSpace(forceQuery) != "" { 107 | value, err := strconv.ParseBool(forceQuery) 108 | if err != nil { 109 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 110 | } 111 | force = value 112 | } 113 | 114 | containerName := c.Query("containerName") 115 | containerId := c.Param("containerId") 116 | return &DeleteContainerRequest{ 117 | ContainerId: containerId, 118 | ContainerName: containerName, 119 | Force: force, 120 | }, nil 121 | } 122 | 123 | type StartContainerRequest struct { 124 | ContainerId string `json:"containerId"` 125 | } 126 | 127 | func BindStartContainerRequest(c *gin.Context) (*StartContainerRequest, *ErrorResult) { 128 | request := &StartContainerRequest{} 129 | if err := c.ShouldBind(request); err != nil { 130 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 131 | } 132 | 133 | if request.ContainerId == "" { 134 | request.ContainerId = c.Param("containerId") 135 | } 136 | return request, nil 137 | } 138 | 139 | type StopContainerRequest struct { 140 | ContainerId string `json:"containerId"` 141 | } 142 | 143 | func BindStopContainerRequest(c *gin.Context) (*StopContainerRequest, *ErrorResult) { 144 | request := &StopContainerRequest{} 145 | if err := c.ShouldBind(request); err != nil { 146 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 147 | } 148 | 149 | if request.ContainerId == "" { 150 | request.ContainerId = c.Param("containerId") 151 | } 152 | return request, nil 153 | } 154 | 155 | type RestartContainerRequest struct { 156 | ContainerId string `json:"containerId"` 157 | } 158 | 159 | func BindRestartContainerRequest(c *gin.Context) (*RestartContainerRequest, *ErrorResult) { 160 | request := &RestartContainerRequest{} 161 | if err := c.ShouldBind(request); err != nil { 162 | return nil, RequestErrorResult(RequestArgsErrorCode, RequestArgsErrorMsg) 163 | } 164 | 165 | if request.ContainerId == "" { 166 | request.ContainerId = c.Param("containerId") 167 | } 168 | return request, nil 169 | } 170 | 171 | type GetContainerLogsRequest struct { 172 | ContainerId string `json:"containerId"` 173 | Follow *bool `json:"follow"` // 是否实时跟随日志 174 | Tail *string `json:"tail"` // 日志行数(例如 "10" 或 "all") 175 | Since *string `json:"since"` // 从某个时间点开始的日志(例如 "2023-10-01T00:00:00Z") 176 | Until *string `json:"until"` // 到某个时间点结束的日志 177 | Timestamps *bool `json:"timestamps"` // 是否显示时间戳 178 | Details *bool `json:"details"` // 是否显示详细信息 179 | } 180 | 181 | func BindGetContainerLogsRequest(c *gin.Context) (*GetContainerLogsRequest, *ErrorResult) { 182 | request := &GetContainerLogsRequest{ 183 | ContainerId: c.Param("containerId"), 184 | } 185 | 186 | follow := c.Query("follow") 187 | if follow != "" { 188 | if value, err := strconv.ParseBool(follow); err == nil { 189 | request.Follow = &value 190 | } 191 | } 192 | 193 | tail := c.Query("tail") 194 | if tail != "" { 195 | request.Tail = &tail 196 | } 197 | 198 | since := c.Query("since") 199 | if since != "" { 200 | request.Since = &since 201 | } 202 | 203 | until := c.Query("until") 204 | if until != "" { 205 | request.Until = &until 206 | } 207 | 208 | timestamps := c.Query("timestamps") 209 | if timestamps != "" { 210 | if value, err := strconv.ParseBool(timestamps); err == nil { 211 | request.Timestamps = &value 212 | } 213 | } 214 | 215 | details := c.Query("details") 216 | if details != "" { 217 | if value, err := strconv.ParseBool(details); err == nil { 218 | request.Details = &value 219 | } 220 | } 221 | return request, nil 222 | } 223 | 224 | type GetContainerStatsRequest struct { 225 | ContainerId string `json:"containerId"` 226 | } 227 | 228 | func BindGetContainerStatsRequest(c *gin.Context) (*GetContainerStatsRequest, *ErrorResult) { 229 | request := &GetContainerStatsRequest{ 230 | ContainerId: c.Param("containerId"), 231 | } 232 | return request, nil 233 | } 234 | -------------------------------------------------------------------------------- /model/container.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "humpback-agent/pkg/utils" 5 | "log/slog" 6 | "math" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | ) 14 | 15 | const ( 16 | ContainerStatusPending = "Pending" 17 | ContainerStatusStarting = "Starting" 18 | ContainerStatusCreated = "Created" 19 | ContainerStatusRunning = "Running" 20 | ContainerStatusFailed = "Failed" 21 | ContainerStatusExited = "Exited" 22 | ContainerStatusRemoved = "Removed" 23 | ContainerStatusWarning = "Warning" 24 | ) 25 | 26 | var stateMap = map[string]string{ 27 | "healthy": ContainerStatusRunning, 28 | "unhealthy": ContainerStatusFailed, 29 | "starting": ContainerStatusStarting, 30 | "restarting": ContainerStatusStarting, 31 | "running": ContainerStatusRunning, 32 | "exited": ContainerStatusExited, 33 | "create": ContainerStatusCreated, 34 | "created": ContainerStatusCreated, 35 | "stop": ContainerStatusExited, 36 | "stopped": ContainerStatusExited, 37 | "destroy": ContainerStatusRemoved, 38 | "remove": ContainerStatusRemoved, 39 | "removing": ContainerStatusRemoved, 40 | "delete": ContainerStatusRemoved, 41 | "pending": ContainerStatusPending, 42 | "warning": ContainerStatusWarning, 43 | } 44 | 45 | type ContainerPort struct { 46 | BindIP string `json:"bindIP"` 47 | PrivatePort int `json:"privatePort"` 48 | PublicPort int `json:"publicPort"` 49 | Type string `json:"type"` 50 | } 51 | 52 | type ContainerIP struct { 53 | NetworkID string `json:"networkID"` 54 | EndpointID string `json:"endpointID"` 55 | Gateway string `json:"gateway"` 56 | IPAddress string `json:"ipAddress"` 57 | } 58 | 59 | type MounteInfo struct { 60 | Source string `json:"Source"` 61 | Destination string `json:"Destination"` 62 | } 63 | 64 | type ContainerMeta struct { 65 | ContainerName string 66 | State string 67 | ErrorMsg string 68 | IsDelete bool 69 | } 70 | 71 | type ContainerInfo struct { 72 | ContainerId string `json:"containerId"` 73 | ContainerName string `json:"containerName"` 74 | State string `json:"state"` 75 | Status string `json:"status"` 76 | Network string `json:"network"` 77 | Image string `json:"image"` 78 | Labels map[string]string `json:"labels"` 79 | Env []string `json:"env"` 80 | Mountes []MounteInfo `json:"mounts"` 81 | Command string `json:"command"` 82 | Ports []ContainerPort `json:"ports"` 83 | IPAddr []ContainerIP `json:"ipAddr"` 84 | Created int64 `json:"created"` 85 | Started int64 `json:"started"` 86 | Finished int64 `json:"finished"` 87 | ErrorMsg string `json:"errorMsg"` 88 | } 89 | 90 | func ParseContainerInfo(container types.ContainerJSON) *ContainerInfo { 91 | createdTimestamp := int64(0) 92 | if createdAt, err := time.Parse(time.RFC3339Nano, container.Created); err == nil { 93 | createdTimestamp = createdAt.UnixMilli() 94 | } 95 | 96 | state, status := "", "" 97 | startedTimestamp, finishedTimestamp := int64(0), int64(0) 98 | if container.State != nil { 99 | var err error 100 | var startedAt time.Time 101 | var finishedAt time.Time 102 | if container.State.Status != "created" { 103 | if startedAt, err = time.Parse(time.RFC3339Nano, container.State.StartedAt); err == nil { 104 | startedTimestamp = startedAt.UnixMilli() 105 | } 106 | 107 | if finishedAt, err = time.Parse(time.RFC3339Nano, container.State.FinishedAt); err == nil { 108 | finishedTimestamp = finishedAt.UnixMilli() 109 | } 110 | } 111 | 112 | if container.State.Status == "exited" { 113 | status = utils.HumanDuration(time.Since(finishedAt)) 114 | } else { 115 | if container.State.Status != "created" { 116 | status = utils.HumanDuration(time.Since(startedAt)) 117 | } 118 | } 119 | } 120 | 121 | state = stateMap[container.State.Status] 122 | if state == "" { 123 | slog.Info("unknow container status", "status", container.State.Status) 124 | } 125 | return &ContainerInfo{ 126 | ContainerId: container.ID, 127 | ContainerName: utils.ContainerName(container.Name), 128 | State: state, 129 | Status: status, 130 | Image: container.Config.Image, 131 | Labels: container.Config.Labels, 132 | Network: container.HostConfig.NetworkMode.NetworkName(), 133 | Env: container.Config.Env, 134 | Mountes: ParseContainerMountes(container.Mounts), 135 | Command: ParseContainerCommandWithConfig(container.Config), 136 | Ports: ParseContainerPortsWithNetworkSettings(container.NetworkSettings), 137 | IPAddr: ParseContainerIPAddrWithNetworkSettings(container.NetworkSettings), 138 | Created: createdTimestamp, 139 | Started: startedTimestamp, 140 | Finished: finishedTimestamp, 141 | } 142 | } 143 | 144 | func ParseContainerCommandWithConfig(containerConfig *container.Config) string { 145 | if containerConfig != nil { 146 | if len(containerConfig.Cmd) > 0 { 147 | return strings.Join(containerConfig.Cmd, " ") 148 | } else if len(containerConfig.Entrypoint) > 0 { 149 | return strings.Join(containerConfig.Entrypoint, " ") 150 | } 151 | } 152 | return "" 153 | } 154 | 155 | func ParseContainerPortsWithNetworkSettings(networkSettings *types.NetworkSettings) []ContainerPort { 156 | ports := []ContainerPort{} 157 | for containerPort, bindings := range networkSettings.Ports { 158 | 159 | for _, binding := range bindings { 160 | //fmt.Printf(" Host IP: %s, Host Port: %s\n", binding.HostIP, binding.HostPort) 161 | 162 | portInfo := ContainerPort{ 163 | BindIP: binding.HostIP, 164 | Type: containerPort.Proto(), 165 | } 166 | 167 | pport, _ := strconv.Atoi(containerPort.Port()) 168 | portInfo.PrivatePort = pport 169 | 170 | hport, _ := strconv.Atoi(binding.HostPort) 171 | portInfo.PublicPort = hport 172 | 173 | ports = append(ports, portInfo) 174 | } 175 | 176 | } 177 | return ports 178 | } 179 | 180 | func ParseContainerIPAddrWithNetworkSettings(networkSettings *types.NetworkSettings) []ContainerIP { 181 | ipAddrs := []ContainerIP{} 182 | if networkSettings != nil { 183 | for _, network := range networkSettings.Networks { 184 | ipAddrs = append(ipAddrs, ContainerIP{ 185 | NetworkID: network.NetworkID, 186 | EndpointID: network.EndpointID, 187 | Gateway: network.Gateway, 188 | IPAddress: network.IPAddress, 189 | }) 190 | } 191 | } 192 | return ipAddrs 193 | } 194 | 195 | func ParseContainerMountes(mps []types.MountPoint) []MounteInfo { 196 | if mps == nil { 197 | return nil 198 | } 199 | 200 | mountes := make([]MounteInfo, 0) 201 | for _, mp := range mps { 202 | m := MounteInfo{ 203 | Source: mp.Source, 204 | Destination: mp.Destination, 205 | } 206 | mountes = append(mountes, m) 207 | } 208 | return mountes 209 | } 210 | 211 | type DockerLog struct { 212 | Time string `json:"time"` 213 | Stream string `json:"stream"` 214 | Log string `json:"log"` 215 | } 216 | 217 | type DockerContainerLog struct { 218 | ContainerId string `json:"containerId"` 219 | DockerLogs []DockerLog `json:"containerLogs"` 220 | } 221 | 222 | type ContainerNetwork struct { 223 | Name string `json:"name"` 224 | RxBytes uint64 `json:"rxBytes"` 225 | TxBytes uint64 `json:"txBytes"` 226 | } 227 | 228 | type ContainerStats struct { 229 | CPUPercent float64 `json:"cpuPercent"` 230 | MemoryUsageBytes uint64 `json:"memoryUsageBytes"` 231 | MemoryLimitBytes uint64 `json:"memoryLimitBytes"` 232 | DiskReadBytes uint64 `json:"diskReadBytes"` 233 | DiskWriteBytes uint64 `json:"diskWriteBytes"` 234 | Networks []ContainerNetwork `json:"networks"` 235 | StatsTime string `json:"statsTime"` 236 | } 237 | 238 | func ParseContainerStats(containerStats *container.StatsResponse) *ContainerStats { 239 | stats := ContainerStats{} 240 | if containerStats != nil { 241 | stats.CPUPercent = calculateCPUPercent(containerStats) 242 | stats.MemoryUsageBytes = containerStats.MemoryStats.Usage 243 | stats.MemoryLimitBytes = containerStats.MemoryStats.Limit 244 | stats.Networks = calculateNetworkIO(containerStats) 245 | stats.DiskReadBytes, stats.DiskWriteBytes = calculateDiskIO(containerStats) 246 | stats.StatsTime = time.Now().Format(time.RFC3339Nano) //containerStats.Read.Format(time.RFC3339Nano) 247 | } 248 | return &stats 249 | } 250 | 251 | // 计算容器 CPU 使用率 252 | func calculateCPUPercent(stats *container.StatsResponse) float64 { 253 | cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage) 254 | systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage) 255 | // 获取 CPU 核心数 256 | cpuCount := float64(stats.CPUStats.OnlineCPUs) 257 | if cpuCount == 0 { 258 | //如果 OnlineCPUs 为 0,使用PercpuUsage的长度作为备选 259 | cpuCount = float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) 260 | if cpuCount == 0 { 261 | // 如果仍然为 0,默认设置为 1(避免除零错误) 262 | cpuCount = 1.0 263 | } 264 | } 265 | // CPU 使用率 = (容器 CPU 使用时间增量 / 系统 CPU 时间增量) * CPU 核心数 * 100 266 | cpuPercent := (cpuDelta / systemDelta) * cpuCount * 100.0 267 | //cpuPercent := (cpuDelta / systemDelta) * float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) * 100.0 268 | return math.Round(cpuPercent*100) / 100 //保留两位小数 269 | } 270 | 271 | // 计算容器网络 I/O 272 | func calculateNetworkIO(stats *container.StatsResponse) []ContainerNetwork { 273 | networks := []ContainerNetwork{} 274 | for name, network := range stats.Networks { 275 | networks = append(networks, ContainerNetwork{ 276 | Name: name, 277 | RxBytes: network.RxBytes, 278 | TxBytes: network.TxBytes, 279 | }) 280 | } 281 | return networks 282 | } 283 | 284 | func calculateDiskIO(stats *container.StatsResponse) (uint64, uint64) { 285 | var diskReadBytes, diskWriteBytes uint64 286 | if len(stats.BlkioStats.IoServiceBytesRecursive) > 0 { 287 | for _, bytesIO := range stats.BlkioStats.IoServiceBytesRecursive { 288 | if bytesIO.Op == "read" { 289 | diskReadBytes = bytesIO.Value 290 | } else if bytesIO.Op == "write" { 291 | diskWriteBytes = bytesIO.Value 292 | } 293 | } 294 | } 295 | return diskReadBytes, diskWriteBytes 296 | } 297 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /service/agent.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "humpback-agent/api" 15 | v1model "humpback-agent/api/v1/model" 16 | "humpback-agent/config" 17 | "humpback-agent/controller" 18 | reqclient "humpback-agent/internal/client" 19 | "humpback-agent/internal/docker" 20 | "humpback-agent/internal/schedule" 21 | "humpback-agent/model" 22 | 23 | "github.com/docker/docker/api/types" 24 | "github.com/docker/docker/api/types/events" 25 | "github.com/docker/docker/client" 26 | "github.com/sirupsen/logrus" 27 | ) 28 | 29 | type AgentService struct { 30 | sync.RWMutex 31 | config *config.AppConfig 32 | apiServer *api.APIServer 33 | httpClient *http.Client 34 | scheduler schedule.TaskSchedulerInterface 35 | controller controller.ControllerInterface 36 | failureChan chan model.ContainerMeta 37 | tokenChan chan string 38 | token string 39 | containers map[string]*model.ContainerInfo 40 | failureContainers map[string]*model.ContainerInfo 41 | } 42 | 43 | func NewAgentService(ctx context.Context, config *config.AppConfig, certBundle *model.CertificateBundle, token string) (*AgentService, error) { 44 | //构建Agent服务 45 | agentService := &AgentService{ 46 | config: config, 47 | httpClient: &http.Client{ 48 | Timeout: config.Health.Timeout, 49 | }, 50 | containers: make(map[string]*model.ContainerInfo), 51 | failureContainers: make(map[string]*model.ContainerInfo), 52 | failureChan: make(chan model.ContainerMeta, 10), 53 | tokenChan: make(chan string, 1), // 用于接收token更新 54 | token: token, 55 | } 56 | 57 | if certBundle != nil { 58 | cert, _ := tls.X509KeyPair(certBundle.CertPEM, certBundle.KeyPEM) 59 | 60 | config := &tls.Config{ 61 | Certificates: []tls.Certificate{cert}, 62 | RootCAs: certBundle.CertPool, 63 | ClientAuth: tls.NoClientCert, 64 | ClientCAs: certBundle.CertPool, 65 | } 66 | agentService.httpClient.Transport = &http.Transport{ 67 | TLSClientConfig: config, 68 | } 69 | } 70 | 71 | //构建Docker-client 72 | dockerClient, err := docker.BuildDockerClient(config.DockerConfig) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | //构建API和Controller接口 78 | appController := controller.NewController( 79 | dockerClient, 80 | agentService.sendConfigValuesRequest, 81 | config.VolumesConfig.RootDirectory, 82 | config.DockerTimeoutOpts.Request, 83 | agentService.failureChan, 84 | ) 85 | 86 | apiServer, err := api.NewAPIServer(appController, config.APIConfig, certBundle, token, agentService.tokenChan) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | agentService.apiServer = apiServer 92 | agentService.scheduler = schedule.NewJobScheduler(dockerClient) //构建任务定时调度器 93 | agentService.controller = appController 94 | 95 | //启动先加载本地所有容器 96 | if err = agentService.loadDockerContainers(ctx); err != nil { 97 | return nil, err 98 | } 99 | 100 | //启动服务API 101 | if err = apiServer.Startup(ctx); err != nil { 102 | return nil, err 103 | } 104 | 105 | //启动心跳 106 | go agentService.heartbeatLoop() 107 | 108 | go agentService.watchMetaChange() 109 | 110 | //启动docker事件监听 111 | go agentService.watchDockerEvents(ctx, dockerClient) 112 | //启动定时任务调度器 113 | agentService.scheduler.Start() 114 | //初始化一次所有定时容器, 加入调度器 115 | for _, container := range agentService.containers { 116 | if _, ret := container.Labels[schedule.HumpbackJobRulesLabel]; ret { //job定时容器, 交给定时调度器 117 | agentService.addToScheduler(container.ContainerId, container.ContainerName, container.Image, container.Labels) 118 | } 119 | } 120 | 121 | return agentService, nil 122 | } 123 | 124 | func (agentService *AgentService) Shutdown(ctx context.Context) { 125 | if agentService.apiServer != nil { 126 | if err := agentService.apiServer.Stop(ctx); err != nil { 127 | logrus.Errorf("Humpback Agent api server stop error, %s", err.Error()) 128 | } 129 | } 130 | //关闭定时任务调度器 131 | agentService.scheduler.Stop() 132 | } 133 | 134 | func (agentService *AgentService) loadDockerContainers(ctx context.Context) error { 135 | result := agentService.controller.Container().List(ctx, &v1model.QueryContainerRequest{All: true}) 136 | if result.Error != nil { 137 | return fmt.Errorf("load container list error") 138 | } 139 | 140 | containers := result.Object.([]types.Container) 141 | slog.Info("[loadDockerContainers] contianer len.", "Length", len(containers)) 142 | agentService.Lock() 143 | for _, container := range containers { 144 | result = agentService.controller.Container().Get(ctx, &v1model.GetContainerRequest{ContainerId: container.ID}) 145 | if result.Error != nil { 146 | return fmt.Errorf("load container inspect %s error, %v", container.ID, result.Error) 147 | } 148 | containerInfo := model.ParseContainerInfo(result.Object.(types.ContainerJSON)) 149 | agentService.containers[container.ID] = containerInfo 150 | slog.Info("[loadDockerContainers] add to cache.", "ContainerID", container.ID, "Name", containerInfo.ContainerName) 151 | } 152 | agentService.Unlock() 153 | return nil 154 | } 155 | 156 | func (agentService *AgentService) fetchContainer(ctx context.Context, containerId string) (*model.ContainerInfo, error) { 157 | result := agentService.controller.Container().Get(ctx, &v1model.GetContainerRequest{ContainerId: containerId}) 158 | if result.Error != nil { 159 | return nil, fmt.Errorf("get container %s error, %v", containerId, result.Error) 160 | } 161 | return model.ParseContainerInfo(result.Object.(types.ContainerJSON)), nil 162 | } 163 | 164 | func (agentService *AgentService) heartbeatLoop() { 165 | for { 166 | if err := agentService.sendHealthRequest(context.Background()); err != nil { 167 | logrus.Errorf("heartbeat health send request error: %+v", err.Error()) 168 | } else { 169 | logrus.Debugf("heartbeat health send request done at %s\n", time.Now().String()) 170 | } 171 | time.Sleep(agentService.config.Health.Interval) 172 | } 173 | } 174 | 175 | func (agentService *AgentService) watchDockerEvents(ctx context.Context, dockerClient *client.Client) { 176 | eventChan, errChan := dockerClient.Events(ctx, types.EventsOptions{}) 177 | for { 178 | select { 179 | case event := <-eventChan: 180 | agentService.handleDockerEvent(event) 181 | case err := <-errChan: 182 | logrus.Errorf("Watch docker event error, %v", err) 183 | time.Sleep(time.Second * 1) 184 | } 185 | } 186 | } 187 | 188 | func (agentService *AgentService) handleDockerEvent(message events.Message) { 189 | if message.Type == "container" { 190 | switch message.Action { 191 | case "create", "start", "stop", "die", "kill", "healthy", "unhealthy": 192 | containerInfo, err := agentService.fetchContainer(context.Background(), message.Actor.ID) 193 | if err != nil { 194 | logrus.Errorf("Docker create container %s event, %v", message.Actor.ID, err) 195 | } 196 | if containerInfo != nil { 197 | 198 | needReport := true 199 | 200 | if message.Action == "create" { 201 | if _, ret := containerInfo.Labels[schedule.HumpbackJobRulesLabel]; ret { //创建了一个job定时容器, 交给定时调度器 202 | agentService.addToScheduler(containerInfo.ContainerId, containerInfo.ContainerName, containerInfo.Image, containerInfo.Labels) 203 | //找到相同name的删除, 因为reCreate原因, 缓存先同步删除 204 | agentService.Lock() 205 | for containerId, container := range agentService.containers { 206 | if container.ContainerName == containerInfo.ContainerName { 207 | delete(agentService.containers, containerId) 208 | break 209 | } 210 | } 211 | agentService.Unlock() 212 | } else { 213 | // 非定时容器的create,不需要汇报心跳 214 | needReport = false 215 | } 216 | } 217 | 218 | agentService.Lock() 219 | old, ok := agentService.containers[containerInfo.ContainerId] 220 | if ok && old.State == containerInfo.State { 221 | needReport = false 222 | } 223 | agentService.containers[containerInfo.ContainerId] = containerInfo 224 | 225 | agentService.Unlock() 226 | 227 | if needReport { 228 | slog.Info("send heartbeat", "container", containerInfo.ContainerName, "action", message.Action, "status", containerInfo.State) 229 | agentService.sendHealthRequest(context.Background()) 230 | } 231 | } 232 | case "destroy", "remove", "delete": 233 | if message.Action == "destroy" { //从job定时调度器删除, 无论是否在调度器中, 会自动处理 234 | agentService.removeFromScheduler(message.Actor.ID) 235 | } 236 | state := "unknow" 237 | //修改容器状态 238 | agentService.Lock() 239 | if containerInfo, ret := agentService.containers[message.Actor.ID]; ret { 240 | containerInfo.State = model.ContainerStatusRemoved 241 | state = model.ContainerStatusRemoved 242 | } 243 | agentService.Unlock() 244 | //主动通知一次心跳 245 | slog.Info("send heartbeat", "container", message.Actor.ID, "action", message.Action, "status", state) 246 | agentService.sendHealthRequest(context.Background()) 247 | //缓存删除容器 248 | agentService.Lock() 249 | delete(agentService.containers, message.Actor.ID) 250 | agentService.Unlock() 251 | } 252 | } 253 | } 254 | 255 | func (agentService *AgentService) addToScheduler(containerId string, containerName string, containerImage string, containerLabels map[string]string) error { 256 | if value, ret := containerLabels[schedule.HumpbackJobRulesLabel]; ret && value != "Manual" { 257 | var ( 258 | err error 259 | timeout time.Duration 260 | alwaysPull bool 261 | authStr string 262 | ) 263 | rules := strings.Split(value, ";") 264 | if value, ret = containerLabels[schedule.HumpbackJobMaxTimeoutLabel]; ret && value != "" { 265 | if timeout, err = time.ParseDuration(value); err != nil { 266 | return err 267 | } 268 | } 269 | 270 | if value, ret = containerLabels[schedule.HumpbackJobAlwaysPullLabel]; ret && value != "" { 271 | if alwaysPull, err = strconv.ParseBool(value); err != nil { 272 | return err 273 | } 274 | } 275 | 276 | if value, ret = containerLabels[schedule.HumpbackJobImageAuth]; ret { 277 | authStr = value 278 | } 279 | 280 | return agentService.scheduler.AddContainer(containerId, containerName, containerImage, alwaysPull, rules, authStr, timeout) 281 | } 282 | return nil 283 | } 284 | 285 | func (agentService *AgentService) removeFromScheduler(containerId string) error { 286 | return agentService.scheduler.RemoveContainer(containerId) 287 | } 288 | 289 | func (agentService *AgentService) watchMetaChange() { 290 | for meta := range agentService.failureChan { 291 | agentService.Lock() 292 | 293 | meta.ContainerName = strings.TrimPrefix(meta.ContainerName, "/") 294 | 295 | slog.Info("receive failure contaier", "containername", meta.ContainerName, "error", meta.ErrorMsg, "state", meta.State) 296 | 297 | c, ok := agentService.failureContainers[meta.ContainerName] 298 | 299 | if !ok { 300 | if !meta.IsDelete { 301 | agentService.failureContainers[meta.ContainerName] = &model.ContainerInfo{ 302 | ContainerName: meta.ContainerName, 303 | State: meta.State, 304 | ErrorMsg: meta.ErrorMsg, 305 | } 306 | } 307 | } else { 308 | if meta.IsDelete { 309 | delete(agentService.failureContainers, meta.ContainerName) 310 | } else { 311 | c.State = meta.State 312 | c.ErrorMsg = meta.ErrorMsg 313 | } 314 | } 315 | 316 | agentService.Unlock() 317 | } 318 | } 319 | 320 | func (agentService *AgentService) sendHealthRequest(ctx context.Context) error { 321 | // 获取 Docker Engine 信息 322 | dockerEngineInfo, err := agentService.controller.DockerEngine(ctx) 323 | if err != nil { 324 | return err 325 | } 326 | 327 | //本地容器信息 328 | containers := []*model.ContainerInfo{} 329 | agentService.RLock() 330 | reportNames := make(map[string]string) 331 | for _, containerInfo := range agentService.containers { 332 | if fc, ok := agentService.failureContainers[containerInfo.ContainerName]; ok { 333 | containerInfo.State = fc.State 334 | containerInfo.ErrorMsg = fc.ErrorMsg 335 | } 336 | containers = append(containers, containerInfo) 337 | reportNames[containerInfo.ContainerName] = "" 338 | } 339 | 340 | for _, containerInfo := range agentService.failureContainers { 341 | if _, ok := reportNames[containerInfo.ContainerName]; !ok { 342 | slog.Info("report failure container", "containername", containerInfo.ContainerName, "state", containerInfo.State) 343 | containers = append(containers, containerInfo) 344 | } 345 | } 346 | 347 | agentService.RUnlock() 348 | 349 | payload := &model.HostHealthRequest{ 350 | HostInfo: model.GetHostInfo(agentService.config.APIConfig.HostIP, agentService.config.APIConfig.Port), 351 | DockerEngine: *dockerEngineInfo, 352 | Containers: containers, 353 | } 354 | 355 | // for _, containerInfo := range containers { 356 | // slog.Info("report container", "containername", containerInfo.ContainerName, "state", containerInfo.State, "error", containerInfo.ErrorMsg) 357 | // } 358 | 359 | token, err := reqclient.PostRequest(agentService.httpClient, fmt.Sprintf("https://%s/api/health", agentService.config.ServerConfig.Host), payload, agentService.token) 360 | if err == nil && token != "" { 361 | slog.Info("new token received") 362 | agentService.token = token 363 | agentService.tokenChan <- token // 更新token 364 | } 365 | return err 366 | } 367 | 368 | func (agentService *AgentService) sendConfigValuesRequest(configNames []string) (map[string][]byte, error) { 369 | configPair := map[string][]byte{} 370 | for _, configName := range configNames { 371 | data, err := reqclient.GetRequest(agentService.httpClient, fmt.Sprintf("https://%s/api/config/%s", agentService.config.ServerConfig.Host, configName), agentService.token) 372 | if err != nil { 373 | return nil, err 374 | } 375 | configPair[configName] = data 376 | } 377 | return configPair, nil 378 | } 379 | -------------------------------------------------------------------------------- /controller/container.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | "strings" 15 | 16 | v1model "humpback-agent/api/v1/model" 17 | "humpback-agent/internal/schedule" 18 | "humpback-agent/model" 19 | 20 | "github.com/docker/docker/api/types" 21 | "github.com/docker/docker/api/types/container" 22 | "github.com/docker/docker/api/types/filters" 23 | "github.com/docker/docker/api/types/mount" 24 | "github.com/docker/docker/api/types/network" 25 | "github.com/docker/docker/client" 26 | "github.com/docker/docker/errdefs" 27 | "github.com/docker/go-connections/nat" 28 | ) 29 | 30 | type ContainerControllerInterface interface { 31 | BaseController() ControllerInterface 32 | Get(ctx context.Context, request *v1model.GetContainerRequest) *v1model.ObjectResult 33 | List(ctx context.Context, request *v1model.QueryContainerRequest) *v1model.ObjectResult 34 | Create(ctx context.Context, request *v1model.CreateContainerRequest) *v1model.ObjectResult 35 | Update(ctx context.Context, request *v1model.UpdateContainerRequest) *v1model.ObjectResult 36 | Delete(ctx context.Context, request *v1model.DeleteContainerRequest) *v1model.ObjectResult 37 | Start(ctx context.Context, request *v1model.StartContainerRequest) *v1model.ObjectResult 38 | Restart(ctx context.Context, request *v1model.RestartContainerRequest) *v1model.ObjectResult 39 | Stop(ctx context.Context, request *v1model.StopContainerRequest) *v1model.ObjectResult 40 | Logs(ctx context.Context, request *v1model.GetContainerLogsRequest) *v1model.ObjectResult 41 | Stats(ctx context.Context, request *v1model.GetContainerStatsRequest) *v1model.ObjectResult 42 | } 43 | 44 | type ContainerController struct { 45 | baseController ControllerInterface 46 | client *client.Client 47 | } 48 | 49 | func NewContainerController(baseController ControllerInterface, client *client.Client) ContainerControllerInterface { 50 | return &ContainerController{ 51 | baseController: baseController, 52 | client: client, 53 | } 54 | } 55 | 56 | func (controller *ContainerController) BaseController() ControllerInterface { 57 | return controller.baseController 58 | } 59 | 60 | func (controller *ContainerController) Get(ctx context.Context, request *v1model.GetContainerRequest) *v1model.ObjectResult { 61 | var containerBody types.ContainerJSON 62 | err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 63 | var err error 64 | containerBody, err = controller.client.ContainerInspect(ctx, request.ContainerId) 65 | return err 66 | }) 67 | 68 | if err != nil { 69 | if errdefs.IsNotFound(err) { 70 | return v1model.ObjectNotFoundErrorResult(v1model.ContainerNotFoundCode, err.Error()) 71 | } 72 | return v1model.ObjectInternalErrorResult(v1model.ContainerGetErrorCode, err.Error()) 73 | } 74 | return v1model.ResultWithObject(containerBody) 75 | } 76 | 77 | func (controller *ContainerController) List(ctx context.Context, request *v1model.QueryContainerRequest) *v1model.ObjectResult { 78 | var containers []types.Container 79 | err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 80 | filterArgs := filters.NewArgs() 81 | for key, value := range request.Filters { 82 | filterArgs.Add(key, value) 83 | } 84 | var queryErr error 85 | containers, queryErr = controller.client.ContainerList(ctx, container.ListOptions{ 86 | All: request.All, // 是否包括已停止的容器 87 | Size: request.Size, 88 | Latest: request.Latest, 89 | Since: request.Since, 90 | Before: request.Before, 91 | Limit: request.Limit, 92 | Filters: filterArgs, 93 | }) 94 | return queryErr 95 | }) 96 | 97 | if err != nil { 98 | return v1model.ObjectInternalErrorResult(v1model.ServerInternalErrorCode, v1model.ServerInternalErrorMsg) 99 | } 100 | return v1model.ResultWithObject(containers) 101 | } 102 | 103 | func (controller *ContainerController) Create(ctx context.Context, request *v1model.CreateContainerRequest) *v1model.ObjectResult { 104 | result := controller.createInternal(ctx, request) 105 | 106 | if result.Error != nil { 107 | containerMeta := model.ContainerMeta{ 108 | ContainerName: request.ContainerName, 109 | State: model.ContainerStatusFailed, 110 | ErrorMsg: result.Error.ErrMsg, 111 | } 112 | controller.BaseController().FailureChan() <- containerMeta 113 | } 114 | 115 | return result 116 | } 117 | 118 | func (controller *ContainerController) createInternal(ctx context.Context, request *v1model.CreateContainerRequest) *v1model.ObjectResult { 119 | 120 | value, _ := json.MarshalIndent(request, "", " ") 121 | fmt.Printf("%s\n", value) 122 | 123 | image := filepath.Join(request.RegistryDomain, request.Image) 124 | //先尝试处理镜像 125 | if pullResult := controller.BaseController().Image().AttemptPull(context.Background(), image, request.AlwaysPull, request.RegistryAuth); pullResult.Error != nil { 126 | return v1model.ObjectInternalErrorResult(v1model.ImagePullErrorCode, pullResult.Error.ErrMsg) 127 | } 128 | 129 | isJob := false 130 | 131 | if request.Labels == nil { 132 | request.Labels = make(map[string]string) 133 | } 134 | 135 | request.Labels[v1model.ContainerLabelServiceId] = request.ServiceId 136 | request.Labels[v1model.ContainerLabelGroupId] = request.GroupId 137 | request.Labels[v1model.ContainerLabelServiceName] = request.ServiceName 138 | 139 | if request.ScheduleInfo != nil && (len(request.ScheduleInfo.Rules) > 0 || request.ManualExec) { 140 | isJob = true 141 | var jobRules string 142 | if len(request.ScheduleInfo.Rules) > 0 { 143 | jobRules = strings.Join(request.ScheduleInfo.Rules, ";") 144 | } else { 145 | jobRules = "Manual" 146 | } 147 | 148 | request.Labels[schedule.HumpbackJobRulesLabel] = jobRules 149 | request.Labels[schedule.HumpbackJobAlwaysPullLabel] = strconv.FormatBool(request.AlwaysPull) 150 | request.Labels[schedule.HumpbackJobMaxTimeoutLabel] = request.ScheduleInfo.Timeout 151 | 152 | if request.RegistryAuth.RegistryUsername != "" && request.RegistryAuth.RegistryPassword != "" { 153 | combined := strings.Join([]string{request.RegistryAuth.RegistryUsername, request.RegistryAuth.RegistryPassword, request.RegistryAuth.ServerAddress}, "^^") 154 | encoded := base64.StdEncoding.EncodeToString([]byte(combined)) 155 | request.Labels[schedule.HumpbackJobImageAuth] = encoded 156 | } 157 | } 158 | 159 | containerConfig := &container.Config{ 160 | Image: image, 161 | Env: request.Envs, 162 | Labels: request.Labels, 163 | } 164 | 165 | if request.Command != "" { 166 | containerConfig.Cmd = strings.Fields(request.Command) 167 | } 168 | 169 | hostConfig := &container.HostConfig{ 170 | Privileged: request.Privileged, 171 | } 172 | 173 | if request.Capabilities != nil { 174 | capAdd := request.Capabilities.CapAdd 175 | if len(capAdd) > 0 { 176 | hostConfig.CapAdd = capAdd 177 | } 178 | 179 | capDrop := request.Capabilities.CapDrop 180 | if len(capDrop) > 0 { 181 | hostConfig.CapDrop = capDrop 182 | } 183 | } 184 | 185 | if request.LogConfig != nil { 186 | hostConfig.LogConfig = container.LogConfig{ 187 | Type: request.LogConfig.Type, 188 | Config: request.LogConfig.Config, 189 | } 190 | } 191 | 192 | if request.Resources != nil { 193 | hostConfig.Resources = container.Resources{} 194 | if request.Resources.Memory > 0 { 195 | mLimit := int64(request.Resources.Memory * 1024 * 1024) 196 | if mLimit < 6*1024*1024 { 197 | mLimit = 6 * 1024 * 1024 198 | } 199 | hostConfig.Resources.Memory = mLimit 200 | } 201 | if request.Resources.MemoryReservation > 0 { 202 | hostConfig.Resources.MemoryReservation = int64(request.Resources.MemoryReservation * 1024 * 1024) 203 | } 204 | if hostConfig.Resources.Memory < hostConfig.Resources.MemoryReservation && hostConfig.Resources.Memory != 0 { 205 | hostConfig.Resources.Memory = hostConfig.Resources.MemoryReservation 206 | } 207 | if request.Resources.MaxCpuUsage > 0 { 208 | cpuLimit := int64(request.Resources.MaxCpuUsage * 1000000000 / 100) 209 | hostConfig.Resources.NanoCPUs = cpuLimit 210 | } 211 | } 212 | 213 | if request.RestartPolicy != nil { 214 | restartPolicyModeName := request.RestartPolicy.Mode 215 | maxRetryCount := request.RestartPolicy.MaxRetryCount 216 | if isJob { //定时任务强制设置为No 217 | restartPolicyModeName = v1model.RestartPolicyModeNo 218 | maxRetryCount = 0 219 | } 220 | hostConfig.RestartPolicy = container.RestartPolicy{ 221 | Name: container.RestartPolicyMode(restartPolicyModeName), 222 | } 223 | 224 | if restartPolicyModeName == v1model.RestartPolicyModeOnFail { 225 | hostConfig.RestartPolicy.MaximumRetryCount = maxRetryCount 226 | } 227 | } 228 | 229 | var networkConfig *network.NetworkingConfig 230 | if request.Network != nil { 231 | hostname := request.Network.Hostname 232 | if request.Network.UseMachineHostname { 233 | hostname, _ = os.Hostname() 234 | } 235 | if request.Network.Mode == v1model.NetworkModeCustom { //构建自定义网络 236 | containerConfig.Hostname = hostname 237 | if request.Network.NetworkName != "" { 238 | networkResult := controller.BaseController().Network().Create(ctx, &v1model.CreateNetworkRequest{NetworkName: request.Network.NetworkName, Driver: "bridge", Scope: "local"}) 239 | if networkResult.Error != nil { 240 | return networkResult 241 | } 242 | hostConfig.NetworkMode = container.NetworkMode(request.Network.NetworkName) 243 | networkConfig = &network.NetworkingConfig{ 244 | EndpointsConfig: map[string]*network.EndpointSettings{ 245 | request.Network.NetworkName: { 246 | NetworkID: networkResult.ObjectId, 247 | }, 248 | }, 249 | } 250 | } 251 | } else if request.Network.Mode == v1model.NetworkModeHost { 252 | hostConfig.NetworkMode = container.NetworkMode(request.Network.Mode) 253 | hostConfig.PublishAllPorts = true 254 | } else if request.Network.Mode == v1model.NetworkModeBridge { // 桥接, 配置 PortBindings 255 | hostConfig.NetworkMode = container.NetworkMode(request.Network.Mode) 256 | containerConfig.Hostname = hostname 257 | networkConfig = &network.NetworkingConfig{ 258 | EndpointsConfig: map[string]*network.EndpointSettings{ 259 | "bridge": { 260 | NetworkID: "bridge", 261 | }, 262 | }, 263 | } 264 | } 265 | 266 | portBindings := nat.PortMap{} 267 | if len(request.Network.Ports) > 0 { 268 | containerConfig.ExposedPorts = make(nat.PortSet) 269 | for _, bindPort := range request.Network.Ports { 270 | proto := strings.ToLower(bindPort.Protocol) 271 | if proto != "tcp" && proto != "udp" { 272 | proto = "tcp" // 默认使用 TCP 273 | } 274 | port, err := nat.NewPort(proto, strconv.Itoa(int(bindPort.ContainerPort))) 275 | if err != nil { 276 | return v1model.ObjectInternalErrorResult(v1model.ContainerCreateErrorCode, err.Error()) 277 | } 278 | hostPort := int(bindPort.HostPort) 279 | if hostPort == 0 { 280 | if hostPort, err = controller.BaseController().AllocPort(proto); err != nil { 281 | return v1model.ObjectInternalErrorResult(v1model.ContainerCreateErrorCode, err.Error()) 282 | } 283 | } 284 | containerConfig.ExposedPorts[port] = struct{}{} 285 | portBindings[port] = []nat.PortBinding{{HostPort: strconv.Itoa(hostPort)}} 286 | } 287 | hostConfig.PortBindings = portBindings 288 | } else { 289 | hostConfig.PublishAllPorts = true //若请求中没设置端口, 则自动暴露镜像Dockerfile中的所有端口 290 | } 291 | } 292 | 293 | //处理卷配置绑定 294 | if err := controller.buildHostConfigVolumesWithRequest(request.Volumes, hostConfig); err != nil { 295 | return v1model.ObjectInternalErrorResult(v1model.ContainerCreateErrorCode, err.Error()) 296 | } 297 | 298 | var containerInfo container.CreateResponse 299 | err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 300 | var createdErr error 301 | containerInfo, createdErr = controller.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, request.ContainerName) 302 | if createdErr != nil { 303 | return createdErr 304 | } 305 | if !isJob { //Job容器创建后自动启动 306 | return controller.client.ContainerStart(ctx, containerInfo.ID, container.StartOptions{}) 307 | } 308 | return nil 309 | }) 310 | 311 | if err != nil { 312 | return v1model.ObjectInternalErrorResult(v1model.ContainerCreateErrorCode, err.Error()) 313 | } 314 | return v1model.ResultWithObjectId(containerInfo.ID) 315 | } 316 | 317 | func (controller *ContainerController) Update(ctx context.Context, request *v1model.UpdateContainerRequest) *v1model.ObjectResult { 318 | return nil 319 | } 320 | 321 | func (controller *ContainerController) Delete(ctx context.Context, request *v1model.DeleteContainerRequest) *v1model.ObjectResult { 322 | var containerId string 323 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 324 | containerBody, inspectErr := controller.client.ContainerInspect(ctx, request.ContainerId) 325 | if inspectErr != nil { 326 | return inspectErr 327 | } 328 | containerId = containerBody.ID 329 | return controller.client.ContainerRemove(ctx, request.ContainerId, container.RemoveOptions{Force: request.Force}) 330 | }); err != nil { 331 | return v1model.ObjectInternalErrorResult(v1model.ContainerDeleteErrorCode, err.Error()) 332 | } 333 | return v1model.ResultWithObjectId(containerId) 334 | } 335 | 336 | func (controller *ContainerController) Restart(ctx context.Context, request *v1model.RestartContainerRequest) *v1model.ObjectResult { 337 | var containerId string 338 | var containerName string 339 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 340 | containerBody, inspectErr := controller.client.ContainerInspect(ctx, request.ContainerId) 341 | if inspectErr != nil { 342 | return inspectErr 343 | } 344 | containerId = containerBody.ID 345 | containerName = containerBody.Name 346 | return controller.client.ContainerRestart(ctx, request.ContainerId, container.StopOptions{}) 347 | }); err != nil { 348 | containerMeta := model.ContainerMeta{ 349 | ContainerName: containerName, 350 | State: model.ContainerStatusFailed, 351 | ErrorMsg: err.Error(), 352 | } 353 | controller.BaseController().FailureChan() <- containerMeta 354 | return v1model.ObjectInternalErrorResult(v1model.ContainerDeleteErrorCode, err.Error()) 355 | } 356 | return v1model.ResultWithObjectId(containerId) 357 | } 358 | 359 | func (controller *ContainerController) Start(ctx context.Context, request *v1model.StartContainerRequest) *v1model.ObjectResult { 360 | var containerId string 361 | var containerName string 362 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 363 | containerBody, inspectErr := controller.client.ContainerInspect(ctx, request.ContainerId) 364 | if inspectErr != nil { 365 | return inspectErr 366 | } 367 | containerId = containerBody.ID 368 | containerName = containerBody.Name 369 | return controller.client.ContainerStart(ctx, request.ContainerId, container.StartOptions{}) 370 | }); err != nil { 371 | containerMeta := model.ContainerMeta{ 372 | ContainerName: containerName, 373 | State: model.ContainerStatusFailed, 374 | ErrorMsg: err.Error(), 375 | } 376 | controller.BaseController().FailureChan() <- containerMeta 377 | return v1model.ObjectInternalErrorResult(v1model.ContainerDeleteErrorCode, err.Error()) 378 | } 379 | return v1model.ResultWithObjectId(containerId) 380 | } 381 | 382 | func (controller *ContainerController) Stop(ctx context.Context, request *v1model.StopContainerRequest) *v1model.ObjectResult { 383 | var containerId string 384 | var containerName string 385 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 386 | containerBody, inspectErr := controller.client.ContainerInspect(ctx, request.ContainerId) 387 | if inspectErr != nil { 388 | return inspectErr 389 | } 390 | containerId = containerBody.ID 391 | containerName = containerBody.Name 392 | return controller.client.ContainerStop(ctx, request.ContainerId, container.StopOptions{}) 393 | }); err != nil { 394 | containerMeta := model.ContainerMeta{ 395 | ContainerName: containerName, 396 | State: model.ContainerStatusFailed, 397 | ErrorMsg: err.Error(), 398 | } 399 | controller.BaseController().FailureChan() <- containerMeta 400 | return v1model.ObjectInternalErrorResult(v1model.ContainerDeleteErrorCode, err.Error()) 401 | } 402 | return v1model.ResultWithObjectId(containerId) 403 | } 404 | 405 | func (controller *ContainerController) Logs(ctx context.Context, request *v1model.GetContainerLogsRequest) *v1model.ObjectResult { 406 | options := container.LogsOptions{ 407 | ShowStdout: true, // 显示标准输出 408 | ShowStderr: true, // 显示标准错误 409 | } 410 | 411 | if request.Follow != nil { 412 | options.Follow = *request.Follow 413 | } 414 | 415 | if request.Tail != nil { 416 | options.Tail = *request.Tail 417 | } 418 | 419 | if request.Since != nil { 420 | options.Since = *request.Since 421 | } 422 | 423 | if request.Until != nil { 424 | options.Until = *request.Until 425 | } 426 | 427 | if request.Timestamps != nil { 428 | options.Timestamps = *request.Timestamps 429 | } 430 | 431 | if request.Details != nil { 432 | options.Details = *request.Details 433 | } 434 | 435 | var ( 436 | logs = make([]string, 0) 437 | line = 0 438 | ) 439 | 440 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 441 | //获取日志流 442 | logReader, logsErr := controller.client.ContainerLogs(ctx, request.ContainerId, options) 443 | if logsErr != nil { 444 | return logsErr 445 | } 446 | 447 | defer logReader.Close() 448 | hdr := make([]byte, 8) 449 | for { 450 | _, readErr := logReader.Read(hdr) 451 | if readErr != nil { 452 | if readErr == io.EOF { 453 | return nil 454 | } 455 | return readErr 456 | } 457 | 458 | count := binary.BigEndian.Uint32(hdr[4:]) 459 | dat := make([]byte, count) 460 | _, readErr = logReader.Read(dat) 461 | if readErr != nil && readErr != io.EOF { 462 | return readErr 463 | } 464 | if line > 10000 { 465 | return errors.New("The maximum limit of 10,000 rows is exceeded.") 466 | } 467 | 468 | logs = append(logs, string(dat)) 469 | line++ 470 | } 471 | }); err != nil { 472 | return v1model.ObjectInternalErrorResult(v1model.ContainerLogsErrorCode, err.Error()) 473 | } 474 | return v1model.ResultWithObject(logs) 475 | } 476 | 477 | func (controller *ContainerController) Stats(ctx context.Context, request *v1model.GetContainerStatsRequest) *v1model.ObjectResult { 478 | containerStats := container.StatsResponse{} 479 | if err := controller.baseController.WithTimeout(ctx, func(ctx context.Context) error { 480 | statsReader, statsErr := controller.client.ContainerStats(ctx, request.ContainerId, true) 481 | if statsErr != nil { 482 | return statsErr 483 | } 484 | defer statsReader.Body.Close() 485 | return json.NewDecoder(statsReader.Body).Decode(&containerStats) 486 | }); err != nil { 487 | return v1model.ObjectInternalErrorResult(v1model.ContainerStatsErrorCode, err.Error()) 488 | } 489 | return v1model.ResultWithObject(model.ParseContainerStats(&containerStats)) 490 | } 491 | 492 | func (controller *ContainerController) buildHostConfigVolumesWithRequest(reqVolumes []*v1model.ServiceVolume, hostConfig *container.HostConfig) error { 493 | configNames := controller.BaseController().GetConfigNamesWithVolumes(reqVolumes) 494 | configPaths, err := controller.BaseController().BuildVolumesWithConfigNames(configNames) 495 | if err != nil { 496 | return err 497 | } 498 | 499 | var mounts []mount.Mount 500 | for _, volume := range reqVolumes { 501 | if volume.Type == v1model.ServiceVolumeTypeBind { 502 | matches := re.FindStringSubmatch(volume.Source) 503 | if len(matches) > 1 { 504 | path, ret := configPaths[matches[1]] 505 | if !ret { 506 | return fmt.Errorf("invalid %s volume path: %s", volume.Type, volume.Source) 507 | } 508 | volume.Source = path 509 | } 510 | 511 | // 将配置转换为 mount.Mount 512 | mounts = append(mounts, mount.Mount{ 513 | Type: mount.Type(volume.Type), 514 | Source: volume.Source, 515 | Target: volume.Target, 516 | ReadOnly: volume.Readonly, 517 | }) 518 | } 519 | } 520 | hostConfig.Mounts = mounts 521 | return nil 522 | } 523 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= 2 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= 4 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 5 | github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= 6 | github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= 7 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 8 | github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= 9 | github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 10 | github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= 11 | github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 12 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 13 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 14 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 15 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 16 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 17 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 18 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 19 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 24 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 25 | github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= 26 | github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 27 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 28 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 29 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 30 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 31 | github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE= 32 | github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 33 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 34 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 35 | github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= 36 | github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= 37 | github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns= 38 | github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4= 39 | github.com/gin-contrib/pprof v1.5.2 h1:Kcq5W2bA2PBcVtF0MqkQjpvCpwJr+pd7zxcQh2csg7E= 40 | github.com/gin-contrib/pprof v1.5.2/go.mod h1:a1W4CDXwAPm2zql2AKdnT7OVCJdV/oFPhJXVOrDs5Ns= 41 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 42 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 43 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 44 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 45 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 46 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 47 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 48 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 49 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 50 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 51 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 52 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 53 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 54 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 55 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 56 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 57 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 58 | github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= 59 | github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 60 | github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= 61 | github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 62 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 63 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 64 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 65 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 66 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 67 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 68 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 69 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 70 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= 71 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= 72 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 73 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 74 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 75 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 76 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 77 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 78 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 79 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 80 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 81 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 82 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 83 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 84 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 85 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 86 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 87 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 88 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 89 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 90 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 91 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 92 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 93 | github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 94 | github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 95 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 96 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 97 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 98 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 99 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 100 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 101 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 102 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 103 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 104 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 105 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 106 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 107 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 108 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 109 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 110 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 111 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 112 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 113 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 114 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 115 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 116 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 117 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 118 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 119 | github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4= 120 | github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o= 121 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 122 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 123 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 124 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 125 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 126 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 127 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 128 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 129 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 130 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 131 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 132 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 133 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 134 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 135 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 136 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 137 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 138 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 139 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 140 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 141 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 142 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 143 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 144 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 145 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 146 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 147 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 148 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 149 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 150 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= 151 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= 152 | go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= 153 | go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= 154 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 155 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 156 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= 157 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= 158 | go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= 159 | go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= 160 | go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= 161 | go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= 162 | go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= 163 | go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= 164 | go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= 165 | go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= 166 | golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= 167 | golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 168 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 169 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 170 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 171 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 172 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 173 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 174 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 175 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 176 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 177 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 178 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 179 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 180 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 181 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 182 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 184 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 185 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 186 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 187 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 188 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 189 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 190 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 191 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 193 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 196 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 197 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 198 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 199 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 200 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 201 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 202 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 203 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 204 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 205 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 206 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 207 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 208 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 209 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 210 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 211 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= 212 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= 213 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= 214 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= 215 | google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= 216 | google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= 217 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 218 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 219 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 221 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 222 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 223 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 224 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 225 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 226 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 227 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 228 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 229 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 230 | --------------------------------------------------------------------------------