restructured the project to comply community guideline, for others check release note

This commit is contained in:
yusing
2024-09-28 09:51:34 +08:00
parent 4120fd8d1c
commit 90487bfde6
134 changed files with 1110 additions and 625 deletions

153
internal/docker/client.go Normal file
View File

@@ -0,0 +1,153 @@
package docker
import (
"net/http"
"sync"
"sync/atomic"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type Client struct {
key string
refCount *atomic.Int32
*client.Client
l logrus.FieldLogger
}
func ParseDockerHostname(host string) (string, E.NestedError) {
switch host {
case common.DockerHostFromEnv, "":
return "localhost", nil
}
url, err := E.Check(client.ParseHostURL(host))
if err != nil {
return "", E.Invalid("host", host).With(err)
}
return url.Hostname(), nil
}
func (c Client) DaemonHostname() string {
// DaemonHost should always return a valid host
hostname, _ := ParseDockerHostname(c.DaemonHost())
return hostname
}
func (c Client) Connected() bool {
return c.Client != nil
}
// if the client is still referenced, this is no-op
func (c *Client) Close() error {
if c.refCount.Add(-1) > 0 {
return nil
}
clientMap.Delete(c.key)
client := c.Client
c.Client = nil
c.l.Debugf("client closed")
if client != nil {
return client.Close()
}
return nil
}
// ConnectClient creates a new Docker client connection to the specified host.
//
// Returns existing client if available.
//
// Parameters:
// - host: the host to connect to (either a URL or common.DockerHostFromEnv).
//
// Returns:
// - Client: the Docker client connection.
// - error: an error if the connection failed.
func ConnectClient(host string) (Client, E.NestedError) {
clientMapMu.Lock()
defer clientMapMu.Unlock()
// check if client exists
if client, ok := clientMap.Load(host); ok {
client.refCount.Add(1)
return client, nil
}
// create client
var opt []client.Opt
switch host {
case common.DockerHostFromEnv:
opt = clientOptEnvHost
default:
helper, err := E.Check(connhelper.GetConnectionHelper(host))
if err.HasError() {
return Client{}, E.UnexpectedError(err.Error())
}
if helper != nil {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: helper.Dialer,
},
}
opt = []client.Opt{
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
client.WithAPIVersionNegotiation(),
client.WithDialContext(helper.Dialer),
}
} else {
opt = []client.Opt{
client.WithHost(host),
client.WithAPIVersionNegotiation(),
}
}
}
client, err := E.Check(client.NewClientWithOpts(opt...))
if err.HasError() {
return Client{}, err
}
c := Client{
Client: client,
key: host,
refCount: &atomic.Int32{},
l: logger.WithField("docker_client", client.DaemonHost()),
}
c.refCount.Add(1)
c.l.Debugf("client connected")
clientMap.Store(host, c)
return c, nil
}
func CloseAllClients() {
clientMap.RangeAll(func(_ string, c Client) {
c.Client.Close()
})
clientMap.Clear()
logger.Debug("closed all clients")
}
var (
clientMap F.Map[string, Client] = F.NewMapOf[string, Client]()
clientMapMu sync.Mutex
clientOptEnvHost = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),
}
logger = logrus.WithField("module", "docker")
)

View File

@@ -0,0 +1,54 @@
package docker
import (
"context"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
E "github.com/yusing/go-proxy/internal/error"
)
type ClientInfo struct {
Client Client
Containers []types.Container
}
var listOptions = container.ListOptions{
// Filters: filters.NewArgs(
// filters.Arg("health", "healthy"),
// filters.Arg("health", "none"),
// filters.Arg("health", "starting"),
// ),
All: true,
}
func GetClientInfo(clientHost string, getContainer bool) (*ClientInfo, E.NestedError) {
dockerClient, err := ConnectClient(clientHost)
if err.HasError() {
return nil, E.FailWith("connect to docker", err)
}
defer dockerClient.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var containers []types.Container
if getContainer {
containers, err = E.Check(dockerClient.ContainerList(ctx, listOptions))
if err.HasError() {
return nil, E.FailWith("list containers", err)
}
}
return &ClientInfo{
Client: dockerClient,
Containers: containers,
}, nil
}
func IsErrConnectionFailed(err error) bool {
return client.IsErrConnectionFailed(err)
}

View File

