Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ EASYPANEL_ENDPOINT=https://panel.example.com
EASYPANEL_API_KEY=your-easypanel-api-key
# Optional Go duration. Deploy calls can block while images pull (default: 5m).
EASYPANEL_HTTP_TIMEOUT=5m
# Optional. Set to 1 to skip the startup panel version/support-range probe.
# EASYPANEL_SKIP_VERSION_CHECK=1
GRPC_PORT=50051
GRPC_AUTH_TOKENS=client-token-1,client-token-2
DEFAULT_DOMAIN=xyz.easypanel.host
Expand Down
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Changelog

All notable changes to this project are documented here. Versions follow the
`vMAJOR.MINOR.PATCH` git tags that drive the release pipeline.

## v0.1.0

Supported Easypanel versions: **2.32.0 .. 2.33.1**, both bounds backed by pinned
route surfaces in `internal/easypanel/testdata/`.

### Breaking

- `GetServiceStatus` no longer returns environment variable values by default.
Keys are kept and values become `<redacted>`. Set `include_env: true` on the
request to get clear text. Easypanel stores secrets in the same env blob its
inspect routes return, and a `GRPC_AUTH_TOKENS` entry can read every project.

### Features

- `GetServiceStatus` now reports runtime state alongside stored configuration:
`status` (`running` / `stopped` / `unknown`), `running_containers`,
`containers[]` (id, name, image, state, status, created) and, for compose
services, `compose_services[]`. Compose services fall back to the running
container's image, which the panel's inspect route never provides.
- `GetLogs` gained the panel's remaining Loki filters: `stream`, `levels`,
`search`, `start` and `end`. `limit` is clamped to the panel's maximum of 1000
instead of being rejected as a validation error.
- Panel version support window is now explicit (`MinSupportedVersion`,
`MaxTestedVersion`). The gRPC server probes the live panel at startup and logs
whether it is inside that window. Skip with `EASYPANEL_SKIP_VERSION_CHECK=1`.
- New CLI commands: `settings panel-version` (panel version + supported range)
and `settings logs` (log aggregation status).
- `scripts/panel-surface.sh` + `scripts/extract-panel-surface.py` re-pin the panel
route surface from the official `easypanel/easypanel` images.

### Fixes

- `GetLogs` no longer surfaces the panel's opaque `[BAD_REQUEST] fetch failed`.
Easypanel serves logs only from its own Loki deployment, which exists only when
log aggregation is enabled (Settings -> Logs, licensed feature). The RPC now
prechecks `logs.getSettings` and returns `FailedPrecondition` naming the cause
and the fix; an unreachable-but-enabled log store is reported separately.
- Invalid `stream` values are rejected locally with `InvalidArgument` rather than
producing a panel validation error.
- A failed container query no longer fails the whole `GetServiceStatus` call; it
degrades to `status: "unknown"` with configuration intact.

### Internal

- Every panel route is declared in `internal/easypanel/routes.go` instead of being
spread across string literals.
- Pinned panel route surfaces for 2.32.0, 2.32.2, 2.33.0 and 2.33.1 with tests
asserting every route used still exists, that its input shape is unchanged across
the supported range, and that the declared window matches the pinned files.
- New unit tests cover log filter forwarding, limit clamping, precondition
mapping, runtime status assembly, env redaction, version parsing and the panel
version probe. New live test `TestGRPC_RuntimeStatusAndLogs`.
- Enabling log aggregation now takes effect on the next `GetLogs` call: the
disabled precheck invalidates the cached settings instead of waiting out the TTL.
- `EASYPANEL_SKIP_VERSION_CHECK` is parsed as a boolean, so `=0` no longer skips
the probe.

## v0.0.2

- Apply Easypanel config changes via redeploy.
- Full resource limits (CPU/memory reservation) plus `UpdateDeploy` RPC.
- Docker images and release pipeline with `dev` / release tagging.
- Deployment guide.

## v0.0.1

