api: implement several docker apis

This commit is contained in:
yusing 2025-02-20 18:03:54 +08:00
parent 2b51c47846
commit e22366e524
8 changed files with 340 additions and 2 deletions

View file

@ -8,6 +8,7 @@ import (
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/auth"
"github.com/yusing/go-proxy/internal/api/v1/certapi"
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
"github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types"
@ -88,6 +89,9 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
mux.HandleFunc("GET", "/v1/metrics/uptime", uptime.Poller.ServeHTTP, true)
mux.HandleFunc("GET", "/v1/cert/info", certapi.GetCertInfo, true)
mux.HandleFunc("", "/v1/cert/renew", certapi.RenewCert, true)
mux.HandleFunc("GET", "/v1/docker/info", dockerapi.Info, true)
mux.HandleFunc("GET", "/v1/docker/logs/{server}/{container}", dockerapi.Logs, true)
mux.HandleFunc("GET", "/v1/docker/containers", dockerapi.Containers, true)
if common.PrometheusEnabled {
mux.Handle("GET /v1/metrics", promhttp.Handler())

View file

@ -0,0 +1,5 @@
package dockerapi
import "time"
const reqTimeout = 10 * time.Second

View file

@ -0,0 +1,79 @@
package dockerapi
import (
"context"
"net/http"
"sort"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
)
type Container struct {
Server string `json:"server"`
Name string `json:"name"`
ID string `json:"id"`
Image string `json:"image"`
State string `json:"state"`
}
func Containers(w http.ResponseWriter, r *http.Request) {
if httpheaders.IsWebsocket(r.Header) {
gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error {
containers, err := listContainers(r.Context())
if err != nil {
return err
}
return wsjson.Write(r.Context(), conn, containers)
})
} else {
containers, err := listContainers(r.Context())
handleResult(w, err, containers)
}
}
func listContainers(ctx context.Context) ([]Container, error) {
ctx, cancel := context.WithTimeout(ctx, reqTimeout)
defer cancel()
dockerClients, err := getDockerClients()
if err != nil {
return nil, err
}
defer closeAllClients(dockerClients)
errs := gperr.NewBuilder("failed to get containers")
containers := make([]Container, 0)
for server, dockerClient := range dockerClients {
conts, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
errs.Add(err)
continue
}
for _, cont := range conts {
containers = append(containers, Container{
Server: server,
Name: cont.Names[0],
ID: cont.ID,
Image: cont.Image,
State: cont.State,
})
}
}
sort.Slice(containers, func(i, j int) bool {
return containers[i].Name < containers[j].Name
})
if err := errs.Error(); err != nil {
gperr.LogError("failed to get containers", err)
if len(containers) == 0 {
return nil, err
}
return containers, nil
}
return containers, nil
}

View file

@ -0,0 +1,57 @@
package dockerapi
import (
"context"
"encoding/json"
"net/http"
dockerSystem "github.com/docker/docker/api/types/system"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type DockerInfo dockerSystem.Info
func (d *DockerInfo) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"host": d.Name,
"containers": map[string]int{
"total": d.Containers,
"running": d.ContainersRunning,
"paused": d.ContainersPaused,
"stopped": d.ContainersStopped,
},
"images": d.Images,
"n_cpu": d.NCPU,
"memory": strutils.FormatByteSizeWithUnit(d.MemTotal),
"version": d.ServerVersion,
})
}
func Info(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), reqTimeout)
defer cancel()
dockerClients, ok := getDockerClientsWithErrHandling(w)
if !ok {
return
}
defer closeAllClients(dockerClients)
errs := gperr.NewBuilder("failed to get docker info")
dockerInfos := make([]DockerInfo, len(dockerClients))
i := 0
for name, dockerClient := range dockerClients {
info, err := dockerClient.Info(ctx)
if err != nil {
errs.Add(err)
continue
}
info.Name = name
dockerInfos[i] = DockerInfo(info)
i++
}
handleResult(w, errs.Error(), dockerInfos)
}

View file