@@ -0,0 +1,105 @@
package docker
import (
"fmt"
"strconv"
"strings"
"github.com/docker/docker/api/types"
U "github.com/yusing/go-proxy/internal/utils"
)
type Container struct {
*types.Container
*ProxyProperties
}
func FromDocker(c *types.Container, dockerHost string) (res Container) {
res.Container = c
res.ProxyProperties = &ProxyProperties{
DockerHost: dockerHost,
ContainerName: res.getName(),
ImageName: res.getImageName(),
PublicPortMapping: res.getPublicPortMapping(),
PrivatePortMapping: res.getPrivatePortMapping(),
NetworkMode: c.HostConfig.NetworkMode,
Aliases: res.getAliases(),
IsExcluded: U.ParseBool(res.getDeleteLabel(LabelExclude)),
IdleTimeout: res.getDeleteLabel(LabelIdleTimeout),
WakeTimeout: res.getDeleteLabel(LabelWakeTimeout),
StopMethod: res.getDeleteLabel(LabelStopMethod),
StopTimeout: res.getDeleteLabel(LabelStopTimeout),
StopSignal: res.getDeleteLabel(LabelStopSignal),
Running: c.Status == "running" || c.State == "running",
}
return
}
func FromJson(json types.ContainerJSON, dockerHost string) Container {
ports := make([]types.Port, 0)
for k, bindings := range json.NetworkSettings.Ports {
for _, v := range bindings {
pubPort, _ := strconv.ParseUint(v.HostPort, 10, 16)
privPort, _ := strconv.ParseUint(k.Port(), 10, 16)
ports = append(ports, types.Port{
IP: v.HostIP,
PublicPort: uint16(pubPort),
PrivatePort: uint16(privPort),
})
}
}
return FromDocker(&types.Container{
ID: json.ID,
Names: []string{json.Name},
Image: json.Image,
Ports: ports,
Labels: json.Config.Labels,
State: json.State.Status,
Status: json.State.Status,
}, dockerHost)
}
func (c Container) getDeleteLabel(label string) string {
if l, ok := c.Labels[label]; ok {
delete(c.Labels, label)
return l
}
return ""
}
func (c Container) getAliases() []string {
if l := c.getDeleteLabel(LabelAliases); l != "" {
return U.CommaSeperatedList(l)
} else {
return []string{c.getName()}
}
}
func (c Container) getName() string {
return strings.TrimPrefix(c.Names[0], "/")
}
func (c Container) getImageName() string {
colonSep := strings.Split(c.Image, ":")
slashSep := strings.Split(colonSep[0], "/")
return slashSep[len(slashSep)-1]
}
func (c Container) getPublicPortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
if v.PublicPort == 0 {
continue
}
res[fmt.Sprint(v.PublicPort)] = v
}
return res
}
func (c Container) getPrivatePortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
res[fmt.Sprint(v.PrivatePort)] = v
}
return res
}

View File

