diff --git a/internal/api/v1/dockerapi/common.go b/internal/api/v1/dockerapi/common.go new file mode 100644 index 0000000..9beb128 --- /dev/null +++ b/internal/api/v1/dockerapi/common.go @@ -0,0 +1,5 @@ +package dockerapi + +import "time" + +const reqTimeout = 10 * time.Second diff --git a/internal/api/v1/dockerapi/containers.go b/internal/api/v1/dockerapi/containers.go new file mode 100644 index 0000000..eabac6d --- /dev/null +++ b/internal/api/v1/dockerapi/containers.go @@ -0,0 +1,54 @@ +package dockerapi + +import ( + "context" + "net/http" + "sort" + + "github.com/docker/docker/api/types/container" + "github.com/yusing/go-proxy/internal/gperr" +) + +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) { + serveHTTP[Container, []Container](w, r, GetContainers) +} + +func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Container, gperr.Error) { + 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 +} diff --git a/internal/api/v1/dockerapi/info.go b/internal/api/v1/dockerapi/info.go new file mode 100644 index 0000000..373895d --- /dev/null +++ b/internal/api/v1/dockerapi/info.go @@ -0,0 +1,56 @@ +package dockerapi + +import ( + "context" + "encoding/json" + "net/http" + "sort" + + 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{ + "name": d.Name, + "version": d.ServerVersion, + "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), + }) +} + +func DockerInfo(w http.ResponseWriter, r *http.Request) { + serveHTTP[dockerInfo](w, r, GetDockerInfo) +} + +func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerInfo, gperr.Error) { + 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++ + } + + sort.Slice(dockerInfos, func(i, j int) bool { + return dockerInfos[i].Name < dockerInfos[j].Name + }) + return dockerInfos, errs.Error() +} diff --git a/internal/api/v1/dockerapi/logs.go b/internal/api/v1/dockerapi/logs.go new file mode 100644 index 0000000..0bec4c6 --- /dev/null +++ b/internal/api/v1/dockerapi/logs.go @@ -0,0 +1,69 @@ +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/logging" + "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 + } + defer conn.CloseNow() + + writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.MessageText) + _, err = stdcopy.StdCopy(writer, writer, logs) // de-multiplex logs + if err != nil { + logging.Err(err). + Str("server", server). + Str("container", containerID). + Msg("failed to de-multiplex logs") + } +} diff --git a/internal/api/v1/dockerapi/utils.go b/internal/api/v1/dockerapi/utils.go new file mode 100644 index 0000000..2345b00 --- /dev/null +++ b/internal/api/v1/dockerapi/utils.go @@ -0,0 +1,124 @@ +package dockerapi + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/docker" + "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 ( + DockerClients map[string]*docker.SharedClient + ResultType[T any] interface { + map[string]T | []T + } +) + +// 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() (DockerClients, gperr.Error) { + cfg := config.GetInstance() + + dockerHosts := cfg.Value().Providers.Docker + dockerClients := make(DockerClients) + + connErrs := gperr.NewBuilder("failed to connect to docker") + + for name, host := range dockerHosts { + dockerClient, err := docker.NewClient(host) + if err != nil { + connErrs.Add(err) + continue + } + dockerClients[name] = dockerClient + } + + for _, agent := range cfg.ListAgents() { + dockerClient, err := docker.NewClient(agent.FakeDockerHost()) + if err != nil { + connErrs.Add(err) + continue + } + dockerClients[agent.Name()] = dockerClient + } + + return dockerClients, connErrs.Error() +} + +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.NewClient(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 DockerClients) { + for _, dockerClient := range dockerClients { + dockerClient.Close() + } +} + +func handleResult[V any, T ResultType[V]](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) +} + +func serveHTTP[V any, T ResultType[V]](w http.ResponseWriter, r *http.Request, getResult func(ctx context.Context, dockerClients DockerClients) (T, gperr.Error)) { + dockerClients, err := getDockerClients() + if err != nil { + handleResult[V, T](w, err, nil) + return + } + defer closeAllClients(dockerClients) + + if httpheaders.IsWebsocket(r.Header) { + gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error { + result, err := getResult(r.Context(), dockerClients) + if err != nil { + return err + } + return wsjson.Write(r.Context(), conn, result) + }) + } else { + result, err := getResult(r.Context(), dockerClients) + handleResult[V, T](w, err, result) + } +}