@ -0,0 +1,60 @@
package dockerapi
import (
"net/http"
"github.com/coder/websocket"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func Logs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
server := r.PathValue("server")
containerID := r.PathValue("container")
stdout := strutils.ParseBool(query.Get("stdout"))
stderr := strutils.ParseBool(query.Get("stderr"))
since := query.Get("from")
until := query.Get("to")
levels := query.Get("levels") // TODO: implement levels
dockerClient, found, err := getDockerClient(w, server)
if err != nil {
gphttp.BadRequest(w, err.Error())
return
}
if !found {
gphttp.NotFound(w, "server not found")
return
}
opts := container.LogsOptions{
ShowStdout: stdout,
ShowStderr: stderr,
Since: since,
Until: until,
Timestamps: true,
Follow: true,
Tail: "100",
}
if levels != "" {
opts.Details = true
}
logs, err := dockerClient.ContainerLogs(r.Context(), containerID, opts)
if err != nil {
gphttp.BadRequest(w, err.Error())
return
}
defer logs.Close()
conn, err := gpwebsocket.Initiate(w, r)
if err != nil {
return
}
writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.MessageText)
stdcopy.StdCopy(writer, writer, logs) //de-multiplex logs
}

View file

@ -0,0 +1,104 @@
package dockerapi
import (
"encoding/json"
"net/http"
config "github.com/yusing/go-proxy/internal/config/types"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/gperr"
)
// getDockerClients returns a map of docker clients for the current config.
//
// Returns a map of docker clients by server name and an error if any.
//
// Even if there are errors, the map of docker clients might not be empty.
func getDockerClients() (map[string]*docker.SharedClient, gperr.Error) {
cfg := config.GetInstance()
dockerHosts := cfg.Value().Providers.Docker
dockerClients := make(map[string]*docker.SharedClient)
connErrs := gperr.NewBuilder("failed to connect to docker")
for name, host := range dockerHosts {
dockerClient, err := docker.ConnectClient(host)
if err != nil {
connErrs.Add(err)
continue
}
dockerClients[name] = dockerClient
}
for _, agent := range cfg.ListAgents() {
dockerClient, err := docker.ConnectClient(agent.FakeDockerHost())
if err != nil {
connErrs.Add(err)
continue
}
dockerClients[agent.Name()] = dockerClient
}
return dockerClients, connErrs.Error()
}
// getDockerClientsWithErrHandling returns a map of docker clients for the current config.
//
// Returns a map of docker clients by server name and a boolean indicating if http handler should stop/
func getDockerClientsWithErrHandling(w http.ResponseWriter) (map[string]*docker.SharedClient, bool) {
dockerClients, err := getDockerClients()
if err != nil {
gperr.LogError("failed to get docker clients", err)
if len(dockerClients) == 0 {
http.Error(w, "no docker hosts connected successfully", http.StatusInternalServerError)
return nil, false
}
}
return dockerClients, true
}
func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient, bool, error) {
cfg := config.GetInstance()
var host string
for name, h := range cfg.Value().Providers.Docker {
if name == server {
host = h
break
}
}
for _, agent := range cfg.ListAgents() {
if agent.Name() == server {
host = agent.FakeDockerHost()
break
}
}
if host == "" {
return nil, false, nil
}
dockerClient, err := docker.ConnectClient(host)
if err != nil {
return nil, false, err
}
return dockerClient, true, nil
}
// closeAllClients closes all docker clients after a delay.
//
// This is used to ensure that all docker clients are closed after the http handler returns.
func closeAllClients(dockerClients map[string]*docker.SharedClient) {
for _, dockerClient := range dockerClients {
dockerClient.Close()
}
}
func handleResult[T any](w http.ResponseWriter, errs error, result []T) {
if errs != nil {
gperr.LogError("docker errors", errs)
if len(result) == 0 {
http.Error(w, "docker errors", http.StatusInternalServerError)
return
}
}
json.NewEncoder(w).Encode(result)
}

View file

@ -0,0 +1,29 @@
package gpwebsocket
import (
"context"
"github.com/coder/websocket"
)
type Writer struct {
conn *websocket.Conn
msgType websocket.MessageType
ctx context.Context
}
func NewWriter(ctx context.Context, conn *websocket.Conn, msgType websocket.MessageType) *Writer {
return &Writer{
ctx: ctx,
conn: conn,
msgType: msgType,
}
}
func (w *Writer) Write(p []byte) (int, error) {
return len(p), w.conn.Write(w.ctx, w.msgType, p)
}
func (w *Writer) Close() error {
return w.conn.CloseNow()
}

View file

@ -74,7 +74,7 @@ func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func FormatByteSize[T ~uint64 | ~float64](size T) (value, unit string) {
func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) {
const (
_ = (1 << (10 * iota))
kb
@ -99,7 +99,7 @@ func FormatByteSize[T ~uint64 | ~float64](size T) (value, unit string) {
}
}
func FormatByteSizeWithUnit[T ~uint64 | ~float64](size T) string {
func FormatByteSizeWithUnit[T ~int64 | ~uint64 | ~float64](size T) string {
value, unit := FormatByteSize(size)
return value + " " + unit
}