@@ -0,0 +1,32 @@
package docker
type (
HomePageConfig struct{ m map[string]HomePageCategory }
HomePageCategory []HomePageItem
HomePageItem struct {
Name string
Icon string
Category string
Description string
WidgetConfig map[string]any
}
)
func NewHomePageConfig() *HomePageConfig {
return &HomePageConfig{m: make(map[string]HomePageCategory)}
}
func NewHomePageItem() *HomePageItem {
return &HomePageItem{}
}
func (c *HomePageConfig) Clear() {
c.m = make(map[string]HomePageCategory)
}
func (c *HomePageConfig) Add(item HomePageItem) {
c.m[item.Category] = HomePageCategory{item}
}
const NSHomePage = "homepage"

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #fff;
background-color: #212121;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
/* Spinner Styles */
.spinner {
width: 120px;
height: 120px;
border: 16px solid #333;
border-radius: 50%;
border-top: 16px solid #66d9ef;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Error Styles */
.error {
display: inline-block;
text-align: center;
justify-content: center;
}
.error::before {
content: "\26A0"; /* Unicode for warning symbol */
font-size: 40px;
color: #ff9900;
}
/* Message Styles */
.message {
font-size: 24px;
font-weight: bold;
padding-left: 32px;
text-align: center;
}
</style>
</head>
<body>
<script>
window.onload = async function () {
let result = await fetch(window.location.href, {
headers: {
{{ range $key, $value := .RequestHeaders }}
'{{ $key }}' : {{ $value }}
{{ end }}
},
}).then((resp) => resp.text())
.catch((err) => {
document.getElementById("message").innerText = err;
});
if (result) {
document.documentElement.innerHTML = result
}
};
</script>
<div class="{{.SpinnerClass}}"></div>
<div class="message">{{.Message}}</div>
</body>
</html>

View File

@@ -0,0 +1,87 @@
package idlewatcher
import (
"bytes"
_ "embed"
"fmt"
"io"
"net/http"
"strings"
"text/template"
)
type templateData struct {
Title string
Message string
RequestHeaders http.Header
SpinnerClass string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
const (
htmlContentType = "text/html; charset=utf-8"
errPrefix = "\u1000"
headerGoProxyTargetURL = "X-GoProxy-Target"
headerContentType = "Content-Type"
spinnerClassSpinner = "spinner"
spinnerClassErrorSign = "error"
)
func (w *watcher) makeSuccResp(redirectURL string, resp *http.Response) (*http.Response, error) {
h := make(http.Header)
h.Set("Location", redirectURL)
h.Set("Content-Length", "0")
h.Set(headerContentType, htmlContentType)
return &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Header: h,
Body: http.NoBody,
TLS: resp.TLS,
}, nil
}
func (w *watcher) makeErrResp(errFmt string, args ...any) (*http.Response, error) {
return w.makeResp(errPrefix+errFmt, args...)
}
func (w *watcher) makeResp(format string, args ...any) (*http.Response, error) {
msg := fmt.Sprintf(format, args...)
data := new(templateData)
data.Title = w.ContainerName
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
data.Message = strings.ReplaceAll(data.Message, " ", "&ensp;")
data.RequestHeaders = make(http.Header)
data.RequestHeaders.Add(headerGoProxyTargetURL, "window.location.href")
if strings.HasPrefix(data.Message, errPrefix) {
data.Message = strings.TrimLeft(data.Message, errPrefix)
data.SpinnerClass = spinnerClassErrorSign
} else {
data.SpinnerClass = spinnerClassSpinner
}
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen
panic(err)
}
return &http.Response{
StatusCode: http.StatusAccepted,
Header: http.Header{
headerContentType: {htmlContentType},
"Cache-Control": {
"no-cache",
"no-store",
"must-revalidate",
},
},
Body: io.NopCloser(buf),
ContentLength: int64(buf.Len()),
}, nil
}

View File

@@ -0,0 +1,83 @@
package idlewatcher
import (
"context"
"net/http"
)
type (
roundTripper struct {
patched roundTripFunc
}
roundTripFunc func(*http.Request) (*http.Response, error)
)
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt.patched(req)
}
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
// wake the container
select {
case w.wakeCh <- struct{}{}:
default:
}
// target site is ready, passthrough
if w.ready.Load() {
return origRoundTrip(req)
}
// initial request
targetUrl := req.Header.Get(headerGoProxyTargetURL)
if targetUrl == "" {
return w.makeResp(
"%s is starting... Please wait",
w.ContainerName,
)
}
w.l.Debug("serving event")
// stream request
rtDone := make(chan *http.Response, 1)
ctx, cancel := context.WithTimeout(req.Context(), w.WakeTimeout)
defer cancel()
// loop original round trip until success in a goroutine
go func() {
for {
select {
case <-ctx.Done():
return
case <-w.ctx.Done():
return
default:
resp, err := origRoundTrip(req)
if err == nil {
w.ready.Store(true)
rtDone <- resp
return
}
}
}
}()
for {
select {
case resp := <-rtDone:
return w.makeSuccResp(targetUrl, resp)
case err := <-w.wakeDone:
if err != nil {
return w.makeErrResp("error waking up %s\n%s", w.ContainerName, err.Error())
}
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return w.makeErrResp("Timed out waiting for %s to fully wake", w.ContainerName)
}
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
case <-w.ctx.Done():
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
}
}
}

View File