- Initial release: Easypanel tRPC CLI and gRPC PaaS adapter with bearer token
auth, container and compose deployments, domain management.
140 changes: 132 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ A Go toolkit for managing [Easypanel](https://easypanel.io) deployments — both
- **Domain Management** — Default subdomains + custom domains with HTTPS
- **Lifecycle Control** — Start, stop, restart, scale, update env/resources
- **Resource Limits** — CPU and memory constraints per service
- **Runtime Status** — Live container state, image, and compose sub-services per service
- **Version Pinned** — Panel route surface pinned per Easypanel release, checked by tests and at startup

## Quick Start

Expand Down Expand Up @@ -180,7 +182,7 @@ Tokens are configured via `GRPC_AUTH_TOKENS` (comma-separated). Tokens grant pan
| `DeployContainer` | Deploy Docker image → create service + set image + env + deploy + auto-domain |
| `DeployCompose` | Deploy docker-compose stack (strips `ports:`, routes via `composeService`) |
| `DestroyService` | Remove service and all associated domains |
| `GetServiceStatus` | Service details: name, type, image, env, domains, deploy URL |
| `GetServiceStatus` | Service config (name, type, image, env, domains, deploy URL) plus runtime state (`status`, running containers, compose sub-services). Env values are redacted unless `include_env` is set |
| `ListServices` | All services in a project |
| `AddDomain` | Attach domain to service (supports compose sub-services) |
| `RemoveDomain` | Detach domain by ID |
Expand All @@ -189,7 +191,7 @@ Tokens are configured via `GRPC_AUTH_TOKENS` (comma-separated). Tokens grant pan
| `RestartService` | Restart service |
| `StopService` | Stop service |
| `StartService` | Start service |
| `GetLogs` | Fetch app/compose logs with structured entries and flat text |
| `GetLogs` | Fetch app/compose logs with structured entries and flat text. Requires panel log aggregation (see [Logs](#logs)) |
| `ScaleService` | Set replica count |
| `UpdateResources` | Set CPU/memory limits |
| `UpdateDeploy` | Set app replicas, zero-downtime behavior, and command |
Expand Down Expand Up @@ -281,9 +283,13 @@ grpcurl -plaintext -H "authorization: your-token" -d '{
│ ├── auth/
│ │ └── interceptor.go # gRPC auth interceptor (unary + stream)
│ ├── easypanel/
│ │ └── client.go # Reusable Easypanel HTTP client
│ │ ├── client.go # Reusable Easypanel HTTP client
│ │ ├── routes.go # Every panel route this project calls
│ │ ├── version.go # Panel version probe + supported range + log settings
│ │ └── testdata/ # Pinned panel route surfaces (2.32.2 / 2.33.0 / 2.33.1)
│ ├── server/
│ │ └── paas.go # PaaS gRPC service implementation
│ │ ├── paas.go # PaaS gRPC service implementation
│ │ └── logs.go # Panel log response flattening
│ └── version/
│ └── version.go # Build metadata injected via -ldflags
├── proto/
Expand All @@ -293,10 +299,14 @@ grpcurl -plaintext -H "authorization: your-token" -d '{
├── .github/workflows/
│ ├── ci.yml # PR checks: fmt, vet, test, build, image build
│ └── release.yml # master -> :dev images, tags -> release + binaries
├── scripts/
│ ├── panel-surface.sh # Re-pin panel route surfaces from official images
│ └── extract-panel-surface.py # Route/input extractor for the panel bundle
├── Dockerfile # Multi-stage, multi-arch (targets: server, cli)
├── Makefile # build / test / cross / docker targets
├── .dockerignore
├── grpc_e2e_test.go # E2E tests (live panel)
├── CHANGELOG.md # Release notes per version
├── .env.example # Environment template
├── .gitignore
├── go.mod
Expand All @@ -320,9 +330,122 @@ The gRPC server **automatically strips `ports:` directives** from compose conten

---

## Service status

`GetServiceStatus` merges the panel's stored configuration with the live Docker
view, so a single call answers both "how is it configured" and "is it up":

```jsonc
{
"name": "hermes-a1",
"type": "compose",
"enabled": true,
"image": "nousresearch/hermes-agent:v2026.8.3",
"env": "API_KEY=<redacted>", // values hidden unless include_env is set
"domains": ["pods-hermes-a1-agent.cv911b.easypanel.host"],
"status": "running", // running | stopped | unknown
"runningContainers": 1,
"containers": [{
"id": "21bb7f989f0f",
"name": "pods_hermes-a1-agent-1",
"image": "nousresearch/hermes-agent:v2026.8.3",
"state": "running",
"status": "Up 47 hours"
}],
"composeServices": ["agent"] // compose only
}
```

The panel reports only running containers, so an empty list means stopped. If the
panel refuses the container query the RPC still returns the configuration with
`status: "unknown"` instead of failing. Compose services have no configured image,
so `image` falls back to the running container's image.

### Env values are redacted by default

Easypanel stores secrets in the same env blob its inspect routes return, and a
gRPC token is panel-wide: any caller can read any project. `GetServiceStatus`
therefore returns keys with `<redacted>` values. Set `include_env: true` to get
clear text:

```bash
grpcurl -plaintext -H "authorization: Bearer $TOKEN" \
-d '{"project":"demo","service":"api","serviceType":"app","includeEnv":true}' \
localhost:50051 paas.PaaS/GetServiceStatus
```

---

## Supported Easypanel versions

| | Version |
|---|---|
| Minimum supported | `2.32.0` |
| Newest tested | `2.33.1` |

The panel's route surface is pinned in `internal/easypanel/testdata/panel-surface-*.json`,
extracted from the official `easypanel/easypanel` images. Every route this project
calls is listed in `internal/easypanel/routes.go`, and
`TestRequiredRoutesExistInEverySupportedPanel` asserts each one exists in every
pinned release. Both bounds of the window are backed by a pinned surface: `2.32.0`,
`2.32.2`, `2.33.0` and `2.33.1` expose the same routes with identical input fields
(374 procedures in `2.32.0`, 375 from `2.32.2` on, the addition being unrelated to
this project), which is why one code path covers the range.

The gRPC server probes the live panel version at startup (`GET /api/openapi.json`,
the one version route present in all supported releases) and logs whether it falls
inside that window. Set `EASYPANEL_SKIP_VERSION_CHECK=1` to skip the probe.

```bash
# Which panel am I talking to?
deploy-everything settings panel-version

# Re-pin the route surface after a panel upgrade (needs docker + python3)
scripts/panel-surface.sh 2.32.0 2.32.2 2.33.0 2.33.1
go test ./internal/easypanel/ -run TestRequiredRoutes -v
```

After adding a newer release, bump `MaxTestedVersion` in
`internal/easypanel/version.go` to match the newest pinned file. `MinSupportedVersion`
must equal the oldest pinned file; the tests enforce both.

---

## Logs

Easypanel serves service logs from its own Loki deployment and has no other log
source: no `docker logs` route exists in the panel API. Loki and Promtail are only
deployed when log aggregation is enabled under **Settings -> Logs**, which the panel
gates behind a license with advanced monitoring.

When aggregation is off, the panel answers log queries with an opaque
`[BAD_REQUEST] fetch failed`. `GetLogs` prechecks `logs.getSettings` and returns
`FailedPrecondition` with the reason instead:

```text
panel log aggregation is disabled, so Loki is not deployed and the panel has no
log source. Enable it in the Easypanel UI under Settings -> Logs (requires a
license with advanced monitoring), then retry GetLogs.
```

Check the current state with:

```bash
deploy-everything settings logs
```

`GetLogs` filters map straight onto the panel's Loki query: `stream`
(`stdout`/`stderr`), `levels`, `search`, `start`, `end`, and `limit` (clamped to the
panel's maximum of 1000). Supplying `start` switches the query to oldest-first.

---

## Testing

```bash
# Unit tests (no panel needed): route surface, version parsing, log/status mapping
go test ./internal/... ./cmd/... -count=1

# Run E2E tests (requires live Easypanel instance)
go test -v -run "TestGRPC_" -timeout 180s

Expand All @@ -331,6 +454,7 @@ go test -v -run TestGRPC_AuthReject -timeout 30s
go test -v -run TestGRPC_DeployContainerE2E -timeout 120s
go test -v -run TestGRPC_DeployComposeE2E -timeout 120s
go test -v -run TestGRPC_UpdateResources -timeout 60s
go test -v -run TestGRPC_RuntimeStatusAndLogs -timeout 120s
```

Tests create real services on the panel, verify HTTP reachability, and clean up after themselves.
Expand Down Expand Up @@ -382,11 +506,11 @@ One `Dockerfile`, two final stages, both distroless + static (`CGO_ENABLED=0`),
```bash
# Run the gRPC server
docker run --rm -p 50051:50051 --env-file .env \
ghcr.io/igun997/deploy-everything:0.0.2
ghcr.io/igun997/deploy-everything:0.1.0

# Run a CLI command
docker run --rm --env-file .env \
ghcr.io/igun997/deploy-everything-cli:0.0.2 projects list
ghcr.io/igun997/deploy-everything-cli:0.1.0 projects list

# Bleeding edge (current master)
docker run --rm --env-file .env \
Expand All @@ -407,13 +531,13 @@ docker run --rm --env-file .env \
| Git event | Image tags |
|-----------|-----------|
| push/merge to `master` | `dev`, `dev-<short-sha>` |
| tag `v0.0.2` | `0.0.2`, `0.0`, `0`, `latest` |
| tag `v0.1.0` | `0.1.0`, `0.1`, `0`, `latest` |
| tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
| pre-release tag `v1.2.3-rc1` | `1.2.3-rc1` only (no `latest`, no `1.2`/`1`) |

So `:dev` always points at the current `master`, `:latest` always points at the newest stable release.

The leading `v` is stripped from image tags: git tag `v0.0.2` produces image tag `0.0.2`. The binaries still report the full `v0.0.2` from `version`.
The leading `v` is stripped from image tags: git tag `v0.1.0` produces image tag `0.1.0`. The binaries still report the full `v0.1.0` from `version`.

### Cutting a release

Expand Down
43 changes: 43 additions & 0 deletions cmd/grpc-server/main.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package main

import (
"context"
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"time"

"github.com/igun997/deploy-everything/internal/auth"
"github.com/igun997/deploy-everything/internal/easypanel"
Expand Down Expand Up @@ -36,6 +39,13 @@ func main() {
// Easypanel client
ep := easypanel.NewClient(endpoint, token)

// Probe the panel version so route drift shows up at boot instead of on the
// first failing RPC. Never fatal: a slow or briefly unreachable panel should
// not stop the server from serving.
if !envBool("EASYPANEL_SKIP_VERSION_CHECK") {
reportPanelVersion(ep, endpoint)
}
Comment on lines +42 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Only skip the version probe when the value is 1.

EASYPANEL_SKIP_VERSION_CHECK=0 currently skips the probe. This conflicts with the documented =1 contract and can hide an unsupported panel version.

Proposed fix
-	if os.Getenv("EASYPANEL_SKIP_VERSION_CHECK") == "" {
+	if os.Getenv("EASYPANEL_SKIP_VERSION_CHECK") != "1" {
 		reportPanelVersion(ep, endpoint)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Probe the panel version so route drift shows up at boot instead of on the
// first failing RPC. Never fatal: a slow or briefly unreachable panel should
// not stop the server from serving.
if os.Getenv("EASYPANEL_SKIP_VERSION_CHECK") == "" {
reportPanelVersion(ep, endpoint)
}
// Probe the panel version so route drift shows up at boot instead of on the
// first failing RPC. Never fatal: a slow or briefly unreachable panel should
// not stop the server from serving.
if os.Getenv("EASYPANEL_SKIP_VERSION_CHECK") != "1" {
reportPanelVersion(ep, endpoint)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/grpc-server/main.go` around lines 41 - 46, Update the
EASYPANEL_SKIP_VERSION_CHECK condition around reportPanelVersion so the probe is
skipped only when the environment value equals "1"; run reportPanelVersion for
unset, "0", and all other values.


// Auth tokens
tokens := strings.Split(grpcTokens, ",")
for i := range tokens {
Expand Down Expand Up @@ -68,6 +78,39 @@ func main() {
}
}

// envBool reads a boolean switch. Anything Go's parser rejects (including an
// unset value) counts as false, so EASYPANEL_SKIP_VERSION_CHECK=0 keeps the
// probe enabled.
func envBool(key string) bool {
v, err := strconv.ParseBool(os.Getenv(key))
return err == nil && v
}

// reportPanelVersion logs the panel version and whether it falls inside the
// verified support window. Set EASYPANEL_SKIP_VERSION_CHECK to skip the probe.
func reportPanelVersion(ep *easypanel.Client, endpoint string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

ver, err := ep.PanelVersion(ctx)
if err != nil {
log.Printf("warning: could not read Easypanel version from %s: %v", endpoint, err)
return
}

tooOld, untested := easypanel.SupportRange(ver)
switch {
case tooOld:
log.Printf("warning: Easypanel %s is older than the supported minimum %s; routes may be missing",
ver, easypanel.MinSupportedVersion)
case untested:
log.Printf("warning: Easypanel %s is newer than the newest tested release %s; run scripts/panel-surface.sh to refresh the pinned route surface",
ver, easypanel.MaxTestedVersion)
default:
log.Printf("Easypanel %s (supported range %s..%s)", ver, easypanel.MinSupportedVersion, easypanel.MaxTestedVersion)
}
}

func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
Expand Down
22 changes: 22 additions & 0 deletions cmd/grpc-server/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package main

import "testing"

func TestEnvBool(t *testing.T) {
cases := map[string]bool{
"": false,
"0": false,
"false": false,
"no": false, // not a Go bool literal, so it stays off
"1": true,
"true": true,
"TRUE": true,
"t": true,
}
for value, want := range cases {
t.Setenv("EASYPANEL_SKIP_VERSION_CHECK", value)
if got := envBool("EASYPANEL_SKIP_VERSION_CHECK"); got != want {
t.Errorf("envBool(%q) = %v, want %v", value, got, want)
}
}
}
Loading