diff --git a/.dockerignore b/.dockerignore index 6969a38..9ab66d0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,16 @@ -!app.py \ No newline at end of file +# env file +*.env + +# Exclude git folders +.git* +!.github + +# Ignore yml files +*.yaml +*.yml + +# Markdown files +*.md + +# Include data/ +!data/* \ No newline at end of file diff --git a/.github/templates/README.template.md b/.github/templates/README.template.md index 82f4f89..6d903c1 100644 --- a/.github/templates/README.template.md +++ b/.github/templates/README.template.md @@ -1,68 +1,113 @@ +
+ + GitHub release + + + GitHub stars + + + Docker image size + + + Docker image Pulls + + + License: MIT + +
+ # ServDiscovery -ServDiscovery is a Discovery Service that keeps an Endpoint updated with active Hosts (of Services). +**ServDiscovery** is a dynamic **Discovery Service** that keeps your endpoints in sync with active hosts of your services — perfect for modern, containerized environments. Think of it as the bridge between your services and your reverse proxy, ensuring traffic always finds the right destination. ## Installation -> [!NOTE] -> ServDiscovery only works with Traefik and not with **any** other Reverse Proxy due to `traefik.http.routers.router.rule` label +> [!IMPORTANT] +> ServDiscovery works **only with Traefik**. It will **not** work with other reverse proxies due to using traefik labels to determine routes. -Get the latest `docker-compose.yaml` file: +Get the latest `docker-compose.yaml`: ```yaml {{{ #://docker-compose.yaml }}} ``` +Then spin it up: + ```bash docker compose up -d ``` +Your discovery service is now live! 🎉 + ## Usage -Take this little `whoami` Container as an Example: +Let's take a simple `whoami` container as an example: ```yaml {{{ #://examples/whoami.docker-compose.yaml }}} ``` -Whenever a new **Host-Rule** gets added / modified ServDiscovery will update the set Endpoint to notify of any new changes. -This way the Endpoint can correctly route to different Hosts based on **SNI / Hostnames**. +Whenever a new **Host-Rule** is added or updated, ServDiscovery will **automatically notify the configured endpoint**. +This ensures the endpoint can correctly route traffic based on **SNI / Hostnames**. -## Endpoint +## Endpoint Integration -ServDiscovery sends requests to the Endpoint as a **JSON HTTP Request**: +ServDiscovery communicates with your endpoint via **JSON HTTP Requests**: ```json {{{ #://examples/payload.json }}} ``` -This example tell the Endpoint that... +Example explanation: -| Available | Unavailable | +| ✅ Available | ❌ Unavailable | | -------------------- | --------------------------- | | whoami.mydomain.com | whoami-backup.mydomain.com | | website.mydomain.com | website-backup.mydomain.com | | auth.mydomain.com | auth-backup.mydomain.com | -This way (if the Endpoint is used by a LoadBalancer) the Owner of the Endpoint can now delete the `*-backup.mydomain.com` records from a Registry, -thus updating the list of routable Containers / Services. +This allows the endpoint (e.g., a load balancer) to remove `\*-backup` records from your registry and **update routable containers/services automatically**. + +### Integrations + +You can find example integrations inside of [examples/](./examples). ## Configuration -### ENDPOINT_KEY +### `ENDPOINT_KEY` + +The endpoint key is used in the `Authorization` header (Bearer token) when ServDiscovery sends POST requests. +If no key is provided, the header is omitted. + +### `DISCOVERY_INTERVAL` -The Endpoint Key is provided in the Authorization Header (via Bearer) during the POST request between the Endpoint and ServDiscovery. -If no Key is provided ServDiscovery will leave out the Authorization Header. +Time (in seconds) between updates to your endpoint. +**Default:** `60` seconds -### DISCOVERY_INTERVAL +### `ALIVE_INTERVAL` -The Discovery Interval sets the Interval of which ServDiscovery will update the Endpoint, etc. +Time (in seconds) between full alive discoveries. ServDiscovery sends a **complete update** of all active containers in the `added` JSON key. +**Default:** `120` seconds ## Contributing -Found a bug or have new ideas or enhancements for this Project? -Feel free to open up an issue or create a Pull Request! +Found a bug or have a brilliant idea? Contributions are welcome! Open an **issue** or create a **pull request** — your help makes this project better. ## License -[MIT](https://choosealicense.com/licenses/mit/) +This project is licensed under the [MIT License](./LICENSE). diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml index 03e2292..513ef6c 100644 --- a/.github/workflows/docker-image-dev.yml +++ b/.github/workflows/docker-image-dev.yml @@ -4,12 +4,13 @@ on: push: branches: - dev - paths-ignore: - - ".**" + paths: + - "**/*.go" jobs: update: - uses: codeshelldev/gh-actions/.github/workflows/docker-image.yml@main + uses: codeshelldev/gh-actions/.github/workflows/docker-image-go.yml@main + name: Development Image with: registry: ghcr.io flavor: | diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d0c694f..25b5620 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,7 +6,8 @@ on: jobs: update: - uses: codeshelldev/gh-actions/.github/workflows/docker-image.yml@main + uses: codeshelldev/gh-actions/.github/workflows/docker-image-go.yml@main + name: Stable Image with: registry: ghcr.io secrets: diff --git a/.github/workflows/readme-update.yml b/.github/workflows/readme-update.yml index ed75786..8f55af2 100644 --- a/.github/workflows/readme-update.yml +++ b/.github/workflows/readme-update.yml @@ -10,5 +10,6 @@ on: jobs: update: uses: codeshelldev/gh-actions/.github/workflows/readme-update.yml@main + name: Update secrets: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c66ba3a..69c120e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,33 @@ -.env -.venv \ No newline at end of file +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +*.env + +# Editor/IDE +# .idea/ +.vscode/ + +# Exclude git folders +.git* +!.github \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0b96b88..da53e45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,19 @@ -FROM python:3.12-alpine +FROM alpine:3.22 +RUN apk --no-cache add ca-certificates -WORKDIR /app +ARG IMAGE_TAG +ENV IMAGE_TAG=$IMAGE_TAG +LABEL org.opencontainers.image.version=$IMAGE_TAG + +ARG TARGETOS +ARG TARGETARCH -RUN pip install docker +WORKDIR /app COPY . . -ENV PORT=4531 +COPY dist/${TARGETOS}/${TARGETARCH}/app . -EXPOSE ${PORT} +RUN rm dist/ -r -CMD ["python", "app.py"] \ No newline at end of file +CMD ["./app"] \ No newline at end of file diff --git a/README.md b/README.md index c30272c..e8015fd 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,46 @@ +
+ + GitHub release + + + GitHub stars + + + Docker image size + + + Docker image Pulls + + + License: MIT + +
+ # ServDiscovery -ServDiscovery is a Discovery Service that keeps an Endpoint updated with active Hosts (of Services). +**ServDiscovery** is a dynamic **Discovery Service** that keeps your endpoints in sync with active hosts of your services — perfect for modern, containerized environments. Think of it as the bridge between your services and your reverse proxy, ensuring traffic always finds the right destination. ## Installation -> [!NOTE] -> ServDiscovery only works with Traefik and not with **any** other Reverse Proxy due to `traefik.http.routers.router.rule` label +> [!IMPORTANT] +> ServDiscovery works **only with Traefik**. It will **not** work with other reverse proxies due to using traefik labels to determine routes. -Get the latest `docker-compose.yaml` file: +Get the latest `docker-compose.yaml`: ```yaml services: @@ -15,20 +48,26 @@ services: image: ghcr.io/codeshelldev/servdiscovery:latest container_name: service-discovery environment: - ENDPOINT: https://mydomain.com/ENDPOINT + ENDPOINT: https://mydomain.com/discover ENDPOINT_KEY: MY_VERY_SECURE_KEY + DISCOVERY_INTERVAL: 60 + ALIVE_INTERVAL: 60 SERVER_NAME: server-1 volumes: - /var/run/docker.sock:/var/run/docker.sock ``` +Then spin it up: + ```bash docker compose up -d ``` +Your discovery service is now live! 🎉 + ## Usage -Take this little `whoami` Container as an Example: +Let's take a simple `whoami` container as an example: ```yaml services: @@ -43,7 +82,7 @@ services: - traefik.http.routers.whoami.tls.certresolver=cloudflare - traefik.http.routers.whoami.service=whoami-svc - traefik.http.services.whoami-svc.loadbalancer.server.port=80 - # Enable Discovery on this Container + # Enable Discovery for this Container - discovery.enable=true networks: - traefik @@ -53,12 +92,12 @@ networks: external: true ``` -Whenever a new **Host-Rule** gets added / modified ServDiscovery will update the set Endpoint to notify of any new changes. -This way the Endpoint can correctly route to different Hosts based on **SNI / Hostnames**. +Whenever a new **Host-Rule** is added or updated, ServDiscovery will **automatically notify the configured endpoint**. +This ensures the endpoint can correctly route traffic based on **SNI / Hostnames**. -## Endpoint +## Endpoint Integration -ServDiscovery sends requests to the Endpoint as a **JSON HTTP Request**: +ServDiscovery communicates with your endpoint via **JSON HTTP Requests**: ```json { @@ -78,33 +117,41 @@ ServDiscovery sends requests to the Endpoint as a **JSON HTTP Request**: } ``` -This example tell the Endpoint that... +Example explanation: -| Available | Unavailable | +| ✅ Available | ❌ Unavailable | | -------------------- | --------------------------- | | whoami.mydomain.com | whoami-backup.mydomain.com | | website.mydomain.com | website-backup.mydomain.com | | auth.mydomain.com | auth-backup.mydomain.com | -This way (if the Endpoint is used by a LoadBalancer) the Owner of the Endpoint can now delete the `*-backup.mydomain.com` records from a Registry, -thus updating the list of routable Containers / Services. +This allows the endpoint (e.g., a load balancer) to remove `\*-backup` records from your registry and **update routable containers/services automatically**. + +### Integrations + +You can find example integrations inside of [examples/](./examples). ## Configuration -### ENDPOINT_KEY +### `ENDPOINT_KEY` + +The endpoint key is used in the `Authorization` header (Bearer token) when ServDiscovery sends POST requests. +If no key is provided, the header is omitted. + +### `DISCOVERY_INTERVAL` -The Endpoint Key is provided in the Authorization Header (via Bearer) during the POST request between the Endpoint and ServDiscovery. -If no Key is provided ServDiscovery will leave out the Authorization Header. +Time (in seconds) between updates to your endpoint. +**Default:** `60` seconds -### DISCOVERY_INTERVAL +### `ALIVE_INTERVAL` -The Discovery Interval sets the Interval of which ServDiscovery will update the Endpoint, etc. +Time (in seconds) between full alive discoveries. ServDiscovery sends a **complete update** of all active containers in the `added` JSON key. +**Default:** `120` seconds ## Contributing -Found a bug or have new ideas or enhancements for this Project? -Feel free to open up an issue or create a Pull Request! +Found a bug or have a brilliant idea? Contributions are welcome! Open an **issue** or create a **pull request** — your help makes this project better. ## License -[MIT](https://choosealicense.com/licenses/mit/) +This project is licensed under the [MIT License](./LICENSE). diff --git a/app.py b/app.py deleted file mode 100644 index 444ab34..0000000 --- a/app.py +++ /dev/null @@ -1,206 +0,0 @@ -from docker import DockerClient -from urllib.parse import urlparse as parseUrl -import requests -import os -import signal -import logging -import re -import sys -from time import sleep - -# import threading - -logger = logging.getLogger("ServDiscovery") - -handler = logging.StreamHandler(sys.stdout) -formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)s] %(message)s", - datefmt="%d.%m %H:%M" -) -handler.setFormatter(formatter) -logger.addHandler(handler) - -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") - - -SERVER_NAME = os.getenv("SERVER_NAME") - -ENDPOINT = os.getenv("ENDPOINT") -ENDPOINT_KEY = os.getenv("ENDPOINT_KEY") - -DISCOVERY_INTERVAL = os.getenv("DISCOVERY_INTERVAL") - -dockerClient = DockerClient(base_url="unix://var/run/docker.sock") - -# Threading -# containerToHostLock = threading.Lock() -# containerLock = threading.Lock() - -containersToHosts = {} -containers = {} - -def getDiff(old, new): - old_set, new_set = set(old), set(new) - - removed = old_set - new_set - added = new_set - old_set - - return { - removed: list(removed), - added: list(added) - } - -def getHostsFromLabels(labels): - hosts = [] - - for key, value in labels.items(): - if key.startswith("traefik.http.routers.") and key.endswith(".rule"): - matches = re.findall(r"Host\(`([^`]+)`\)", value) - hosts.extend(matches) - - return hosts - -def updateEnabledContainers(): - diffs = { - "removed": [], - "added": [] - } - - newContainers = [] - - try: - newContainers = dockerClient.containers.list(filters={"label": "discovery.enable=true"}) - except Exception as e: - logger.error(f"Error fetching containers: {str(e)}") - - containerDiff = getDiff(containers, newContainers) - - logger.info(f"Found {newContainers.count()} Containers") - - logger.debug(f"Found {containerDiff.get("added").count()} added Containers") - logger.debug(f"Found {containerDiff.get("removed").count()} removed Containers") - - # Threading - # with containerToHostLock and containerLock: - - containers = newContainers - - # Update changed Containers and Add new Containers - for container in newContainers: - hosts = getHostsFromLabels(container.labels) - - if container.id in containersToHosts: - old = containersToHosts[container.id] - new = hosts - - # Get Difference - diff = getDiff(old, new) - - logger.debug(f"[{container.name}] + {diff.get("added")}, - {diff.get("removed")}") - - diffs.get("removed").extend(diff.get("removed")) - diffs.get("added").extend(diff.get("added")) - - containersToHosts[container.id] = hosts - else: - logger.debug(f"Added {container.name}") - - containersToHosts[container.id] = hosts - - # Diff Old / Removed Containers - for removedContainer in containerDiff.get("removed"): - if removedContainer.id in containersToHosts: - - # Get all Hosts from Removed Container and Add them to the global Diff - diffs.get("removed").extend(containersToHosts[removedContainer.id]) - - # Remove Container from Dict - logger.debug(f"Removed {removedContainer.name}") - - containersToHosts.pop(removedContainer.id) - - return diffs - -def cleanDiff(diff): - both = diff.get("removed") and diff.get("added") - - removed -= both - added -= both - - return { - removed: list(removed), - added: list(added) - } - -def exitContainer(): - logger.error(f"Shutting Container down...") - - os.kill(os.getpid(), signal.SIGTERM) - -def sendDiffToEndpoint(diff): - data = { "server": SERVER_NAME, "diff": diff } - - headers = {} - - if ENDPOINT_KEY: - headers["Authorization"] = f"Bearer {ENDPOINT_KEY}" - - response = requests.post( - url=ENDPOINT, - json=data, - headers=headers - ) - - return response - -# Threading -# def startBackgroundThread(): -# thread = threading.Thread(target=main, daemon=True) -# thread.start() - -def main(): - while True: - logger.info(f"Starting Discover in {DISCOVERY_INTERVAL}...") - - sleep(DISCOVERY_INTERVAL) - - logger.info("Starting Discovery") - - globalDiff = updateEnabledContainers() - - logger.debug("Cleaning Diff") - - globalDiff = cleanDiff(globalDiff) - - # Check if there is actually any Diff - if globalDiff.get("removed").count() + globalDiff.get("added").count() <= 0: - logger.debug("No Changes were made, skipping...") - - return - - logger.info(f"Sending Diff to {ENDPOINT} with{"out" if not ENDPOINT_KEY else ""} Auth") - - response = sendDiffToEndpoint(globalDiff) - - if not response.ok: - logger.error(f"Endpoint responded with {response.status_code} NOT OK") - -if __name__ == '__main__': - logger.setLevel(level=LOG_LEVEL) - - if not SERVER_NAME or not ENDPOINT: - if not SERVER_NAME: - logger.error(f"No SERVER_NAME set") - if not ENDPOINT: - logger.error(f"No ENDPOINT set") - - exitContainer() - - if not DISCOVERY_INTERVAL: - logger.warning(f"No DISCOVERY_INTERVAL set, using 30sec as default") - DISCOVERY_INTERVAL = 30 - - if not ENDPOINT_KEY or ENDPOINT_KEY == "": - logger.warning(f"No ENDPOINT_KEY set, requests may be denied") - - main() \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index a7112bb..80dd626 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,8 +3,10 @@ services: image: ghcr.io/codeshelldev/servdiscovery:latest container_name: service-discovery environment: - ENDPOINT: https://mydomain.com/ENDPOINT + ENDPOINT: https://mydomain.com/discover ENDPOINT_KEY: MY_VERY_SECURE_KEY + DISCOVERY_INTERVAL: 60 + ALIVE_INTERVAL: 60 SERVER_NAME: server-1 volumes: - /var/run/docker.sock:/var/run/docker.sock diff --git a/examples/haproxy/haproxy.cfg b/examples/haproxy/haproxy.cfg new file mode 100644 index 0000000..69a30fb --- /dev/null +++ b/examples/haproxy/haproxy.cfg @@ -0,0 +1,20 @@ +global + lua-prepend-path /tmp/haproxy/lua/?.lua + # Load discovery script + lua-load /tmp/haproxy/lua/snidiscovery.lua + # Load json helper script + # Download here: https://github.com/rxi/json.lua/blob/master/json.lua + lua-load /tmp/haproxy/lua/json.lua + +frontend sni-discovery_frontend + bind *:4676 + mode http + option http-keep-alive + + acl is_private_range src 192.168.0.0/16 172.16.0.0/12 + acl is_authenticated hdr(Authorization) -i "Bearer ENDPOINT_KEY" + + http-request deny if !is_private_range || !is_authenticated + + # Apply discovery routing + http-request use-service lua.update_mapping diff --git a/examples/haproxy/haproxy.lua b/examples/haproxy/haproxy.lua new file mode 100644 index 0000000..c2a5e23 --- /dev/null +++ b/examples/haproxy/haproxy.lua @@ -0,0 +1,176 @@ +local json = require("json") + +-- delete Host after some time (add a few seconds to remove any race-conditioning) +local host_server_alive_time = 65 + +-- interval for stale cleanup +local host_server_stale_cleanup_interval = 5 + +-- mapping table: host.domain.com -> [ { serverName: "server1", aliveUntil: 600 }, { serverName: "server2", aliveUntil: 300 }, ... ] +local host_servers = host_servers or {} + +-- helper to update host-server mapping +local function apply_diff(serverName, diff) + -- Add hosts + if diff.added then + local currentTime = core.now() + + for _, host in ipairs(diff.added) do + host_servers[host] = host_servers[host] or {} + + local exists = false + for _, server in ipairs(host_servers[host]) do + if server.serverName == serverName then exists = true break end + end + + local serverObject = { + serverName = serverName, + aliveUntil = currentTime.sec + host_server_alive_time + } + + if not exists then + core.Info("Added Server "..serverName.." in "..host..". Reason: Diff-Added") + + table.insert(host_servers[host], serverObject) + else + -- Update Alive Time + for i = 1, #host_servers[host] do + local server = host_servers[host][i] + + if server.serverName == serverName then + core.Info("Bumped "..serverName.."'s Alive Time in "..host.." by "..host_server_alive_time..". Reason: Diff-Added + Update") + + host_servers[host][i] = serverObject + end + end + end + end + end + + -- Remove hosts + if diff.removed then + for _, host in ipairs(diff.removed) do + if host_servers[host] then + for i = #host_servers[host], 1, -1 do + local server = host_servers[host][i] + + if server.serverName == serverName then + core.Info("Removed Server "..serverName.." from "..host..". Reason: Diff-Removed") + + table.remove(host_servers[host], i) + end + end + if #host_servers[host] == 0 then + host_servers[host] = nil + end + end + end + end +end + +-- helper to send HTTP Text Responses +local function sendTextResponse(txn, msg, status) + txn:set_status(status) + txn:add_header("Content-Type", "text/plain") + txn:start_response() + txn:send(msg) + return +end + +-- Stale Handler +local function removeStale() + local currentTime = core.now() + + for host, servers in pairs(host_servers) do + for i = #servers, 1, -1 do + local server = servers[i] + + if server.aliveUntil < currentTime.sec then + core.Info("Removed Server "..server.serverName.." from "..host..". Reason: Stale") + + table.remove(host_servers[host], i) + end + end + if #host_servers[host] == 0 then + host_servers[host] = nil + end + end +end + +function cleanup_stales() + while true do + core.msleep(host_server_stale_cleanup_interval * 1000) + + removeStale() + end +end + +-- register Stale-Cleanup task +core.register_task(cleanup_stales) + +-- HTTP endpoint handler +function handle_update(txn) + local body = txn:receive() + if not body or #body == 0 then + sendTextResponse(txn, "Empty body request", 400) + return + end + + local payload, err = json.decode(body) + if not payload then + sendTextResponse(txn, "Invalid JSON "..(err or "unknown"), 400) + return + end + + if not payload.serverName or not payload.diff then + sendTextResponse(txn, "Missing serverName or diff", 400) + return + end + + apply_diff(payload.serverName, payload.diff) + + sendTextResponse(txn, "Mapping successfully updated!", 200) + + core.Info("Mapping successfully updated!") +end + +-- register the HTTP endpoint +core.register_service("update_mapping", "http", handle_update) + +-- resolve backend for a given host +function resolve_backend(txn) + local host = txn:get_var("txn.sni") + + if host == nil or host == "" then + txn:set_var("txn.backend", "default_backend") + + core.Alert("No SNI found, using default backend") + + return + end + + local servers = host_servers[host] + + if not servers or #servers == 0 then + txn:set_var("txn.backend", "default_backend") + + core.Info("No Server found, using default backend") + elseif #servers == 1 then + txn:set_var("txn.backend", servers[1].serverName.."_backend") + + core.Info("Selected "..servers[1].serverName.." as Backend") + elseif #servers >= 1 then + local server_names = {} + for i, server in ipairs(servers) do + server_names[i] = server.serverName + end + + -- multiple servers -> failover backend + local backend_name = table.concat(server_names, "_") .. "_failover_backend" + txn:set_var("txn.backend", backend_name) + + core.Info("Selected "..backend_name.." as Failover Backend") + end +end + +core.register_action("resolve_backend", {"tcp-req", "http-req" }, resolve_backend) diff --git a/examples/whoami.docker-compose.yaml b/examples/whoami.docker-compose.yaml index 0f0e8cf..991ffc0 100644 --- a/examples/whoami.docker-compose.yaml +++ b/examples/whoami.docker-compose.yaml @@ -10,7 +10,7 @@ services: - traefik.http.routers.whoami.tls.certresolver=cloudflare - traefik.http.routers.whoami.service=whoami-svc - traefik.http.services.whoami-svc.loadbalancer.server.port=80 - # Enable Discovery on this Container + # Enable Discovery for this Container - discovery.enable=true networks: - traefik @@ -18,4 +18,3 @@ services: networks: traefik: external: true - diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f4ceb8 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module github.com/codeshelldev/servdiscovery + +go 1.25.5 + +require ( + github.com/codeshelldev/gotl v0.0.4 + github.com/moby/moby/api v1.52.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) + +require ( + github.com/moby/moby/client v0.2.1 + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7a806e1 --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/codeshelldev/gotl v0.0.4 h1:W2cup8Pw9LzFLxmS5QUzY+NSE3ZgiRSUM7FiGd6qJrI= +github.com/codeshelldev/gotl v0.0.4/go.mod h1:Mfb+Lb+DV3DUXdA1sixJb2pLawaJGGFFeC29gUZQLcg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= +github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k= +github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internals/config/config.go b/internals/config/config.go new file mode 100644 index 0000000..79647e1 --- /dev/null +++ b/internals/config/config.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" + "strconv" + + "github.com/codeshelldev/gotl/pkg/logger" + "github.com/codeshelldev/servdiscovery/internals/config/structure" +) + +var ENV = &structure.ENV{ + DISCOVERY_INTERVAL: 60, + ALIVE_INTERVAL: 120, +} + +func Load() { + ENV.LOG_LEVEL = os.Getenv("LOG_LEVEL") + + discoveryInterval, err := strconv.Atoi(os.Getenv("DISCOVERY_INTERVAL")) + + if err != nil { + if discoveryInterval > 0 { + ENV.DISCOVERY_INTERVAL = discoveryInterval + } + } + + aliveInterval, err := strconv.Atoi(os.Getenv("ALIVE_INTERVAL")) + + if err != nil { + if aliveInterval > 0 { + ENV.ALIVE_INTERVAL = aliveInterval + } + } + + ENV.SERVER_NAME = os.Getenv("SERVER_NAME") + ENV.ENDPOINT = os.Getenv("ENDPOINT") + ENV.ENDPOINT_KEY = os.Getenv("ENDPOINT_KEY") +} + +func Log() { + logger.Dev("Loaded Environment:", ENV) +} \ No newline at end of file diff --git a/internals/config/structure/structure.go b/internals/config/structure/structure.go new file mode 100644 index 0000000..e9a96db --- /dev/null +++ b/internals/config/structure/structure.go @@ -0,0 +1,10 @@ +package structure + +type ENV struct { + LOG_LEVEL string + SERVER_NAME string + ENDPOINT string + ENDPOINT_KEY string + DISCOVERY_INTERVAL int + ALIVE_INTERVAL int +} \ No newline at end of file diff --git a/internals/discovery/diff.go b/internals/discovery/diff.go new file mode 100644 index 0000000..fe069b7 --- /dev/null +++ b/internals/discovery/diff.go @@ -0,0 +1,91 @@ +package discovery + +import ( + "strings" + + "github.com/codeshelldev/gotl/pkg/logger" +) + +type Diff[T any] struct { + Added []T + Removed []T +} + +func (diff *Diff[T]) Merge(other Diff[T]) { + diff.Added = append(diff.Added, other.Added...) + diff.Removed = append(diff.Removed, other.Removed...) +} + +func CleanDiff[T comparable](diff Diff[T]) Diff[T] { + removedMap := map[T]struct{}{} + addedMap := map[T]struct{}{} + + for _, r := range diff.Removed { + removedMap[r] = struct{}{} + } + for _, a := range diff.Added { + addedMap[a] = struct{}{} + } + + // Remove items that are in both + for removed := range removedMap { + _, exists := addedMap[removed] + if exists { + delete(removedMap, removed) + delete(addedMap, removed) + } + } + + cleaned := Diff[T]{} + + for removed := range removedMap { + cleaned.Removed = append(cleaned.Removed, removed) + } + for added := range addedMap { + cleaned.Added = append(cleaned.Added, added) + } + + return cleaned +} + +func logDiff(id string, diff Diff[string]) { + if len(diff.Added) <= 0 && len(diff.Removed) <= 0 { + return + } + + addedStr := strings.Join(diff.Added, ",") + removedStr := strings.Join(diff.Removed, ",") + + logger.Debug("[", id, "] ", "(+) ", addedStr, " (-) ", removedStr) +} + +func GetDiff[T comparable](old, new []T) Diff[T] { + diff := Diff[T]{} + + oldMap := map[T]struct{}{} + newMap := map[T]struct{}{} + + for _, value := range old { + oldMap[value] = struct{}{} + } + for _, value := range new { + newMap[value] = struct{}{} + } + + for value := range oldMap { + _, exists := newMap[value] + + if !exists { + diff.Removed = append(diff.Removed, value) + } + } + for value := range newMap { + _, exists := oldMap[value]; + + if !exists { + diff.Added = append(diff.Added, value) + } + } + + return diff +} \ No newline at end of file diff --git a/internals/discovery/discovery.go b/internals/discovery/discovery.go new file mode 100644 index 0000000..d12bc72 --- /dev/null +++ b/internals/discovery/discovery.go @@ -0,0 +1,245 @@ +package discovery + +import ( + "bytes" + "maps" + "net/http" + "regexp" + "slices" + "strings" + "time" + + "github.com/codeshelldev/gotl/pkg/jsonutils" + "github.com/codeshelldev/gotl/pkg/logger" + "github.com/codeshelldev/servdiscovery/internals/docker" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +var containerHosts = map[string][]string{} + +var containers []container.Summary + +func GetDiffDiscovery() Diff[string] { + logger.Debug("Starting discovery") + + diff, err := getContainerDiff() + + if err != nil { + logger.Error("Encountered error during discovery: ", err.Error()) + return Diff[string]{} + } + + logger.Debug("Cleaning diff") + + cleaned := CleanDiff(diff) + + return cleaned +} + +func GetAliveDiscovery() Diff[string] { + logger.Debug("Starting alive discovery") + + globalDiff := Diff[string]{ + Added: []string{}, + Removed: []string{}, + } + + newContainers, err := getEnabledContainers() + + if err != nil { + logger.Error("Encountered error during discovery: ", err.Error()) + return Diff[string]{} + } + + logger.Debug("Found ", len(newContainers), " enabled containers") + + for _, container := range newContainers { + router := getRouterHosts(container) + + seq := maps.Values(router) + hostSlices := slices.Collect(seq) + hosts := slices.Concat(hostSlices...) + + globalDiff.Added = append(globalDiff.Added, hosts...) + } + + cleaned := CleanDiff(globalDiff) + + return cleaned +} + +func SendDiff(serverName, endpoint, key string, diff Diff[string]) (*http.Response, error) { + payload := map[string]any{ + "serverName": serverName, + "diff": map[string]any{ + "added": diff.Added, + "removed": diff.Removed, + }, + } + + data, err := jsonutils.ToJsonSafe(payload) + if err != nil { + return nil, err + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewReader([]byte(data))) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + if key != "" { + req.Header.Set("Authorization", "Bearer " + key) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return resp, nil +} + +func getContainerDiff() (Diff[string], error) { + globalDiff := Diff[string]{ + Added: []string{}, + Removed: []string{}, + } + + newContainers, err := getEnabledContainers() + + if err != nil { + return Diff[string]{}, err + } + + containerDiff := diffContainers(containers, newContainers) + + containers = newContainers + + logger.Info("Found ", len(containers), " enabled containers") + + if len(containerDiff.Added) > 0 { + logger.Debug("Found ", len(containerDiff.Added), " added containers") + } + if len(containerDiff.Removed) > 0 { + logger.Debug("Found ", len(containerDiff.Removed), " removed containers") + } + + for _, container := range newContainers { + router := getRouterHosts(container) + + seq := maps.Values(router) + hostSlices := slices.Collect(seq) + hosts := slices.Concat(hostSlices...) + + old, exists := containerHosts[container.ID] + if exists { + diff := GetDiff(old, hosts) + + logDiff(container.Names[0], diff) + + globalDiff.Merge(diff) + } else { + logger.Info("Added ", container.Names[0]) + + globalDiff.Added = append(globalDiff.Added, hosts...) + + logger.Dev("!> With ", strings.Join(hosts, ",")) + } + + containerHosts[container.ID] = hosts + } + + for _, removed := range containerDiff.Removed { + host, exists := containerHosts[removed.ID] + + if exists { + globalDiff.Removed = append(globalDiff.Removed, host...) + + logger.Info("Removed ", removed.Names[0]) + + delete(containerHosts, removed.ID) + } + } + + return globalDiff, nil +} + +func diffContainers(old, new []container.Summary) Diff[container.Summary] { + oldIDs := make([]string, 0, len(old)) + newIDs := make([]string, 0, len(new)) + + oldContainers := map[string]container.Summary{} + newContainers := map[string]container.Summary{} + + for _, container := range old { + oldIDs = append(oldIDs, container.ID) + oldContainers[container.ID] = container + } + for _, container := range new { + newIDs = append(newIDs, container.ID) + newContainers[container.ID] = container + } + + idDiff := GetDiff(oldIDs, newIDs) + + var diff Diff[container.Summary] + + for _, added := range idDiff.Added { + diff.Added = append(diff.Added, newContainers[added]) + } + for _, removed := range idDiff.Removed { + diff.Removed = append(diff.Removed, oldContainers[removed]) + } + + return diff +} + +func getRouterHosts(container container.Summary) map[string][]string { + hosts := map[string][]string{} + + hostRegex, err := regexp.Compile(`Host\(\x60([^\x60]+)\x60\)`) + + if err != nil { + return nil + } + + routerRegex, err := regexp.Compile(`traefik\.http\.routers\.([A-Za-z0-9._-]+)\.rule`) + + if err != nil { + return nil + } + + for key, value := range container.Labels { + routerMatch := routerRegex.FindStringSubmatch(key) + if len(routerMatch) < 2 { + continue + } + router := routerMatch[1] + + matches := hostRegex.FindAllStringSubmatch(value, -1) + for _, match := range matches { + if len(match) >= 2 { + hosts[router] = append(hosts[router], match[1]) + } + } + } + + return hosts +} + +func getEnabledContainers() ([]container.Summary, error) { + filters := client.Filters{} + filters.Add("label", "discovery.enable=true") + + return docker.GetContainers(client.ContainerListOptions{ + Filters: filters, + }) +} \ No newline at end of file diff --git a/internals/docker/client.go b/internals/docker/client.go new file mode 100644 index 0000000..c7dc324 --- /dev/null +++ b/internals/docker/client.go @@ -0,0 +1,40 @@ +package docker + +import ( + "context" + "time" + + "github.com/codeshelldev/gotl/pkg/logger" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +var apiClient *client.Client + +func InitClient(options ...client.Opt) { + var err error + + if len(options) <= 0 { + options = append(options, client.WithHost("unix:///var/run/docker.sock")) + } + + apiClient, err = client.New(options...) + + if err != nil { + logger.Fatal("Could not connect to " + apiClient.DaemonHost() + ": ", err.Error()) + } + defer apiClient.Close() +} + +func GetContainers(options client.ContainerListOptions) ([]container.Summary, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := apiClient.ContainerList(ctx, options) + + if err != nil { + return []container.Summary{}, err + } + + return res.Items, nil +} \ No newline at end of file diff --git a/internals/docker/docker.go b/internals/docker/docker.go new file mode 100644 index 0000000..989c59c --- /dev/null +++ b/internals/docker/docker.go @@ -0,0 +1,30 @@ +package docker + +import ( + "os" + + "github.com/codeshelldev/gotl/pkg/docker" + log "github.com/codeshelldev/gotl/pkg/logger" +) + +func Init() { + log.Info("Running ", os.Getenv("IMAGE_TAG"), " Image") +} + +func Run(main func()) chan os.Signal { + return docker.Run(main) +} + +func Exit(code int) { + log.Info("Exiting...") + + docker.Exit(code) +} + +func Shutdown() { + log.Info("Shutdown signal received") + + log.Sync() + + log.Info("Server exited gracefully") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ae66ef3 --- /dev/null +++ b/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "sync" + "time" + + log "github.com/codeshelldev/gotl/pkg/logger" + "github.com/codeshelldev/servdiscovery/internals/config" + "github.com/codeshelldev/servdiscovery/internals/discovery" + "github.com/codeshelldev/servdiscovery/internals/docker" +) + +func main() { + config.Load() + + log.Init(config.ENV.LOG_LEVEL) + + docker.Init() + + log.Info("Initialized Logger with Level of ", log.Level()) + + if log.Level() == "dev" { + log.Dev("Welcome back Developer!") + } + + config.Log() + + docker.InitClient() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + + if config.ENV.DISCOVERY_INTERVAL <= 0 { + log.Info("Disabling diff discoveries") + return + } + + log.Debug("Started discovery loop") + + ticker := time.NewTicker(time.Duration(config.ENV.DISCOVERY_INTERVAL) * time.Second) + defer ticker.Stop() + + for range ticker.C { + process(discovery.GetDiffDiscovery()) + } + }() + + go func() { + defer wg.Done() + + if config.ENV.ALIVE_INTERVAL <= 0 { + log.Info("Disabling alive discoveries") + return + } + + log.Debug("Started alive discovery loop") + + ticker := time.NewTicker(time.Duration(config.ENV.ALIVE_INTERVAL) * time.Second) + defer ticker.Stop() + + for range ticker.C { + process(discovery.GetAliveDiscovery()) + } + }() + + stop := docker.Run(func() { + wg.Wait() + }) + + <-stop + docker.Shutdown() +} + +func process(diff discovery.Diff[string]) { + log.Dev("Received diff: ", diff) + + if len(diff.Added) <= 0 && len(diff.Removed) <= 0 { + log.Info("No changes detected, skipping...") + return + } + + withOrWithout := "out" + + if config.ENV.ENDPOINT_KEY != "" { + withOrWithout = "" + } + + log.Debug("Sending diff to ", config.ENV.ENDPOINT, " with", withOrWithout, " Auth") + + resp, err := discovery.SendDiff(config.ENV.SERVER_NAME, config.ENV.ENDPOINT, config.ENV.ENDPOINT_KEY, diff) + + if err != nil { + log.Error("Error sending diff: ", err.Error()) + return + } + + log.Debug("Endpoint responded with ", resp.Status) +}