@@ -0,0 +1,276 @@
package idlewatcher
import (
"context"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/docker/docker/api/types/container"
"github.com/sirupsen/logrus"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
P "github.com/yusing/go-proxy/internal/proxy"
PT "github.com/yusing/go-proxy/internal/proxy/fields"
W "github.com/yusing/go-proxy/internal/watcher"
)
type (
watcher struct {
*P.ReverseProxyEntry
client D.Client
ready atomic.Bool // whether the site is ready to accept connection
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
wakeCh chan struct{}
wakeDone chan E.NestedError
ctx context.Context
cancel context.CancelFunc
refCount *sync.WaitGroup
l logrus.FieldLogger
}
WakeDone <-chan error
WakeFunc func() WakeDone
StopCallback func() E.NestedError
)
var (
mainLoopCtx context.Context
mainLoopCancel context.CancelFunc
mainLoopWg sync.WaitGroup
watcherMap = make(map[string]*watcher)
watcherMapMu sync.Mutex
newWatcherCh = make(chan *watcher)
logger = logrus.WithField("module", "idle_watcher")
)
func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
failure := E.Failure("idle_watcher register")
if entry.IdleTimeout == 0 {
return nil, failure.With(E.Invalid("idle_timeout", 0))
}
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
if w, ok := watcherMap[entry.ContainerName]; ok {
w.refCount.Add(1)
w.ReverseProxyEntry = entry
return w, nil
}
client, err := D.ConnectClient(entry.DockerHost)
if err.HasError() {
return nil, failure.With(err)
}
w := &watcher{
ReverseProxyEntry: entry,
client: client,
refCount: &sync.WaitGroup{},
wakeCh: make(chan struct{}),
wakeDone: make(chan E.NestedError),
l: logger.WithField("container", entry.ContainerName),
}
w.refCount.Add(1)
w.stopByMethod = w.getStopCallback()
watcherMap[w.ContainerName] = w
go func() {
newWatcherCh <- w
}()
return w, nil
}
func Unregister(containerName string) {
if w, ok := watcherMap[containerName]; ok {
w.refCount.Add(-1)
}
}
func Start() {
logger.Debug("started")
defer logger.Debug("stopped")
mainLoopCtx, mainLoopCancel = context.WithCancel(context.Background())
for {
select {
case <-mainLoopCtx.Done():
return
case w := <-newWatcherCh:
w.l.Debug("registered")
mainLoopWg.Add(1)
go func() {
w.watchUntilCancel()
w.refCount.Wait() // wait for 0 ref count
w.client.Close()
delete(watcherMap, w.ContainerName)
w.l.Debug("unregistered")
mainLoopWg.Done()
}()
}
}
}
func Stop() {
mainLoopCancel()
mainLoopWg.Wait()
}
func (w *watcher) PatchRoundTripper(rtp http.RoundTripper) roundTripper {
return roundTripper{patched: func(r *http.Request) (*http.Response, error) {
return w.roundTrip(rtp.RoundTrip, r)
}}
}
func (w *watcher) containerStop() error {
return w.client.ContainerStop(w.ctx, w.ContainerName, container.StopOptions{
Signal: string(w.StopSignal),
Timeout: &w.StopTimeout})
}
func (w *watcher) containerPause() error {
return w.client.ContainerPause(w.ctx, w.ContainerName)
}
func (w *watcher) containerKill() error {
return w.client.ContainerKill(w.ctx, w.ContainerName, string(w.StopSignal))
}
func (w *watcher) containerUnpause() error {
return w.client.ContainerUnpause(w.ctx, w.ContainerName)
}
func (w *watcher) containerStart() error {
return w.client.ContainerStart(w.ctx, w.ContainerName, container.StartOptions{})
}
func (w *watcher) containerStatus() (string, E.NestedError) {
json, err := w.client.ContainerInspect(w.ctx, w.ContainerName)
if err != nil {
return "", E.FailWith("inspect container", err)
}
return json.State.Status, nil
}
func (w *watcher) wakeIfStopped() E.NestedError {
status, err := w.containerStatus()
if err.HasError() {
return err
}
// "created", "running", "paused", "restarting", "removing", "exited", or "dead"
switch status {
case "exited", "dead":
return E.From(w.containerStart())
case "paused":
return E.From(w.containerUnpause())
case "running":
return nil
default:
return E.Unexpected("container state", status)
}
}
func (w *watcher) getStopCallback() StopCallback {
var cb func() error
switch w.StopMethod {
case PT.StopMethodPause:
cb = w.containerPause
case PT.StopMethodStop:
cb = w.containerStop
case PT.StopMethodKill:
cb = w.containerKill
default:
panic("should not reach here")
}
return func() E.NestedError {
status, err := w.containerStatus()
if err.HasError() {
return err
}
if status != "running" {
return nil
}
return E.From(cb())
}
}
func (w *watcher) watchUntilCancel() {
defer close(w.wakeCh)
w.ctx, w.cancel = context.WithCancel(context.Background())
dockerWatcher := W.NewDockerWatcherWithClient(w.client)
dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{
Filters: W.NewDockerFilter(
W.DockerFilterContainer,
W.DockerrFilterContainerName(w.ContainerName),
W.DockerFilterStart,
W.DockerFilterStop,
W.DockerFilterDie,
W.DockerFilterKill,
W.DockerFilterPause,
W.DockerFilterUnpause,
),
})
ticker := time.NewTicker(w.IdleTimeout)
defer ticker.Stop()
for {
select {
case <-mainLoopCtx.Done():
w.cancel()
case <-w.ctx.Done():
w.l.Debug("stopped")
return
case err := <-dockerEventErrCh:
if err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("docker watcher", err))
}
case e := <-dockerEventCh:
switch {
// create / start / unpause
case e.Action.IsContainerWake():
ticker.Reset(w.IdleTimeout)
w.l.Info(e)
default: // stop / pause / kill
ticker.Stop()
w.ready.Store(false)
w.l.Info(e)
}
case <-ticker.C:
w.l.Debug("idle timeout")
ticker.Stop()
if err := w.stopByMethod(); err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("stop", err).Extraf("stop method: %s", w.StopMethod))
}
case <-w.wakeCh:
w.l.Debug("wake signal received")
ticker.Reset(w.IdleTimeout)
err := w.wakeIfStopped()
if err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("wake", err))
}
select {
case w.wakeDone <- err: // this is passed to roundtrip
default:
}
}
}
}

View File

@@ -0,0 +1,19 @@
package docker
import (
"context"
"time"
E "github.com/yusing/go-proxy/internal/error"
)
func (c Client) Inspect(containerID string) (Container, E.NestedError) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
json, err := c.ContainerInspect(ctx, containerID)
if err != nil {
return Container{}, E.From(err)
}
return FromJson(json, c.key), nil
}

151
internal/docker/label.go Normal file
View File

@@ -0,0 +1,151 @@
package docker
import (
"reflect"
"strings"
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
/*
Formats:
- namespace.attribute
- namespace.target.attribute
- namespace.target.attribute.namespace2.attribute
*/
type (
Label struct {
Namespace string
Target string
Attribute string
Value any
}
NestedLabelMap map[string]U.SerializedObject
ValueParser func(string) (any, E.NestedError)
ValueParserMap map[string]ValueParser
)
func (l *Label) String() string {
if l.Attribute == "" {
return l.Namespace + "." + l.Target
}
return l.Namespace + "." + l.Target + "." + l.Attribute
}
// Apply applies the value of a Label to the corresponding field in the given object.
//
// Parameters:
// - obj: a pointer to the object to which the Label value will be applied.
// - l: a pointer to the Label containing the attribute and value to be applied.
//
// Returns:
// - error: an error if the field does not exist.
func ApplyLabel[T any](obj *T, l *Label) E.NestedError {
if obj == nil {
return E.Invalid("nil object", l)
}
switch nestedLabel := l.Value.(type) {
case *Label:
var field reflect.Value
objType := reflect.TypeFor[T]()
for i := 0; i < reflect.TypeFor[T]().NumField(); i++ {
if objType.Field(i).Tag.Get("yaml") == l.Attribute {
field = reflect.ValueOf(obj).Elem().Field(i)
break
}
}
if !field.IsValid() {
return E.NotExist("field", l.Attribute)
}
dst, ok := field.Interface().(NestedLabelMap)
if !ok {
return E.Invalid("type", field.Type())
}
if dst == nil {
field.Set(reflect.MakeMap(reflect.TypeFor[NestedLabelMap]()))
dst = field.Interface().(NestedLabelMap)
}
if dst[nestedLabel.Namespace] == nil {
dst[nestedLabel.Namespace] = make(U.SerializedObject)
}
dst[nestedLabel.Namespace][nestedLabel.Attribute] = nestedLabel.Value
return nil
default:
return U.Deserialize(U.SerializedObject{l.Attribute: l.Value}, obj)
}
}
func ParseLabel(label string, value string) (*Label, E.NestedError) {
parts := strings.Split(label, ".")
if len(parts) < 2 {
return &Label{
Namespace: label,
Value: value,
}, nil
}
l := &Label{
Namespace: parts[0],
Target: parts[1],
Value: value,
}
switch len(parts) {
case 2:
l.Attribute = l.Target
case 3:
l.Attribute = parts[2]
default:
l.Attribute = parts[2]
nestedLabel, err := ParseLabel(strings.Join(parts[3:], "."), value)
if err.HasError() {
return nil, err
}
l.Value = nestedLabel
}
// find if namespace has value parser
pm, ok := valueParserMap.Load(U.ToLowerNoSnake(l.Namespace))
if !ok {
return l, nil
}
// find if attribute has value parser
p, ok := pm[U.ToLowerNoSnake(l.Attribute)]
if !ok {
return l, nil
}
// try to parse value
v, err := p(value)
if err.HasError() {
return nil, err.Subject(label)
}
l.Value = v
return l, nil
}
func RegisterNamespace(namespace string, pm ValueParserMap) {
pmCleaned := make(ValueParserMap, len(pm))
for k, v := range pm {
pmCleaned[U.ToLowerNoSnake(k)] = v
}
valueParserMap.Store(U.ToLowerNoSnake(namespace), pmCleaned)
}
func GetRegisteredNamespaces() map[string][]string {
r := make(map[string][]string)
valueParserMap.RangeAll(func(ns string, vpm ValueParserMap) {
r[ns] = make([]string, 0, len(vpm))
for attr := range vpm {
r[ns] = append(r[ns], attr)
}
})
return r
}
// namespace:target.attribute -> func(string) (any, error)
var valueParserMap = F.NewMapOf[string, ValueParserMap]()

View File

@@ -0,0 +1,78 @@
package docker
import (
"strings"
E "github.com/yusing/go-proxy/internal/error"
"gopkg.in/yaml.v3"
)
const (
NSProxy = "proxy"
ProxyAttributePathPatterns = "path_patterns"
ProxyAttributeNoTLSVerify = "no_tls_verify"
ProxyAttributeMiddlewares = "middlewares"
)
var _ = func() int {
RegisterNamespace(NSProxy, ValueParserMap{
ProxyAttributePathPatterns: YamlStringListParser,
ProxyAttributeNoTLSVerify: BoolParser,
})
return 0
}()
func YamlStringListParser(value string) (any, E.NestedError) {
/*
- foo
- bar
- baz
*/
value = strings.TrimSpace(value)
if value == "" {
return []string{}, nil
}
var data []string
err := E.From(yaml.Unmarshal([]byte(value), &data))
return data, err
}
func YamlLikeMappingParser(allowDuplicate bool) func(string) (any, E.NestedError) {
return func(value string) (any, E.NestedError) {
/*
foo: bar
boo: baz
*/
value = strings.TrimSpace(value)
lines := strings.Split(value, "\n")
h := make(map[string]string)
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return nil, E.Invalid("syntax", line).With("too many colons")
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if existing, ok := h[key]; ok {
if !allowDuplicate {
return nil, E.Duplicated("key", key)
}
h[key] = existing + ", " + val
} else {
h[key] = val
}
}
return h, nil
}
}
func BoolParser(value string) (any, E.NestedError) {
switch strings.ToLower(value) {
case "true", "yes", "1":
return true, nil
case "false", "no", "0":
return false, nil
default:
return nil, E.Invalid("boolean value", value)
}
}

View File

@@ -0,0 +1,106 @@
package docker
import (
"fmt"
"testing"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func makeLabel(namespace string, alias string, field string) string {
return fmt.Sprintf("%s.%s.%s", namespace, alias, field)
}
func TestParseLabel(t *testing.T) {
alias := "foo"
field := "ip"
v := "bar"
pl, err := ParseLabel(makeLabel(NSHomePage, alias, field), v)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Namespace, NSHomePage)
ExpectEqual(t, pl.Target, alias)
ExpectEqual(t, pl.Attribute, field)
ExpectEqual(t, pl.Value.(string), v)
}
func TestStringProxyLabel(t *testing.T) {
v := "bar"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "ip"), v)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Value.(string), v)
}
func TestBoolProxyLabelValid(t *testing.T) {
tests := map[string]bool{
"true": true,
"TRUE": true,
"yes": true,
"1": true,
"false": false,
"FALSE": false,
"no": false,
"0": false,
}
for k, v := range tests {
pl, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeNoTLSVerify), k)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Value.(bool), v)
}
}
func TestBoolProxyLabelInvalid(t *testing.T) {
_, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeNoTLSVerify), "invalid")
if !err.Is(E.ErrInvalid) {
t.Errorf("Expected err InvalidProxyLabel, got %s", err.Error())
}
}
// func TestSetHeaderProxyLabelValid(t *testing.T) {
// v := `
// X-Custom-Header1: foo, bar
// X-Custom-Header1: baz
// X-Custom-Header2: boo`
// v = strings.TrimPrefix(v, "\n")
// h := map[string]string{
// "X-Custom-Header1": "foo, bar, baz",
// "X-Custom-Header2": "boo",
// }
// pl, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeSetHeaders), v)
// ExpectNoError(t, err.Error())
// hGot := ExpectType[map[string]string](t, pl.Value)
// ExpectFalse(t, hGot == nil)
// ExpectDeepEqual(t, h, hGot)
// }
// func TestSetHeaderProxyLabelInvalid(t *testing.T) {
// tests := []string{
// "X-Custom-Header1 = bar",
// "X-Custom-Header1",
// "- X-Custom-Header1",
// }
// for _, v := range tests {
// _, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeSetHeaders), v)
// if !err.Is(E.ErrInvalid) {
// t.Errorf("Expected invalid err for %q, got %s", v, err.Error())
// }
// }
// }
// func TestHideHeadersProxyLabel(t *testing.T) {
// v := `
// - X-Custom-Header1
// - X-Custom-Header2
// - X-Custom-Header3
// `
// v = strings.TrimPrefix(v, "\n")
// pl, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeHideHeaders), v)
// ExpectNoError(t, err.Error())
// sGot := ExpectType[[]string](t, pl.Value)
// sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
// ExpectFalse(t, sGot == nil)
// ExpectDeepEqual(t, sGot, sWant)
// }

View File

@@ -0,0 +1,85 @@
package docker
import (
"fmt"
"testing"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestNestedLabel(t *testing.T) {
mName := "middleware1"
mAttr := "prop1"
v := "value1"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
ExpectNoError(t, err.Error())
sGot := ExpectType[*Label](t, pl.Value)
ExpectFalse(t, sGot == nil)
ExpectEqual(t, sGot.Namespace, mName)
ExpectEqual(t, sGot.Attribute, mAttr)
}
func TestApplyNestedLabel(t *testing.T) {
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
mName := "middleware1"
mAttr := "prop1"
v := "value1"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
middleware1, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
got := ExpectType[string](t, middleware1[mAttr])
ExpectEqual(t, got, v)
}
func TestApplyNestedLabelExisting(t *testing.T) {
mName := "middleware1"
mAttr := "prop1"
v := "value1"
checkAttr := "prop2"
checkV := "value2"
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
entry.Middlewares = make(NestedLabelMap)
entry.Middlewares[mName] = make(U.SerializedObject)
entry.Middlewares[mName][checkAttr] = checkV
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
middleware1, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
got := ExpectType[string](t, middleware1[mAttr])
ExpectEqual(t, got, v)
// check if prop2 is affected
ExpectFalse(t, middleware1[checkAttr] == nil)
got = ExpectType[string](t, middleware1[checkAttr])
ExpectEqual(t, got, checkV)
}
func TestApplyNestedLabelNoAttr(t *testing.T) {
mName := "middleware1"
v := "value1"
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
entry.Middlewares = make(NestedLabelMap)
entry.Middlewares[mName] = make(U.SerializedObject)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", ProxyAttributeMiddlewares, mName)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
_, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
}

13
internal/docker/labels.go Normal file
View File

@@ -0,0 +1,13 @@
package docker
const (
WildcardAlias = "*"
LabelAliases = NSProxy + ".aliases"
LabelExclude = NSProxy + ".exclude"
LabelIdleTimeout = NSProxy + ".idle_timeout"
LabelWakeTimeout = NSProxy + ".wake_timeout"
LabelStopMethod = NSProxy + ".stop_method"
LabelStopTimeout = NSProxy + ".stop_timeout"
LabelStopSignal = NSProxy + ".stop_signal"
)

View File

@@ -0,0 +1,22 @@
package docker
import "github.com/docker/docker/api/types"
type PortMapping = map[string]types.Port
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
ImageName string `yaml:"-" json:"image_name"`
PublicPortMapping PortMapping `yaml:"-" json:"public_port_mapping"` // non-zero publicPort:types.Port
PrivatePortMapping PortMapping `yaml:"-" json:"private_port_mapping"` // privatePort:types.Port
NetworkMode string `yaml:"-" json:"network_mode"`
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
Running bool `yaml:"-" json:"running"`
}