diff --git a/.env.example b/.env.example index 7b19f08..3cd1b42 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ 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 GRPC_PORT=50051 GRPC_AUTH_TOKENS=client-token-1,client-token-2 DEFAULT_DOMAIN=xyz.easypanel.host diff --git a/README.md b/README.md index 3e9320b..c819c66 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ cp .env.example .env |----------|----------|-------------| | `EASYPANEL_ENDPOINT` | Yes | Easypanel panel URL (e.g. `https://panel.example.com`) | | `EASYPANEL_API_KEY` | Yes | Easypanel API key | +| `EASYPANEL_HTTP_TIMEOUT` | No | Panel request timeout as Go duration (default `5m`; deploy calls block while images pull) | | `GRPC_PORT` | No | gRPC listen port (default: `50051`) | | `GRPC_AUTH_TOKENS` | Yes* | Comma-separated bearer tokens for gRPC clients | | `DEFAULT_DOMAIN` | Yes* | Easypanel wildcard domain (e.g. `xyz.easypanel.host`) | @@ -105,12 +106,20 @@ export EASYPANEL_ENDPOINT EASYPANEL_API_KEY ./deploy-everything services restart ./deploy-everything services inspect -# Environment -./deploy-everything services set-env "KEY1=val1\nKEY2=val2" +# Environment (saves config, then automatically redeploys app/compose) +./deploy-everything services set-env "KEY1=val1" "KEY2=val2" -# Compose -./deploy-everything services compose-set -./deploy-everything services compose-deploy +# App sources (all save, then automatically redeploy) +./deploy-everything services set-git [--path /] +./deploy-everything services set-github [--path /] +./deploy-everything services set-dockerfile '' + +# Compose sources (save, then automatically redeploy) +./deploy-everything services set-source-inline docker-compose.yml '' +./deploy-everything services set-source-git-compose docker-compose.yml [--root-path /] + +# Logs +./deploy-everything services logs [--type app|compose] [--compose-service web] [--limit 200] # WordPress ./deploy-everything wordpress create @@ -121,8 +130,11 @@ export EASYPANEL_ENDPOINT EASYPANEL_API_KEY ```bash ./deploy-everything domains list -./deploy-everything domains create [--port 80] [--https] -./deploy-everything domains delete +./deploy-everything domains create [--type app|compose] [--compose-service web] [--port 80] [--https] +./deploy-everything domains delete + +# Domain create/delete automatically redeploys the target app/compose service. +# Easypanel applies domain routing only while generating deployment config. ``` ### Other Commands @@ -176,7 +188,7 @@ Tokens are configured via `GRPC_AUTH_TOKENS` (comma-separated for multi-tenant). | `RestartService` | Restart service | | `StopService` | Stop service | | `StartService` | Start service | -| `GetLogs` | Fetch service logs | +| `GetLogs` | Fetch app/compose logs with structured entries and flat text | | `ScaleService` | Set replica count | | `UpdateResources` | Set CPU/memory limits | diff --git a/cmd/domains.go b/cmd/domains.go index feeb2d2..ef97834 100644 --- a/cmd/domains.go +++ b/cmd/domains.go @@ -31,6 +31,14 @@ func init() { domainsCmd.AddCommand(domainsListCmd) domainsCmd.AddCommand(domainsCreateCmd) domainsCmd.AddCommand(domainsDeleteCmd) + + domainsCreateCmd.Flags().Int("port", 80, "Container port to route to") + domainsCreateCmd.Flags().Bool("https", true, "Serve over HTTPS") + domainsCreateCmd.Flags().Bool("wildcard", false, "Treat host as a wildcard domain") + domainsCreateCmd.Flags().String("path", "/", "Request path to route") + domainsCreateCmd.Flags().String("certificate-resolver", "letsencrypt", "Traefik certificate resolver") + domainsCreateCmd.Flags().String("compose-service", "", "Route to an internal compose service") + domainsCreateCmd.Flags().String("type", "app", "Service type to redeploy: app or compose") } var domainsListCmd = &cobra.Command{ @@ -59,47 +67,85 @@ var domainsListCmd = &cobra.Command{ var domainsCreateCmd = &cobra.Command{ Use: "create [project] [service] [host]", - Short: "Create domain pointing to service (port 80)", + Short: "Create domain pointing to service", Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() + port, _ := cmd.Flags().GetInt("port") + https, _ := cmd.Flags().GetBool("https") + wildcard, _ := cmd.Flags().GetBool("wildcard") + path, _ := cmd.Flags().GetString("path") + resolver, _ := cmd.Flags().GetString("certificate-resolver") + composeService, _ := cmd.Flags().GetString("compose-service") + svcType, _ := cmd.Flags().GetString("type") + if composeService != "" { + svcType = "compose" + } + if svcType != "app" && svcType != "compose" { + return fmt.Errorf("domain redeploy type must be app or compose, got %q", svcType) + } + + destination := map[string]any{ + "protocol": "http", + "port": port, + "path": path, + "projectName": args[0], + "serviceName": args[1], + } + if composeService != "" { + destination["composeService"] = composeService + } + input := map[string]any{ "id": args[2], - "https": true, + "https": https, "host": args[2], - "path": "/", + "path": path, "middlewares": []string{}, - "certificateResolver": "letsencrypt", - "destinationType": "service", - "serviceDestination": map[string]any{ - "protocol": "http", - "port": 80, - "path": "/", - "projectName": args[0], - "serviceName": args[1], - }, + "certificateResolver": resolver, + // Panel schema requires wildcard; omitting it fails validation. + "wildcard": wildcard, + "destinationType": "service", + "serviceDestination": destination, } err := c.call(context.Background(), "/api/trpc/domains.createDomain", input, nil) if err != nil { return err } - fmt.Printf("Domain created: %s -> %s/%s\n", args[2], args[0], args[1]) + if err := redeployAfterConfig(c, args[0], args[1], svcType); err != nil { + return fmt.Errorf("apply domain: %w", err) + } + fmt.Printf("Domain created and deployed: %s -> %s/%s\n", args[2], args[0], args[1]) return nil }, } var domainsDeleteCmd = &cobra.Command{ - Use: "delete [domain-id]", - Short: "Delete a domain", - Args: cobra.ExactArgs(1), + Use: "delete [project] [service] [type] [domain-id]", + Short: "Delete a domain and redeploy its app or compose service", + Args: cobra.ExactArgs(4), RunE: func(cmd *cobra.Command, args []string) error { + svcType := args[2] + if svcType != "app" && svcType != "compose" { + return fmt.Errorf("domain redeploy type must be app or compose, got %q", svcType) + } + c := newPanelClient() - input := map[string]string{"id": args[0]} - err := c.call(context.Background(), "/api/trpc/domains.deleteDomain", input, nil) - if err != nil { + input := map[string]string{"id": args[3]} + if err := c.call(context.Background(), "/api/trpc/domains.deleteDomain", input, nil); err != nil { return err } - fmt.Printf("Deleted domain: %s\n", args[0]) + var deployErr error + for attempt := 0; attempt < 2; attempt++ { + deployErr = redeployAfterConfig(c, args[0], args[1], svcType) + if deployErr == nil { + break + } + } + if deployErr != nil { + return fmt.Errorf("domain %s was deleted, but routing redeploy failed after 2 attempts: %w; run `services deploy %s %s %s` manually", args[3], deployErr, args[0], args[1], svcType) + } + fmt.Printf("Deleted domain and deployed: %s\n", args[3]) return nil }, } diff --git a/cmd/panel_client.go b/cmd/panel_client.go index 348a1bb..ed47be6 100644 --- a/cmd/panel_client.go +++ b/cmd/panel_client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "os" "time" ) @@ -18,11 +19,18 @@ type panelClient struct { } func newPanelClient() *panelClient { + timeout := 5 * time.Minute + if raw := os.Getenv("EASYPANEL_HTTP_TIMEOUT"); raw != "" { + if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 { + timeout = parsed + } + } return &panelClient{ baseURL: getEndpoint(), token: getAPIKey(), httpClient: &http.Client{ - Timeout: 30 * time.Second, + // deployService blocks while images pull/start; 30s was too short. + Timeout: timeout, }, } } diff --git a/cmd/services.go b/cmd/services.go index 2921b40..b7af5f7 100644 --- a/cmd/services.go +++ b/cmd/services.go @@ -2,12 +2,24 @@ package cmd import ( "context" + "encoding/json" "fmt" + "sort" "strings" "github.com/spf13/cobra" ) +// isDatabaseType reports whether the panel exposes database-only routes +// (exposeService, enableService, updateCredentials) for this service type. +func isDatabaseType(svcType string) bool { + switch svcType { + case "mysql", "mariadb", "postgres", "mongo", "redis": + return true + } + return false +} + func init() { rootCmd.AddCommand(servicesCmd) } @@ -44,6 +56,18 @@ func init() { // Compose-specific servicesCmd.AddCommand(servicesSetSourceInlineCmd) servicesCmd.AddCommand(servicesSetSourceGitComposeCmd) + + // Panel schema requires `path` on both git source routes; default to repo root. + servicesSetGitCmd.Flags().String("path", "/", "Path inside the repo to build from") + servicesSetGitCmd.Flags().Bool("auto-deploy", true, "Redeploy on push") + servicesSetGithubCmd.Flags().String("path", "/", "Path inside the repo to build from") + servicesSetGithubCmd.Flags().Bool("auto-deploy", true, "Redeploy on push") + // Panel schema requires `rootPath` on the compose git source route. + servicesSetSourceGitComposeCmd.Flags().String("root-path", "/", "Path inside the repo holding the compose file") + servicesSetSourceGitComposeCmd.Flags().Bool("auto-deploy", true, "Redeploy on push") + servicesLogsCmd.Flags().String("type", "app", "Service type: app or compose") + servicesLogsCmd.Flags().String("compose-service", "", "Restrict compose logs to one internal compose service") + servicesLogsCmd.Flags().Int("limit", 200, "Max log lines") } func svcSelect(args []string) map[string]string { @@ -53,6 +77,34 @@ func svcSelect(args []string) map[string]string { } } +// redeployAfterConfig applies saved env/source/domain changes. Easypanel update +// routes only persist config; deployService regenerates runtime configuration. +func redeployAfterConfig(c *panelClient, project, service, svcType string) error { + if svcType != "app" && svcType != "compose" { + return nil + } + route := fmt.Sprintf("/api/trpc/services.%s.deployService", svcType) + return c.call(context.Background(), route, map[string]string{ + "projectName": project, + "serviceName": service, + }, nil) +} + +func currentAppDeploy(c *panelClient, project, service string) (map[string]any, error) { + var inspect TRPCResponse[map[string]any] + if err := c.call(context.Background(), "/api/trpc/services.app.inspectService", map[string]string{ + "projectName": project, + "serviceName": service, + }, &inspect); err != nil { + return nil, err + } + deploy, _ := inspect.JSON["deploy"].(map[string]any) + if deploy == nil { + deploy = map[string]any{} + } + return deploy, nil +} + var servicesCreateCmd = &cobra.Command{ Use: "create [project] [service] [type]", Short: "Create service (app|mysql|mariadb|postgres|mongo|redis|compose|wordpress|box)", @@ -204,6 +256,11 @@ var servicesExposeCmd = &cobra.Command{ Args: cobra.ExactArgs(4), RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() + // Only database service types have an exposeService route. app/compose + // return "Not found" from the panel, so fail with a usable message. + if !isDatabaseType(args[2]) { + return fmt.Errorf("expose is only supported for database types (mysql|mariadb|postgres|mongo|redis), got %q", args[2]) + } route := fmt.Sprintf("/api/trpc/services.%s.exposeService", args[2]) input := map[string]any{ "projectName": args[0], @@ -234,7 +291,10 @@ var servicesSetImageCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Image set: %s/%s -> %s\n", args[0], args[1], args[2]) + if err := redeployAfterConfig(c, args[0], args[1], "app"); err != nil { + return fmt.Errorf("apply image: %w", err) + } + fmt.Printf("Image set and deployed: %s/%s -> %s\n", args[0], args[1], args[2]) return nil }, } @@ -256,52 +316,72 @@ var servicesSetEnvCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Env updated: %s/%s\n", args[0], args[1]) + if err := redeployAfterConfig(c, args[0], args[1], args[2]); err != nil { + return fmt.Errorf("apply env: %w", err) + } + if args[2] == "app" || args[2] == "compose" { + fmt.Printf("Env updated and deployed: %s/%s\n", args[0], args[1]) + } else { + fmt.Printf("Env updated: %s/%s\n", args[0], args[1]) + } return nil }, } var servicesSetGitCmd = &cobra.Command{ - Use: "set-git [project] [service] [repo] [branch]", + Use: "set-git [project] [service] [repo] [ref]", Short: "Set Git source for app service", Args: cobra.ExactArgs(4), RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() + path, _ := cmd.Flags().GetString("path") + autoDeploy, _ := cmd.Flags().GetBool("auto-deploy") + // Panel schema requires repo, ref and path; "branch" is not a field. input := map[string]any{ "projectName": args[0], "serviceName": args[1], "repo": args[2], - "branch": args[3], - "autoDeploy": true, + "ref": args[3], + "path": path, + "autoDeploy": autoDeploy, } err := c.call(context.Background(), "/api/trpc/services.app.updateSourceGit", input, nil) if err != nil { return err } - fmt.Printf("Git source set: %s/%s -> %s@%s\n", args[0], args[1], args[2], args[3]) + if err := redeployAfterConfig(c, args[0], args[1], "app"); err != nil { + return fmt.Errorf("apply git source: %w", err) + } + fmt.Printf("Git source set and deployed: %s/%s -> %s@%s (path %s)\n", args[0], args[1], args[2], args[3], path) return nil }, } var servicesSetGithubCmd = &cobra.Command{ - Use: "set-github [project] [service] [owner] [repo] [branch]", + Use: "set-github [project] [service] [owner] [repo] [ref]", Short: "Set GitHub source for app service", Args: cobra.ExactArgs(5), RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() + path, _ := cmd.Flags().GetString("path") + autoDeploy, _ := cmd.Flags().GetBool("auto-deploy") input := map[string]any{ "projectName": args[0], "serviceName": args[1], "owner": args[2], "repo": args[3], - "branch": args[4], - "autoDeploy": true, + "ref": args[4], + "path": path, + "autoDeploy": autoDeploy, } err := c.call(context.Background(), "/api/trpc/services.app.updateSourceGithub", input, nil) if err != nil { return err } - fmt.Printf("GitHub source set: %s/%s -> %s/%s@%s\n", args[0], args[1], args[2], args[3], args[4]) + if err := redeployAfterConfig(c, args[0], args[1], "app"); err != nil { + return fmt.Errorf("apply GitHub source: %w", err) + } + fmt.Printf("GitHub source set and deployed: %s/%s -> %s/%s@%s (path %s)\n", args[0], args[1], args[2], args[3], args[4], path) return nil }, } @@ -321,7 +401,10 @@ var servicesSetDockerfileCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Dockerfile set: %s/%s\n", args[0], args[1]) + if err := redeployAfterConfig(c, args[0], args[1], "app"); err != nil { + return fmt.Errorf("apply Dockerfile source: %w", err) + } + fmt.Printf("Dockerfile set and deployed: %s/%s\n", args[0], args[1]) return nil }, } @@ -341,7 +424,10 @@ var servicesSetBuildCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Build type set: %s/%s -> %s\n", args[0], args[1], args[2]) + if err := redeployAfterConfig(c, args[0], args[1], "app"); err != nil { + return fmt.Errorf("apply build type: %w", err) + } + fmt.Printf("Build type set and deployed: %s/%s -> %s\n", args[0], args[1], args[2]) return nil }, } @@ -393,6 +479,9 @@ var servicesUpdateResourcesCmd = &cobra.Command{ Short: "Update resource limits (CPU cores, memory MB)", Args: cobra.ExactArgs(5), RunE: func(cmd *cobra.Command, args []string) error { + if args[2] == "compose" { + return fmt.Errorf("compose has no updateResources route; set resources per service in the compose file") + } c := newPanelClient() route := fmt.Sprintf("/api/trpc/services.%s.updateResources", args[2]) input := map[string]any{ @@ -407,7 +496,10 @@ var servicesUpdateResourcesCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Resources updated: %s/%s (cpu=%s, mem=%sMB)\n", args[0], args[1], args[3], args[4]) + if err := redeployAfterConfig(c, args[0], args[1], args[2]); err != nil { + return fmt.Errorf("apply resources: %w", err) + } + fmt.Printf("Resources updated and applied: %s/%s (cpu=%s, mem=%sMB)\n", args[0], args[1], args[3], args[4]) return nil }, } @@ -417,18 +509,28 @@ var servicesUpdateDeployCmd = &cobra.Command{ Short: "Update deploy config (replicas count)", Args: cobra.ExactArgs(4), RunE: func(cmd *cobra.Command, args []string) error { + if args[2] != "app" { + return fmt.Errorf("only app services have updateDeploy; set compose replicas in the compose file") + } c := newPanelClient() - route := fmt.Sprintf("/api/trpc/services.%s.updateDeploy", args[2]) + deploy, err := currentAppDeploy(c, args[0], args[1]) + if err != nil { + return fmt.Errorf("inspect deploy config: %w", err) + } + deploy["replicas"] = mustInt(args[3]) input := map[string]any{ "projectName": args[0], "serviceName": args[1], - "replicas": mustInt(args[3]), + "deploy": deploy, } - err := c.call(context.Background(), route, input, nil) + err = c.call(context.Background(), "/api/trpc/services.app.updateDeploy", input, nil) if err != nil { return err } - fmt.Printf("Deploy config updated: %s/%s (replicas=%s)\n", args[0], args[1], args[3]) + if err := redeployAfterConfig(c, args[0], args[1], args[2]); err != nil { + return fmt.Errorf("apply deploy config: %w", err) + } + fmt.Printf("Deploy config updated and applied: %s/%s (replicas=%s)\n", args[0], args[1], args[3]) return nil }, } @@ -476,20 +578,80 @@ var servicesLogsCmd = &cobra.Command{ Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() - input := map[string]string{ + svcType, _ := cmd.Flags().GetString("type") + composeService, _ := cmd.Flags().GetString("compose-service") + limit, _ := cmd.Flags().GetInt("limit") + + input := map[string]any{ "projectName": args[0], "serviceName": args[1], + "limit": limit, } - var resp TRPCResponse[string] - err := c.call(context.Background(), "/api/trpc/logs.getServiceLogs", input, &resp) - if err != nil { + // The panel serves compose stacks from a separate route. + route := "/api/trpc/logs.queryServiceLogs" + if svcType == "compose" { + route = "/api/trpc/logs.queryComposeServiceLogs" + if composeService != "" { + input["composeInternalService"] = composeService + } + } + + var resp TRPCResponse[logQueryResult] + if err := c.call(context.Background(), route, input, &resp); err != nil { return err } - fmt.Println(resp.JSON) + for _, line := range flattenLogLines(resp.JSON) { + fmt.Println(line) + } return nil }, } +// logQueryResult mirrors the panel's log store response: label-grouped streams +// each holding [timestamp, line] pairs. +type logQueryResult struct { + Entries []struct { + Stream map[string]string `json:"stream"` + Values [][]json.RawMessage `json:"values"` + } `json:"entries"` +} + +// flattenLogLines returns log lines oldest first. +func flattenLogLines(r logQueryResult) []string { + type row struct { + ts string + line string + } + var rows []row + for _, group := range r.Entries { + for _, pair := range group.Values { + if len(pair) < 2 { + continue + } + rows = append(rows, row{ts: rawJSONText(pair[0]), line: rawJSONText(pair[1])}) + } + } + sort.SliceStable(rows, func(i, j int) bool { + if len(rows[i].ts) != len(rows[j].ts) { + return len(rows[i].ts) < len(rows[j].ts) + } + return rows[i].ts < rows[j].ts + }) + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.line) + } + return out +} + +func rawJSONText(raw json.RawMessage) string { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return strings.Trim(string(raw), `"`) +} + // --- Compose-specific --- var servicesSetSourceInlineCmd = &cobra.Command{ @@ -499,16 +661,20 @@ var servicesSetSourceInlineCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() input := map[string]any{ - "projectName": args[0], - "serviceName": args[1], - "composeFile": args[2], - "composeContent": args[3], + "projectName": args[0], + "serviceName": args[1], + "composeFile": args[2], + // Panel schema calls this field "content", not "composeContent". + "content": args[3], } err := c.call(context.Background(), "/api/trpc/services.compose.updateSourceInline", input, nil) if err != nil { return err } - fmt.Printf("Inline source set: %s/%s\n", args[0], args[1]) + if err := redeployAfterConfig(c, args[0], args[1], "compose"); err != nil { + return fmt.Errorf("apply inline source: %w", err) + } + fmt.Printf("Inline source set and deployed: %s/%s\n", args[0], args[1]) return nil }, } @@ -519,19 +685,26 @@ var servicesSetSourceGitComposeCmd = &cobra.Command{ Args: cobra.ExactArgs(5), RunE: func(cmd *cobra.Command, args []string) error { c := newPanelClient() + rootPath, _ := cmd.Flags().GetString("root-path") + autoDeploy, _ := cmd.Flags().GetBool("auto-deploy") input := map[string]any{ "projectName": args[0], "serviceName": args[1], "repo": args[2], "ref": args[3], "composeFile": args[4], - "autoDeploy": true, + // Panel schema requires rootPath. + "rootPath": rootPath, + "autoDeploy": autoDeploy, } err := c.call(context.Background(), "/api/trpc/services.compose.updateSourceGit", input, nil) if err != nil { return err } - fmt.Printf("Git compose source set: %s/%s -> %s@%s\n", args[0], args[1], args[2], args[3]) + if err := redeployAfterConfig(c, args[0], args[1], "compose"); err != nil { + return fmt.Errorf("apply compose git source: %w", err) + } + fmt.Printf("Git compose source set and deployed: %s/%s -> %s@%s\n", args[0], args[1], args[2], args[3]) return nil }, } diff --git a/cmd/services_test.go b/cmd/services_test.go new file mode 100644 index 0000000..4446243 --- /dev/null +++ b/cmd/services_test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestFlattenLogLinesPreservesNanosecondTimestampOrdering(t *testing.T) { + // These timestamps differ beyond float64's exact integer range. Decoding + // through any would collapse precision and can reverse cross-stream order. + body := `{"entries":[ + {"stream":{},"values":[[1700000000000000200,"third"],[1700000000000000001,"first"]]}, + {"stream":{},"values":[[1700000000000000100,"second"]]} + ]}` + var result logQueryResult + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + got := flattenLogLines(result) + want := []string{"first", "second", "third"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("want %v, got %v", want, got) + } +} diff --git a/compose_test.go b/compose_test.go index 5c564a9..e7aa77e 100644 --- a/compose_test.go +++ b/compose_test.go @@ -24,12 +24,52 @@ type composeTestClient struct { http *http.Client } +// panelDomain returns the panel wildcard domain from the environment so tests +// follow .env instead of a hardcoded panel. +func panelDomain() string { + if v := os.Getenv("DEFAULT_DOMAIN"); v != "" { + return v + } + return "cv911b.easypanel.host" +} + func newComposeTestClient() *composeTestClient { return &composeTestClient{ baseURL: os.Getenv("EASYPANEL_ENDPOINT"), token: os.Getenv("EASYPANEL_API_KEY"), - http: &http.Client{Timeout: 60 * time.Second}, + http: &http.Client{Timeout: 5 * time.Minute}, // compose deploys block on image pulls + } +} + +// waitForRouting polls a host until a real backend answers, i.e. a 2xx or 3xx. +// Traefik answers 404 while no router matches the host yet and 502/503 while the +// container is starting, so both are treated as "not ready" and retried. +func waitForRouting(t *testing.T, url string, timeout time.Duration) int { + t.Helper() + client := &http.Client{Timeout: 15 * time.Second} + deadline := time.Now().Add(timeout) + last := 0 + var lastErr error + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + t.Fatalf("build request for %s: %v", url, err) + } + resp, err := client.Do(req) + if err != nil { + lastErr = err + } else { + last = resp.StatusCode + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode < 400 { + return resp.StatusCode + } + } + time.Sleep(5 * time.Second) } + t.Fatalf("%s never routed within %s (last status %d, last error %v)", url, timeout, last, lastErr) + return last } func (c *composeTestClient) call(t *testing.T, route string, input any) map[string]any { @@ -89,13 +129,17 @@ func TestComposeFullStack(t *testing.T) { c.callErr("services.compose.destroyService", map[string]string{"projectName": project, "serviceName": service}) }() - // docker-compose with wordpress + mysql + adminer + // docker-compose with wordpress + mysql + adminer. + // Host port publishing is deliberately avoided: Traefik owns host ports 80 + // and 443, so `ports:` makes `docker compose up` fail. Traefik reaches + // containers over the project network, so `expose:` is what production + // deploys use too (the gRPC server strips `ports:` for the same reason). composeContent := `services: wordpress: image: wordpress:6-apache restart: unless-stopped - ports: - - "80:80" + expose: + - 80 environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_USER: wp @@ -118,8 +162,8 @@ func TestComposeFullStack(t *testing.T) { adminer: image: adminer:latest restart: unless-stopped - ports: - - "8080:8080" + expose: + - 8080 volumes: db_data: @@ -136,10 +180,10 @@ volumes: // 2. Set inline compose content t.Log("Step 2: Set docker-compose.yml (WordPress + MySQL + Adminer)") c.call(t, "services.compose.updateSourceInline", map[string]any{ - "projectName": project, - "serviceName": service, - "composeFile": "docker-compose.yml", - "composeContent": composeContent, + "projectName": project, + "serviceName": service, + "composeFile": "docker-compose.yml", + "content": composeContent, }) t.Log(" ✓ Compose content set") @@ -152,7 +196,7 @@ volumes: t.Log(" ✓ Deploy triggered") // 4. Create domain for wordpress (port 80) - wpHost := fmt.Sprintf("%s-%s.cv911b.easypanel.host", project, service) + wpHost := fmt.Sprintf("%s-%s.%s", project, service, panelDomain()) t.Logf("Step 4: Create domain for WordPress (%s)", wpHost) c.call(t, "domains.createDomain", map[string]any{ "id": "wp-default", @@ -174,19 +218,20 @@ volumes: }) t.Log(" ✓ Domain created") - // 5. Wait for stack to come up (wordpress + mysql needs time) - t.Log("Step 5: Waiting 30s for full stack startup...") - time.Sleep(30 * time.Second) + // Domain config is written into Easypanel's generated compose override on + // deploy. Without this second deploy Traefik returns 502 indefinitely. + t.Log("Step 4b: Redeploy compose stack to apply domain routing") + c.call(t, "services.compose.deployService", map[string]string{ + "projectName": project, + "serviceName": service, + }) + t.Log(" ✓ Domain routing applied") - // 6. Verify WordPress responds - t.Log("Step 6: Verify WordPress accessible") - resp, err := http.Get("https://" + wpHost + "/") - if err != nil { - t.Logf(" ⚠ GET failed: %v (may need more time)", err) - } else { - resp.Body.Close() - t.Logf(" ✓ WordPress -> HTTP %d", resp.StatusCode) - } + // 5+6. Wait for Traefik to route the domain to the WordPress container. + // MySQL init plus WordPress first boot takes well over 30s. + t.Log("Step 5/6: Wait for WordPress to answer through Traefik (up to 4m)") + status := waitForRouting(t, "https://"+wpHost+"/", 4*time.Minute) + t.Logf(" ✓ WordPress -> HTTP %d", status) // 7. Inspect compose service t.Log("Step 7: Inspect compose service") @@ -304,7 +349,7 @@ func TestMultiServiceProject(t *testing.T) { t.Log(" ✓ App deployed") // 7. Create domain for app - appHost := fmt.Sprintf("%s-%s.cv911b.easypanel.host", project, appService) + appHost := fmt.Sprintf("%s-%s.%s", project, appService, panelDomain()) t.Logf("Step 7: Create domain (%s)", appHost) c.call(t, "domains.createDomain", map[string]any{ "id": "app-default", @@ -325,15 +370,18 @@ func TestMultiServiceProject(t *testing.T) { }) t.Log(" ✓ Domain created") + // Domain mutations are saved config; redeploy applies Traefik routing. + c.call(t, "services.app.deployService", map[string]string{ + "projectName": project, + "serviceName": appService, + }) + t.Log(" ✓ Domain routing applied") + // 8. Verify - t.Log("Step 8: Verify (waiting 12s)") - time.Sleep(12 * time.Second) - resp, err := http.Get("https://" + appHost + "/") - if err != nil { - t.Logf(" ⚠ GET failed: %v", err) - } else { - resp.Body.Close() - t.Logf(" ✓ App -> HTTP %d", resp.StatusCode) + t.Log("Step 8: Wait for app to answer through Traefik (up to 2m)") + { + status := waitForRouting(t, "https://"+appHost+"/", 2*time.Minute) + t.Logf(" ✓ App -> HTTP %d", status) } // 9. Cleanup diff --git a/go.mod b/go.mod index bc49825..b3f000c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/spf13/cobra v1.10.2 google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index f1023fa..3b85dbd 100644 --- a/go.sum +++ b/go.sum @@ -47,4 +47,7 @@ google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grpc_e2e_test.go b/grpc_e2e_test.go index 551010d..f7c59c7 100644 --- a/grpc_e2e_test.go +++ b/grpc_e2e_test.go @@ -246,6 +246,8 @@ func TestGRPC_DeployContainerE2E(t *testing.T) { // 9. Remove custom domain t.Log("Step 9: RemoveDomain") + // Legacy clients only send domain_id. Server resolves target before delete, + // then redeploys routing automatically. _, err = client.RemoveDomain(ctx, &pb.RemoveDomainRequest{DomainId: addResp.DomainId}) if err != nil { t.Fatalf("RemoveDomain: %v", err) diff --git a/internal/easypanel/client.go b/internal/easypanel/client.go index 33995c5..6b39f7b 100644 --- a/internal/easypanel/client.go +++ b/internal/easypanel/client.go @@ -7,6 +7,9 @@ import ( "fmt" "io" "net/http" + "os" + "sort" + "strings" "time" ) @@ -17,17 +20,35 @@ type Client struct { httpClient *http.Client } -// NewClient creates Easypanel tRPC client +// defaultTimeout covers the panel's synchronous deploy calls. deployService +// blocks until the stack is pulled and started, which is seconds for a small +// image but minutes for a multi-service compose stack. +const defaultTimeout = 5 * time.Minute + +// NewClient creates Easypanel tRPC client. Timeout can be overridden with +// EASYPANEL_HTTP_TIMEOUT (any Go duration, e.g. "90s", "10m"). func NewClient(endpoint, token string) *Client { return &Client{ baseURL: endpoint, token: token, httpClient: &http.Client{ - Timeout: 60 * time.Second, + Timeout: timeoutFromEnv(), }, } } +func timeoutFromEnv() time.Duration { + raw := os.Getenv("EASYPANEL_HTTP_TIMEOUT") + if raw == "" { + return defaultTimeout + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return defaultTimeout + } + return d +} + // Response is the flat tRPC response envelope type Response[T any] struct { JSON T `json:"json"` @@ -39,12 +60,55 @@ type APIError struct { Code string `json:"code"` Status int `json:"status"` Message string `json:"message"` + // FieldErrors holds the panel's zod validation errors keyed by input field. + FieldErrors map[string]string `json:"-"` } func (e *APIError) Error() string { + if len(e.FieldErrors) > 0 { + fields := make([]string, 0, len(e.FieldErrors)) + for k, v := range e.FieldErrors { + fields = append(fields, k+": "+v) + } + sort.Strings(fields) + return fmt.Sprintf("[%s] %s (%s)", e.Code, e.Message, strings.Join(fields, ", ")) + } return fmt.Sprintf("[%s] %s", e.Code, e.Message) } +// trpcErrorEnvelope is how the panel reports errors: nested under "json", with +// zod field errors under data.zodErrors. +type trpcErrorEnvelope struct { + JSON *struct { + Code string `json:"code"` + Status int `json:"status"` + Message string `json:"message"` + Data struct { + ZodErrors map[string]string `json:"zodErrors"` + } `json:"data"` + } `json:"json"` +} + +// parseAPIError extracts a panel error from a response body, returning nil when +// the body does not describe an error. +func parseAPIError(body []byte) *APIError { + var env trpcErrorEnvelope + if err := json.Unmarshal(body, &env); err == nil && env.JSON != nil && (env.JSON.Code != "" || env.JSON.Message != "") { + return &APIError{ + Code: env.JSON.Code, + Status: env.JSON.Status, + Message: env.JSON.Message, + FieldErrors: env.JSON.Data.ZodErrors, + } + } + // Some panel responses (and proxies) return a flat error object. + var flat APIError + if err := json.Unmarshal(body, &flat); err == nil && flat.Message != "" { + return &flat + } + return nil +} + // Call executes a tRPC POST request func (c *Client) Call(ctx context.Context, route string, input any, result any) error { body := map[string]any{"json": input} @@ -72,19 +136,15 @@ func (c *Client) Call(ctx context.Context, route string, input any, result any) } if resp.StatusCode >= 400 { - var apiErr APIError - if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Message != "" { - return &apiErr + if apiErr := parseAPIError(respBody); apiErr != nil { + return apiErr } return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) } - // Check inline error - var maybeErr struct { - JSON *APIError `json:"json"` - } - if json.Unmarshal(respBody, &maybeErr) == nil && maybeErr.JSON != nil && maybeErr.JSON.Code != "" { - return maybeErr.JSON + // A 200 response can still carry an inline error envelope. + if apiErr := parseAPIError(respBody); apiErr != nil && apiErr.Code != "" { + return apiErr } if result != nil { diff --git a/internal/easypanel/client_test.go b/internal/easypanel/client_test.go new file mode 100644 index 0000000..f2fcd44 --- /dev/null +++ b/internal/easypanel/client_test.go @@ -0,0 +1,112 @@ +package easypanel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCallSurfacesZodFieldErrors(t *testing.T) { + // The panel rejects bad input with 400 and nests the reason under + // json.data.zodErrors. That detail is the whole diagnosis, so it has to + // reach the caller. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"json":{"code":"BAD_REQUEST","status":400,"message":"Input validation failed","data":{"zodErrors":{"content":"Required","rootPath":"Required"}}}}`)) + })) + defer srv.Close() + + err := NewClient(srv.URL, "token").Call(context.Background(), "services.compose.updateSourceInline", map[string]any{}, nil) + if err == nil { + t.Fatal("want error, got nil") + } + + var apiErr *APIError + if !asAPIError(err, &apiErr) { + t.Fatalf("want *APIError, got %T: %v", err, err) + } + if apiErr.Code != "BAD_REQUEST" || apiErr.Status != 400 { + t.Errorf("want BAD_REQUEST/400, got %s/%d", apiErr.Code, apiErr.Status) + } + if apiErr.FieldErrors["content"] != "Required" { + t.Errorf("want content field error, got %v", apiErr.FieldErrors) + } + + msg := err.Error() + for _, want := range []string{"Input validation failed", "content: Required", "rootPath: Required"} { + if !strings.Contains(msg, want) { + t.Errorf("want error message to contain %q, got %q", want, msg) + } + } +} + +func TestCallSurfacesNestedErrorWithoutFieldErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"json":{"code":"NOT_FOUND","status":404,"message":"Service not found."}}`)) + })) + defer srv.Close() + + err := NewClient(srv.URL, "token").Call(context.Background(), "services.app.deployService", map[string]any{}, nil) + if err == nil { + t.Fatal("want error, got nil") + } + if got := err.Error(); got != "[NOT_FOUND] Service not found." { + t.Errorf("unexpected message: %q", got) + } +} + +func TestCallDecodesSuccessBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "token" { + t.Errorf("want auth header forwarded, got %q", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + if _, ok := body["json"]; !ok { + t.Errorf("want input wrapped under json, got %v", body) + } + _, _ = w.Write([]byte(`{"json":{"name":"pods"}}`)) + })) + defer srv.Close() + + var resp Response[struct { + Name string `json:"name"` + }] + if err := NewClient(srv.URL, "token").Call(context.Background(), "projects.inspectProject", map[string]any{"projectName": "pods"}, &resp); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.JSON.Name != "pods" { + t.Errorf("want pods, got %q", resp.JSON.Name) + } +} + +func TestCallReportsUnparseableBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`gateway blew up`)) + })) + defer srv.Close() + + err := NewClient(srv.URL, "token").Call(context.Background(), "projects.listProjects", nil, nil) + if err == nil { + t.Fatal("want error, got nil") + } + if !strings.Contains(err.Error(), "HTTP 502") { + t.Errorf("want raw status in message, got %q", err.Error()) + } +} + +// asAPIError is a local errors.As shim to keep the assertion readable. +func asAPIError(err error, target **APIError) bool { + if e, ok := err.(*APIError); ok { + *target = e + return true + } + return false +} diff --git a/internal/server/compose_test.go b/internal/server/compose_test.go new file mode 100644 index 0000000..25bdf25 --- /dev/null +++ b/internal/server/compose_test.go @@ -0,0 +1,172 @@ +package server + +import ( + "context" + "strings" + "testing" + + pb "github.com/igun997/deploy-everything/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/yaml.v3" +) + +func TestSanitizeComposeRemovesPortsStructurally(t *testing.T) { + input := `services: + web: + image: nginx:alpine + "ports": + - "8080:80" + expose: + - 80 + api: {image: api:latest, ports: [{target: 3000, published: 3000}]} +` + + got, err := sanitizeCompose(input) + if err != nil { + t.Fatalf("sanitize: %v", err) + } + + var compose map[string]any + if err := yaml.Unmarshal([]byte(got), &compose); err != nil { + t.Fatalf("sanitized output is invalid YAML: %v", err) + } + services := compose["services"].(map[string]any) + for name, raw := range services { + service := raw.(map[string]any) + if _, exists := service["ports"]; exists { + t.Errorf("service %s still has ports: %v", name, service["ports"]) + } + } + web := services["web"].(map[string]any) + if _, exists := web["expose"]; !exists { + t.Error("expose was removed with ports") + } +} + +func TestSanitizeComposeResolvesMergedPorts(t *testing.T) { + input := `x-defaults: &defaults + ports: + - "8080:80" +services: + web: + <<: *defaults + image: nginx:alpine +` + got, err := sanitizeCompose(input) + if err != nil { + t.Fatalf("sanitize: %v", err) + } + var compose map[string]any + if err := yaml.Unmarshal([]byte(got), &compose); err != nil { + t.Fatalf("sanitized output is invalid YAML: %v", err) + } + web := compose["services"].(map[string]any)["web"].(map[string]any) + if _, exists := web["ports"]; exists { + t.Fatalf("merged ports bypassed sanitizer: %s", got) + } +} + +func TestSanitizeComposeRejectsHostNetworking(t *testing.T) { + input := `services: + web: + image: nginx + network_mode: host +` + _, err := sanitizeCompose(input) + if err == nil || !strings.Contains(err.Error(), "network_mode host is not allowed") { + t.Fatalf("want host-network rejection, got %v", err) + } +} + +func TestSanitizeComposeRejectsInvalidShape(t *testing.T) { + for name, input := range map[string]string{ + "invalid yaml": "services: [", + "missing services": "volumes: {}", + "services not map": "services: []", + "service not map": "services:\n web: nginx", + } { + t.Run(name, func(t *testing.T) { + if _, err := sanitizeCompose(input); err == nil { + t.Fatalf("want error for %q", input) + } + }) + } +} + +func TestSanitizeComposeKeepsEnvironmentStrings(t *testing.T) { + input := `services: + web: + image: nginx + environment: + PORTS_LABEL: "ports: must stay inside values" +` + got, err := sanitizeCompose(input) + if err != nil { + t.Fatalf("sanitize: %v", err) + } + if !strings.Contains(got, "ports: must stay inside values") { + t.Fatalf("environment value was corrupted: %s", got) + } +} + +func TestDomainTargetSupportsLegacyRemoveDomain(t *testing.T) { + domains := []map[string]any{ + { + "id": "app-domain", + "serviceDestination": map[string]any{ + "projectName": "pods", + "serviceName": "api", + }, + }, + { + "id": "compose-domain", + "serviceDestination": map[string]any{ + "projectName": "pods", + "serviceName": "stack", + "composeService": "web", + }, + }, + } + + project, service, svcType := domainTarget(domains, "app-domain") + if project != "pods" || service != "api" || svcType != "app" { + t.Fatalf("unexpected app target: %s/%s (%s)", project, service, svcType) + } + project, service, svcType = domainTarget(domains, "compose-domain") + if project != "pods" || service != "stack" || svcType != "compose" { + t.Fatalf("unexpected compose target: %s/%s (%s)", project, service, svcType) + } + project, service, svcType = domainTarget(domains, "missing") + if project != "" || service != "" || svcType != "" { + t.Fatalf("missing domain returned target: %s/%s (%s)", project, service, svcType) + } + + // Caller supplies project/service but omits type: authoritative compose + // destination still wins, preventing an app redeploy after deletion. + project, service, svcType, found, err := resolveDomainTarget(domains, "compose-domain", "pods", "stack", "") + if err != nil || !found || project != "pods" || service != "stack" || svcType != "compose" { + t.Fatalf("compose inference failed: %s/%s (%s), found=%v err=%v", project, service, svcType, found, err) + } + + if _, _, _, _, err := resolveDomainTarget(domains, "compose-domain", "pods", "stack", "app"); err == nil { + t.Fatal("want stale service type mismatch error") + } + + // Already-deleted retry is possible with explicit target context. + project, service, svcType, found, err = resolveDomainTarget(domains, "missing", "pods", "stack", "compose") + if err != nil || found || project != "pods" || service != "stack" || svcType != "compose" { + t.Fatalf("idempotent retry target failed: %s/%s (%s), found=%v err=%v", project, service, svcType, found, err) + } +} + +func TestUpdateResourcesRequiresResources(t *testing.T) { + s := &PaaSServer{} + _, err := s.UpdateResources(context.Background(), &pb.UpdateResourcesRequest{ + Project: "pods", + Service: "api", + }) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("want InvalidArgument, got %v", err) + } +} diff --git a/internal/server/logs.go b/internal/server/logs.go new file mode 100644 index 0000000..a9e95bf --- /dev/null +++ b/internal/server/logs.go @@ -0,0 +1,97 @@ +package server + +import ( + "encoding/json" + "sort" + "strings" + + pb "github.com/igun997/deploy-everything/proto" +) + +// defaultLogLimit matches what the panel UI requests for an initial log view. +const defaultLogLimit = 200 + +// lokiStream is one label-set group in a panel log response. The panel proxies +// its log store (Loki-shaped), so each group carries a label map plus +// [timestamp, line] pairs. +type lokiStream struct { + Stream map[string]string `json:"stream"` + Values [][]json.RawMessage +} + +// UnmarshalJSON accepts the panel's `values: [[ts, line], ...]` shape where +// both elements are strings, tolerating numeric timestamps. +func (l *lokiStream) UnmarshalJSON(b []byte) error { + var raw struct { + Stream map[string]string `json:"stream"` + Values [][]json.RawMessage `json:"values"` + Labels map[string]string `json:"labels"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + l.Stream = raw.Stream + if l.Stream == nil { + l.Stream = raw.Labels + } + l.Values = raw.Values + return nil +} + +// lokiLogs is the panel log query response envelope. +type lokiLogs struct { + Entries []lokiStream `json:"entries"` +} + +// flattenLogs turns the panel's grouped log response into a flat, oldest-first +// list of entries. Label metadata is preserved where the panel supplies it: +// `detected_level` becomes level and `stream` becomes stream, mirroring how the +// panel UI renders each line. +func flattenLogs(l lokiLogs) []*pb.LogEntry { + var out []*pb.LogEntry + for _, group := range l.Entries { + for _, pair := range group.Values { + if len(pair) < 2 { + continue + } + out = append(out, &pb.LogEntry{ + Timestamp: rawToString(pair[0]), + Line: rawToString(pair[1]), + Level: group.Stream["detected_level"], + Stream: group.Stream["stream"], + }) + } + } + // Timestamps are nanosecond strings of equal width in practice, so a + // lexical comparison orders them correctly and avoids overflow concerns. + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i].Timestamp, out[j].Timestamp + if len(a) != len(b) { + return len(a) < len(b) + } + return a < b + }) + return out +} + +// rawToString unwraps a JSON string, falling back to the raw token for numbers. +func rawToString(r json.RawMessage) string { + var s string + if err := json.Unmarshal(r, &s); err == nil { + return s + } + return strings.Trim(string(r), `"`) +} + +// joinLogLines renders entries as newline separated text for clients that only +// read the flat `logs` field. +func joinLogLines(entries []*pb.LogEntry) string { + if len(entries) == 0 { + return "" + } + lines := make([]string, 0, len(entries)) + for _, e := range entries { + lines = append(lines, e.Line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/server/logs_test.go b/internal/server/logs_test.go new file mode 100644 index 0000000..8cdb570 --- /dev/null +++ b/internal/server/logs_test.go @@ -0,0 +1,130 @@ +package server + +import ( + "encoding/json" + "testing" +) + +// The panel returns log data grouped by label set, Loki style. +const panelLogResponse = `{ + "entries": [ + { + "stream": {"stream": "stderr", "detected_level": "error"}, + "values": [ + ["1700000000000000200", "boom"], + ["1700000000000000100", "warming up"] + ] + }, + { + "stream": {"stream": "stdout", "detected_level": "info"}, + "values": [ + ["1700000000000000150", "listening on :3000"] + ] + } + ] +}` + +func TestFlattenLogsOrdersOldestFirst(t *testing.T) { + var l lokiLogs + if err := json.Unmarshal([]byte(panelLogResponse), &l); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + entries := flattenLogs(l) + if len(entries) != 3 { + t.Fatalf("want 3 entries, got %d", len(entries)) + } + + wantLines := []string{"warming up", "listening on :3000", "boom"} + for i, want := range wantLines { + if entries[i].Line != want { + t.Errorf("entry %d: want line %q, got %q", i, want, entries[i].Line) + } + } + + if got := entries[2].Level; got != "error" { + t.Errorf("want level error on last entry, got %q", got) + } + if got := entries[2].Stream; got != "stderr" { + t.Errorf("want stream stderr on last entry, got %q", got) + } + if got := entries[0].Timestamp; got != "1700000000000000100" { + t.Errorf("want first timestamp 1700000000000000100, got %q", got) + } +} + +func TestFlattenLogsAcceptsNumericTimestamps(t *testing.T) { + var l lokiLogs + body := `{"entries":[{"stream":{},"values":[[1700000000000000100,"one"]]}]}` + if err := json.Unmarshal([]byte(body), &l); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + entries := flattenLogs(l) + if len(entries) != 1 { + t.Fatalf("want 1 entry, got %d", len(entries)) + } + if entries[0].Timestamp != "1700000000000000100" { + t.Errorf("want numeric timestamp preserved, got %q", entries[0].Timestamp) + } +} + +func TestFlattenLogsFallsBackToLabelsKey(t *testing.T) { + // The panel UI reads labels from `stream`, but its own helper names the + // same map `labels` after flattening. Accept both. + var l lokiLogs + body := `{"entries":[{"labels":{"detected_level":"warn"},"values":[["1","careful"]]}]}` + if err := json.Unmarshal([]byte(body), &l); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + entries := flattenLogs(l) + if len(entries) != 1 || entries[0].Level != "warn" { + t.Fatalf("want one warn entry, got %+v", entries) + } +} + +func TestFlattenLogsSkipsMalformedPairs(t *testing.T) { + var l lokiLogs + body := `{"entries":[{"stream":{},"values":[["only-timestamp"],["1","kept"]]}]}` + if err := json.Unmarshal([]byte(body), &l); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + entries := flattenLogs(l) + if len(entries) != 1 || entries[0].Line != "kept" { + t.Fatalf("want only the well formed pair, got %+v", entries) + } +} + +func TestFlattenLogsEmpty(t *testing.T) { + if entries := flattenLogs(lokiLogs{}); len(entries) != 0 { + t.Fatalf("want no entries, got %d", len(entries)) + } + if got := joinLogLines(nil); got != "" { + t.Fatalf("want empty string, got %q", got) + } +} + +func TestJoinLogLines(t *testing.T) { + var l lokiLogs + if err := json.Unmarshal([]byte(panelLogResponse), &l); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + want := "warming up\nlistening on :3000\nboom" + if got := joinLogLines(flattenLogs(l)); got != want { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestRequireDeployConfigSupportOnlyAllowsApp(t *testing.T) { + if err := requireDeployConfigSupport("app", "scale"); err != nil { + t.Fatalf("want app supported, got %v", err) + } + for _, svcType := range []string{"compose", "mysql", "wordpress"} { + if err := requireDeployConfigSupport(svcType, "scale"); err == nil { + t.Errorf("want error for %s, got nil", svcType) + } + } +} diff --git a/internal/server/paas.go b/internal/server/paas.go index 49a24e4..108799f 100644 --- a/internal/server/paas.go +++ b/internal/server/paas.go @@ -4,11 +4,25 @@ import ( "context" "fmt" "strings" + "time" "github.com/igun997/deploy-everything/internal/easypanel" pb "github.com/igun997/deploy-everything/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/yaml.v3" ) +// requireDeployConfigSupport rejects service types the panel does not expose +// updateDeploy for. Only app services have this route. +func requireDeployConfigSupport(svcType, action string) error { + if svcType != "app" { + return status.Errorf(codes.Unimplemented, + "the panel only exposes services.app.updateDeploy: cannot %s service type %q", action, svcType) + } + return nil +} + // PaaSServer implements the gRPC PaaS service type PaaSServer struct { pb.UnimplementedPaaSServer @@ -63,7 +77,7 @@ func (s *PaaSServer) DeployContainer(ctx context.Context, req *pb.DeployContaine } // 4. Set resources if provided - if req.Resources != nil && (req.Resources.CpuLimit > 0 || req.Resources.MemoryLimit > 0) { + if req.Resources != nil { err = s.ep.Call(ctx, "services.app.updateResources", map[string]any{ "projectName": req.Project, "serviceName": req.Service, @@ -141,6 +155,12 @@ func (s *PaaSServer) DeployContainer(ctx context.Context, req *pb.DeployContaine } } + // Domain mutations are saved configuration. Redeploy once after all domain + // changes so Easypanel regenerates proxy routing for the running app. + if err := s.redeployService(ctx, "app", req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply domains: %w", err) + } + // Get deploy URL from inspect var inspectResp easypanel.Response[map[string]any] _ = s.ep.Call(ctx, "services.app.inspectService", map[string]string{ @@ -160,8 +180,15 @@ func (s *PaaSServer) DeployContainer(ctx context.Context, req *pb.DeployContaine } func (s *PaaSServer) DeployCompose(ctx context.Context, req *pb.DeployComposeRequest) (*pb.DeployComposeResponse, error) { + // Validate and sanitize before creating anything. Invalid YAML or forbidden + // host networking must not leave an orphaned panel service. + content, err := sanitizeCompose(req.ComposeContent) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid compose YAML: %v", err) + } + // 1. Create compose service - err := s.ep.Call(ctx, "services.compose.createService", map[string]string{ + err = s.ep.Call(ctx, "services.compose.createService", map[string]string{ "projectName": req.Project, "serviceName": req.Service, }, nil) @@ -185,8 +212,7 @@ func (s *PaaSServer) DeployCompose(ctx context.Context, req *pb.DeployComposeReq } } - // 3. Set compose content (sanitize: remove ports, keep expose) - content := sanitizeCompose(req.ComposeContent) + // 3. Set validated compose content (host ports removed, expose kept) err = s.ep.Call(ctx, "services.compose.updateSourceInline", map[string]any{ "projectName": req.Project, "serviceName": req.Service, @@ -248,6 +274,15 @@ func (s *PaaSServer) DeployCompose(ctx context.Context, req *pb.DeployComposeReq }) } + // Easypanel writes compose domain routing into its generated override file + // during deploy. Domains created after the initial deploy return 502 until + // the compose stack is deployed again. + if len(domainMappings) > 0 { + if err := s.redeployCompose(ctx, req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply compose domains: %w", err) + } + } + return &pb.DeployComposeResponse{ ServiceId: fmt.Sprintf("%s/%s", req.Project, req.Service), Domains: domainMappings, @@ -373,6 +408,25 @@ func (s *PaaSServer) ListServices(ctx context.Context, req *pb.ListServicesReque return &pb.ListServicesResponse{Services: services}, nil } +// redeployService applies saved env, source and domain configuration. Easypanel +// update mutations persist config but do not regenerate the running deployment. +func (s *PaaSServer) redeployService(ctx context.Context, svcType, project, service string) error { + if svcType == "" { + svcType = "app" + } + if svcType != "app" && svcType != "compose" { + return nil // These service types do not expose deployService. + } + return s.ep.Call(ctx, fmt.Sprintf("services.%s.deployService", svcType), map[string]string{ + "projectName": project, + "serviceName": service, + }, nil) +} + +func (s *PaaSServer) redeployCompose(ctx context.Context, project, service string) error { + return s.redeployService(ctx, "compose", project, service) +} + func (s *PaaSServer) AddDomain(ctx context.Context, req *pb.AddDomainRequest) (*pb.AddDomainResponse, error) { port := req.Port if port == 0 { @@ -405,6 +459,14 @@ func (s *PaaSServer) AddDomain(ctx context.Context, req *pb.AddDomainRequest) (* return nil, err } + svcType := req.ServiceType + if svcType == "" && req.ComposeService != "" { + svcType = "compose" + } + if err := s.redeployService(ctx, svcType, req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply domain: %w", err) + } + // Get actual ID from list var domainsResp easypanel.Response[[]map[string]any] _ = s.ep.Call(ctx, "domains.listDomains", map[string]string{ @@ -427,11 +489,97 @@ func (s *PaaSServer) AddDomain(ctx context.Context, req *pb.AddDomainRequest) (* } func (s *PaaSServer) RemoveDomain(ctx context.Context, req *pb.RemoveDomainRequest) (*pb.RemoveDomainResponse, error) { - err := s.ep.Call(ctx, "domains.deleteDomain", map[string]string{"id": req.DomainId}, nil) + // Always resolve the authoritative destination before deletion. This keeps + // legacy domain_id-only clients working and prevents stale caller-supplied + // type/project data from redeploying the wrong service. + var all easypanel.Response[[]map[string]any] + if err := s.ep.Call(ctx, "domains.listDomains", map[string]string{}, &all); err != nil { + return nil, fmt.Errorf("resolve domain target: %w", err) + } + project, service, svcType, found, err := resolveDomainTarget( + all.JSON, req.DomainId, req.Project, req.Service, req.ServiceType, + ) if err != nil { - return nil, err + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + // If the domain is already absent, this may be a retry after a partial + // success. Skip deletion and retry only the deployment, making the RPC + // idempotent when caller supplies target context. + if found { + if err := s.ep.Call(ctx, "domains.deleteDomain", map[string]string{"id": req.DomainId}, nil); err != nil { + return nil, err + } + } + + // Deletion is already persisted. Retry once for transient deploy failures; + // if both attempts fail, report explicit partial success so operators know + // to run deployService manually rather than retrying domain deletion. + var deployErr error + for attempt := 0; attempt < 2; attempt++ { + deployErr = s.redeployService(ctx, svcType, project, service) + if deployErr == nil { + return &pb.RemoveDomainResponse{Success: true}, nil + } + if attempt == 0 { + select { + case <-ctx.Done(): + return nil, status.Errorf(codes.Aborted, + "domain was deleted, but routing redeploy was canceled: %v; manually deploy %s/%s", ctx.Err(), project, service) + case <-time.After(time.Second): + } + } + } + return nil, status.Errorf(codes.Aborted, + "domain was deleted, but routing redeploy failed after 2 attempts: %v; manually deploy %s/%s", deployErr, project, service) +} + +func resolveDomainTarget(domains []map[string]any, domainID, requestedProject, requestedService, requestedType string) (project, service, svcType string, found bool, err error) { + project, service, svcType = domainTarget(domains, domainID) + found = project != "" && service != "" + if found { + if requestedProject != "" && requestedProject != project { + return "", "", "", false, fmt.Errorf("domain belongs to project %q, not %q", project, requestedProject) + } + if requestedService != "" && requestedService != service { + return "", "", "", false, fmt.Errorf("domain belongs to service %q, not %q", service, requestedService) + } + if requestedType != "" && requestedType != svcType { + return "", "", "", false, fmt.Errorf("domain belongs to service type %q, not %q", svcType, requestedType) + } + return project, service, svcType, true, nil + } + + // Domain already absent: caller context permits idempotent redeploy retry. + if requestedProject == "" || requestedService == "" { + return "", "", "", false, fmt.Errorf("domain target could not be resolved; project and service are required for an already-deleted domain") + } + if requestedType == "" { + requestedType = "app" } - return &pb.RemoveDomainResponse{Success: true}, nil + if requestedType != "app" && requestedType != "compose" { + return "", "", "", false, fmt.Errorf("unsupported domain service type %q", requestedType) + } + return requestedProject, requestedService, requestedType, false, nil +} + +func domainTarget(domains []map[string]any, domainID string) (project, service, svcType string) { + for _, domain := range domains { + id, _ := domain["id"].(string) + if id != domainID { + continue + } + destination, _ := domain["serviceDestination"].(map[string]any) + project, _ = destination["projectName"].(string) + service, _ = destination["serviceName"].(string) + if composeService, _ := destination["composeService"].(string); composeService != "" { + svcType = "compose" + } else { + svcType = "app" + } + return project, service, svcType + } + return "", "", "" } func (s *PaaSServer) ListDomains(ctx context.Context, req *pb.ListDomainsRequest) (*pb.ListDomainsResponse, error) { @@ -493,6 +641,9 @@ func (s *PaaSServer) UpdateEnv(ctx context.Context, req *pb.UpdateEnvRequest) (* if err != nil { return nil, err } + if err := s.redeployService(ctx, svcType, req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply env: %w", err) + } return &pb.UpdateEnvResponse{Success: true}, nil } @@ -546,15 +697,42 @@ func (s *PaaSServer) StartService(ctx context.Context, req *pb.StartServiceReque } func (s *PaaSServer) GetLogs(ctx context.Context, req *pb.GetLogsRequest) (*pb.GetLogsResponse, error) { - var resp easypanel.Response[string] - err := s.ep.Call(ctx, "logs.getServiceLogs", map[string]string{ + svcType := req.ServiceType + if svcType == "" { + svcType = "app" + } + + limit := int(req.Limit) + if limit <= 0 { + limit = defaultLogLimit + } + + input := map[string]any{ "projectName": req.Project, "serviceName": req.Service, - }, &resp) - if err != nil { - return nil, err + "limit": limit, } - return &pb.GetLogsResponse{Logs: resp.JSON}, nil + + // Compose stacks are served by a dedicated route; the app route only knows + // about single-container services. + route := "logs.queryServiceLogs" + if svcType == "compose" { + route = "logs.queryComposeServiceLogs" + if req.ComposeService != "" { + input["composeInternalService"] = req.ComposeService + } + } + + var resp easypanel.Response[lokiLogs] + if err := s.ep.Call(ctx, route, input, &resp); err != nil { + return nil, fmt.Errorf("query logs: %w", err) + } + + entries := flattenLogs(resp.JSON) + return &pb.GetLogsResponse{ + Logs: joinLogLines(entries), + Entries: entries, + }, nil } func (s *PaaSServer) ScaleService(ctx context.Context, req *pb.ScaleServiceRequest) (*pb.ScaleServiceResponse, error) { @@ -562,15 +740,25 @@ func (s *PaaSServer) ScaleService(ctx context.Context, req *pb.ScaleServiceReque if svcType == "" { svcType = "app" } - route := fmt.Sprintf("services.%s.updateDeploy", svcType) - err := s.ep.Call(ctx, route, map[string]any{ + if err := requireDeployConfigSupport(svcType, "scale"); err != nil { + return nil, err + } + deploy, err := s.currentAppDeploy(ctx, req.Project, req.Service) + if err != nil { + return nil, fmt.Errorf("inspect deploy config: %w", err) + } + deploy["replicas"] = req.Replicas + err = s.ep.Call(ctx, "services.app.updateDeploy", map[string]any{ "projectName": req.Project, "serviceName": req.Service, - "replicas": req.Replicas, + "deploy": deploy, }, nil) if err != nil { return nil, err } + if err := s.redeployService(ctx, svcType, req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply scale: %w", err) + } return &pb.ScaleServiceResponse{Success: true}, nil } @@ -579,6 +767,13 @@ func (s *PaaSServer) UpdateResources(ctx context.Context, req *pb.UpdateResource if svcType == "" { svcType = "app" } + if svcType == "compose" { + return nil, status.Errorf(codes.Unimplemented, + "the panel has no services.compose.updateResources route: set resource limits per service inside the compose file instead") + } + if req.Resources == nil { + return nil, status.Error(codes.InvalidArgument, "resources are required") + } route := fmt.Sprintf("services.%s.updateResources", svcType) err := s.ep.Call(ctx, route, map[string]any{ "projectName": req.Project, @@ -593,6 +788,11 @@ func (s *PaaSServer) UpdateResources(ctx context.Context, req *pb.UpdateResource if err != nil { return nil, err } + // App resource settings are saved until deploy; database/WordPress resource + // routes restart themselves according to the panel API. + if err := s.redeployService(ctx, svcType, req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply resources: %w", err) + } return &pb.UpdateResourcesResponse{Success: true}, nil } @@ -601,19 +801,22 @@ func (s *PaaSServer) UpdateDeploy(ctx context.Context, req *pb.UpdateDeployReque if svcType == "" { svcType = "app" } - route := fmt.Sprintf("services.%s.updateDeploy", svcType) - - deploy := map[string]any{ - "replicas": req.Replicas, - "zeroDowntime": req.ZeroDowntime, + if err := requireDeployConfigSupport(svcType, "update deploy config for"); err != nil { + return nil, err + } + deploy, err := s.currentAppDeploy(ctx, req.Project, req.Service) + if err != nil { + return nil, fmt.Errorf("inspect deploy config: %w", err) } + deploy["replicas"] = req.Replicas + deploy["zeroDowntime"] = req.ZeroDowntime if req.Command != "" { deploy["command"] = req.Command } else { deploy["command"] = nil } - err := s.ep.Call(ctx, route, map[string]any{ + err = s.ep.Call(ctx, "services.app.updateDeploy", map[string]any{ "projectName": req.Project, "serviceName": req.Service, "deploy": deploy, @@ -621,37 +824,55 @@ func (s *PaaSServer) UpdateDeploy(ctx context.Context, req *pb.UpdateDeployReque if err != nil { return nil, err } + if err := s.redeployService(ctx, svcType, req.Project, req.Service); err != nil { + return nil, fmt.Errorf("apply deploy config: %w", err) + } return &pb.UpdateDeployResponse{Success: true}, nil } -// sanitizeCompose strips "ports:" from compose content, keeps "expose:" -func sanitizeCompose(content string) string { - lines := strings.Split(content, "\n") - var result []string - skipPorts := false - portsIndent := 0 - - for _, line := range lines { - trimmed := strings.TrimSpace(line) +// currentAppDeploy fetches the full replace-style deploy object. updateDeploy +// is not a patch endpoint, so callers must merge changes to avoid resetting +// command, capabilities, sysctls and other settings. +func (s *PaaSServer) currentAppDeploy(ctx context.Context, project, service string) (map[string]any, error) { + var inspect easypanel.Response[map[string]any] + if err := s.ep.Call(ctx, "services.app.inspectService", map[string]string{ + "projectName": project, + "serviceName": service, + }, &inspect); err != nil { + return nil, err + } + deploy, _ := inspect.JSON["deploy"].(map[string]any) + if deploy == nil { + deploy = map[string]any{} + } + return deploy, nil +} - // Detect "ports:" section - if strings.HasPrefix(trimmed, "ports:") { - skipPorts = true - portsIndent = len(line) - len(strings.TrimLeft(line, " ")) - continue +// sanitizeCompose removes host-published ports structurally while preserving +// `expose`. Parsing YAML prevents quoted keys, inline mappings and long-form +// port syntax from bypassing a text-prefix filter. +func sanitizeCompose(content string) (string, error) { + var compose map[string]any + if err := yaml.Unmarshal([]byte(content), &compose); err != nil { + return "", err + } + services, ok := compose["services"].(map[string]any) + if !ok { + return "", fmt.Errorf("top-level services mapping is required") + } + for name, raw := range services { + service, ok := raw.(map[string]any) + if !ok { + return "", fmt.Errorf("service %q must be a mapping", name) } - - // If in ports section, skip items until dedent - if skipPorts { - currentIndent := len(line) - len(strings.TrimLeft(line, " ")) - if trimmed == "" || currentIndent > portsIndent { - continue - } - skipPorts = false + if mode, _ := service["network_mode"].(string); mode == "host" { + return "", fmt.Errorf("service %q: network_mode host is not allowed", name) } - - result = append(result, line) + delete(service, "ports") } - - return strings.Join(result, "\n") + out, err := yaml.Marshal(compose) + if err != nil { + return "", err + } + return string(out), nil } diff --git a/paas_test.go b/paas_test.go index 9108e59..3a6c82f 100644 --- a/paas_test.go +++ b/paas_test.go @@ -28,7 +28,7 @@ func newTestClient() *testClient { return &testClient{ baseURL: os.Getenv("EASYPANEL_ENDPOINT"), token: os.Getenv("EASYPANEL_API_KEY"), - http: &http.Client{Timeout: 60 * time.Second}, + http: &http.Client{Timeout: 5 * time.Minute}, // compose deploys block on image pulls } } @@ -75,11 +75,25 @@ const testProject = "pods" const testService = "nginx-e2e" const testImage = "nginx:alpine" -// Default domain uses Easypanel's builtin wildcard: {project}-{service}.{defaultPanelDomain} -const defaultPanelDomain = "cv911b.easypanel.host" +// Domains come from the environment: DEFAULT_DOMAIN is the panel's builtin +// wildcard ({project}-{service}.{DEFAULT_DOMAIN}) and CUSTOM_DOMAIN is your own. +func defaultPanelDomainOrSkip(t *testing.T) string { + t.Helper() + v := os.Getenv("DEFAULT_DOMAIN") + if v == "" { + t.Skip("DEFAULT_DOMAIN not set") + } + return v +} -// Custom domain uses your own wildcard: *.srv-opc.ciptadusa.com -const customBaseDomain = "srv-opc.ciptadusa.com" +func customBaseDomainOrSkip(t *testing.T) string { + t.Helper() + v := os.Getenv("CUSTOM_DOMAIN") + if v == "" { + t.Skip("CUSTOM_DOMAIN not set") + } + return v +} // TestPaaSFlow validates the full PaaS lifecycle: // 1. Create app service @@ -97,6 +111,8 @@ func TestPaaSFlow(t *testing.T) { } c := newTestClient() + defaultPanelDomain := defaultPanelDomainOrSkip(t) + customBaseDomain := customBaseDomainOrSkip(t) // Cleanup on failure defer func() { diff --git a/proto/paas.pb.go b/proto/paas.pb.go index 79bdece..44bb99c 100644 --- a/proto/paas.pb.go +++ b/proto/paas.pb.go @@ -865,8 +865,10 @@ type AddDomainRequest struct { Port int32 `protobuf:"varint,4,opt,name=port,proto3" json:"port,omitempty"` // target port (default 80) ComposeService string `protobuf:"bytes,5,opt,name=compose_service,json=composeService,proto3" json:"compose_service,omitempty"` // for compose services Https bool `protobuf:"varint,6,opt,name=https,proto3" json:"https,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // app (default) or compose. Domain config is applied by redeploying this type. + ServiceType string `protobuf:"bytes,7,opt,name=service_type,json=serviceType,proto3" json:"service_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AddDomainRequest) Reset() { @@ -941,6 +943,13 @@ func (x *AddDomainRequest) GetHttps() bool { return false } +func (x *AddDomainRequest) GetServiceType() string { + if x != nil { + return x.ServiceType + } + return "" +} + type AddDomainResponse struct { state protoimpl.MessageState `protogen:"open.v1"` DomainId string `protobuf:"bytes,1,opt,name=domain_id,json=domainId,proto3" json:"domain_id,omitempty"` @@ -994,8 +1003,12 @@ func (x *AddDomainResponse) GetHost() string { } type RemoveDomainRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - DomainId string `protobuf:"bytes,1,opt,name=domain_id,json=domainId,proto3" json:"domain_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + DomainId string `protobuf:"bytes,1,opt,name=domain_id,json=domainId,proto3" json:"domain_id,omitempty"` + // Required for automatic redeploy after compose/app domain removal. + Project string `protobuf:"bytes,2,opt,name=project,proto3" json:"project,omitempty"` + Service string `protobuf:"bytes,3,opt,name=service,proto3" json:"service,omitempty"` + ServiceType string `protobuf:"bytes,4,opt,name=service_type,json=serviceType,proto3" json:"service_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1037,6 +1050,27 @@ func (x *RemoveDomainRequest) GetDomainId() string { return "" } +func (x *RemoveDomainRequest) GetProject() string { + if x != nil { + return x.Project + } + return "" +} + +func (x *RemoveDomainRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *RemoveDomainRequest) GetServiceType() string { + if x != nil { + return x.ServiceType + } + return "" +} + type RemoveDomainResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` @@ -1686,9 +1720,16 @@ func (x *StartServiceResponse) GetSuccess() bool { } type GetLogsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"` - Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"` + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + // app (default) or compose. Compose services are served by a different + // panel route. + ServiceType string `protobuf:"bytes,3,opt,name=service_type,json=serviceType,proto3" json:"service_type,omitempty"` + // Optional: restrict compose logs to one internal compose service. + ComposeService string `protobuf:"bytes,4,opt,name=compose_service,json=composeService,proto3" json:"compose_service,omitempty"` + // Optional: max log lines to return. Defaults to 200. + Limit int32 `protobuf:"varint,5,opt,name=limit,proto3" json:"limit,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1737,9 +1778,31 @@ func (x *GetLogsRequest) GetService() string { return "" } +func (x *GetLogsRequest) GetServiceType() string { + if x != nil { + return x.ServiceType + } + return "" +} + +func (x *GetLogsRequest) GetComposeService() string { + if x != nil { + return x.ComposeService + } + return "" +} + +func (x *GetLogsRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + type GetLogsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Logs string `protobuf:"bytes,1,opt,name=logs,proto3" json:"logs,omitempty"` + Entries []*LogEntry `protobuf:"bytes,2,rep,name=entries,proto3" json:"entries,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1781,6 +1844,84 @@ func (x *GetLogsResponse) GetLogs() string { return "" } +func (x *GetLogsResponse) GetEntries() []*LogEntry { + if x != nil { + return x.Entries + } + return nil +} + +type LogEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Nanosecond unix timestamp as reported by the panel log store. + Timestamp string `protobuf:"bytes,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + Line string `protobuf:"bytes,2,opt,name=line,proto3" json:"line,omitempty"` + // Detected level (info, error, ...) when the panel provides one. + Level string `protobuf:"bytes,3,opt,name=level,proto3" json:"level,omitempty"` + // stdout or stderr when the panel provides one. + Stream string `protobuf:"bytes,4,opt,name=stream,proto3" json:"stream,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogEntry) Reset() { + *x = LogEntry{} + mi := &file_proto_paas_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogEntry) ProtoMessage() {} + +func (x *LogEntry) ProtoReflect() protoreflect.Message { + mi := &file_proto_paas_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. +func (*LogEntry) Descriptor() ([]byte, []int) { + return file_proto_paas_proto_rawDescGZIP(), []int{30} +} + +func (x *LogEntry) GetTimestamp() string { + if x != nil { + return x.Timestamp + } + return "" +} + +func (x *LogEntry) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +func (x *LogEntry) GetLevel() string { + if x != nil { + return x.Level + } + return "" +} + +func (x *LogEntry) GetStream() string { + if x != nil { + return x.Stream + } + return "" +} + type ScaleServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"` @@ -1793,7 +1934,7 @@ type ScaleServiceRequest struct { func (x *ScaleServiceRequest) Reset() { *x = ScaleServiceRequest{} - mi := &file_proto_paas_proto_msgTypes[30] + mi := &file_proto_paas_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1805,7 +1946,7 @@ func (x *ScaleServiceRequest) String() string { func (*ScaleServiceRequest) ProtoMessage() {} func (x *ScaleServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[30] + mi := &file_proto_paas_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1818,7 +1959,7 @@ func (x *ScaleServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ScaleServiceRequest.ProtoReflect.Descriptor instead. func (*ScaleServiceRequest) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{30} + return file_proto_paas_proto_rawDescGZIP(), []int{31} } func (x *ScaleServiceRequest) GetProject() string { @@ -1858,7 +1999,7 @@ type ScaleServiceResponse struct { func (x *ScaleServiceResponse) Reset() { *x = ScaleServiceResponse{} - mi := &file_proto_paas_proto_msgTypes[31] + mi := &file_proto_paas_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1870,7 +2011,7 @@ func (x *ScaleServiceResponse) String() string { func (*ScaleServiceResponse) ProtoMessage() {} func (x *ScaleServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[31] + mi := &file_proto_paas_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1883,7 +2024,7 @@ func (x *ScaleServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ScaleServiceResponse.ProtoReflect.Descriptor instead. func (*ScaleServiceResponse) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{31} + return file_proto_paas_proto_rawDescGZIP(), []int{32} } func (x *ScaleServiceResponse) GetSuccess() bool { @@ -1905,7 +2046,7 @@ type ResourceLimits struct { func (x *ResourceLimits) Reset() { *x = ResourceLimits{} - mi := &file_proto_paas_proto_msgTypes[32] + mi := &file_proto_paas_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1917,7 +2058,7 @@ func (x *ResourceLimits) String() string { func (*ResourceLimits) ProtoMessage() {} func (x *ResourceLimits) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[32] + mi := &file_proto_paas_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1930,7 +2071,7 @@ func (x *ResourceLimits) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceLimits.ProtoReflect.Descriptor instead. func (*ResourceLimits) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{32} + return file_proto_paas_proto_rawDescGZIP(), []int{33} } func (x *ResourceLimits) GetCpuLimit() float64 { @@ -1973,7 +2114,7 @@ type UpdateResourcesRequest struct { func (x *UpdateResourcesRequest) Reset() { *x = UpdateResourcesRequest{} - mi := &file_proto_paas_proto_msgTypes[33] + mi := &file_proto_paas_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1985,7 +2126,7 @@ func (x *UpdateResourcesRequest) String() string { func (*UpdateResourcesRequest) ProtoMessage() {} func (x *UpdateResourcesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[33] + mi := &file_proto_paas_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1998,7 +2139,7 @@ func (x *UpdateResourcesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateResourcesRequest.ProtoReflect.Descriptor instead. func (*UpdateResourcesRequest) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{33} + return file_proto_paas_proto_rawDescGZIP(), []int{34} } func (x *UpdateResourcesRequest) GetProject() string { @@ -2038,7 +2179,7 @@ type UpdateResourcesResponse struct { func (x *UpdateResourcesResponse) Reset() { *x = UpdateResourcesResponse{} - mi := &file_proto_paas_proto_msgTypes[34] + mi := &file_proto_paas_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2050,7 +2191,7 @@ func (x *UpdateResourcesResponse) String() string { func (*UpdateResourcesResponse) ProtoMessage() {} func (x *UpdateResourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[34] + mi := &file_proto_paas_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2063,7 +2204,7 @@ func (x *UpdateResourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateResourcesResponse.ProtoReflect.Descriptor instead. func (*UpdateResourcesResponse) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{34} + return file_proto_paas_proto_rawDescGZIP(), []int{35} } func (x *UpdateResourcesResponse) GetSuccess() bool { @@ -2087,7 +2228,7 @@ type UpdateDeployRequest struct { func (x *UpdateDeployRequest) Reset() { *x = UpdateDeployRequest{} - mi := &file_proto_paas_proto_msgTypes[35] + mi := &file_proto_paas_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2099,7 +2240,7 @@ func (x *UpdateDeployRequest) String() string { func (*UpdateDeployRequest) ProtoMessage() {} func (x *UpdateDeployRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[35] + mi := &file_proto_paas_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2112,7 +2253,7 @@ func (x *UpdateDeployRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateDeployRequest.ProtoReflect.Descriptor instead. func (*UpdateDeployRequest) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{35} + return file_proto_paas_proto_rawDescGZIP(), []int{36} } func (x *UpdateDeployRequest) GetProject() string { @@ -2166,7 +2307,7 @@ type UpdateDeployResponse struct { func (x *UpdateDeployResponse) Reset() { *x = UpdateDeployResponse{} - mi := &file_proto_paas_proto_msgTypes[36] + mi := &file_proto_paas_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2178,7 +2319,7 @@ func (x *UpdateDeployResponse) String() string { func (*UpdateDeployResponse) ProtoMessage() {} func (x *UpdateDeployResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_paas_proto_msgTypes[36] + mi := &file_proto_paas_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2191,7 +2332,7 @@ func (x *UpdateDeployResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateDeployResponse.ProtoReflect.Descriptor instead. func (*UpdateDeployResponse) Descriptor() ([]byte, []int) { - return file_proto_paas_proto_rawDescGZIP(), []int{36} + return file_proto_paas_proto_rawDescGZIP(), []int{37} } func (x *UpdateDeployResponse) GetSuccess() bool { @@ -2274,19 +2415,23 @@ const file_proto_paas_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12\x18\n" + "\aenabled\x18\x03 \x01(\bR\aenabled\x12\x14\n" + - "\x05image\x18\x04 \x01(\tR\x05image\"\xad\x01\n" + + "\x05image\x18\x04 \x01(\tR\x05image\"\xd0\x01\n" + "\x10AddDomainRequest\x12\x18\n" + "\aproject\x18\x01 \x01(\tR\aproject\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12\x12\n" + "\x04host\x18\x03 \x01(\tR\x04host\x12\x12\n" + "\x04port\x18\x04 \x01(\x05R\x04port\x12'\n" + "\x0fcompose_service\x18\x05 \x01(\tR\x0ecomposeService\x12\x14\n" + - "\x05https\x18\x06 \x01(\bR\x05https\"D\n" + + "\x05https\x18\x06 \x01(\bR\x05https\x12!\n" + + "\fservice_type\x18\a \x01(\tR\vserviceType\"D\n" + "\x11AddDomainResponse\x12\x1b\n" + "\tdomain_id\x18\x01 \x01(\tR\bdomainId\x12\x12\n" + - "\x04host\x18\x02 \x01(\tR\x04host\"2\n" + + "\x04host\x18\x02 \x01(\tR\x04host\"\x89\x01\n" + "\x13RemoveDomainRequest\x12\x1b\n" + - "\tdomain_id\x18\x01 \x01(\tR\bdomainId\"0\n" + + "\tdomain_id\x18\x01 \x01(\tR\bdomainId\x12\x18\n" + + "\aproject\x18\x02 \x01(\tR\aproject\x12\x18\n" + + "\aservice\x18\x03 \x01(\tR\aservice\x12!\n" + + "\fservice_type\x18\x04 \x01(\tR\vserviceType\"0\n" + "\x14RemoveDomainResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\"H\n" + "\x12ListDomainsRequest\x12\x18\n" + @@ -2329,12 +2474,21 @@ const file_proto_paas_proto_rawDesc = "" + "\aservice\x18\x02 \x01(\tR\aservice\x12!\n" + "\fservice_type\x18\x03 \x01(\tR\vserviceType\"0\n" + "\x14StartServiceResponse\x12\x18\n" + - "\asuccess\x18\x01 \x01(\bR\asuccess\"D\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\"\xa6\x01\n" + "\x0eGetLogsRequest\x12\x18\n" + "\aproject\x18\x01 \x01(\tR\aproject\x12\x18\n" + - "\aservice\x18\x02 \x01(\tR\aservice\"%\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12!\n" + + "\fservice_type\x18\x03 \x01(\tR\vserviceType\x12'\n" + + "\x0fcompose_service\x18\x04 \x01(\tR\x0ecomposeService\x12\x14\n" + + "\x05limit\x18\x05 \x01(\x05R\x05limit\"O\n" + "\x0fGetLogsResponse\x12\x12\n" + - "\x04logs\x18\x01 \x01(\tR\x04logs\"\x88\x01\n" + + "\x04logs\x18\x01 \x01(\tR\x04logs\x12(\n" + + "\aentries\x18\x02 \x03(\v2\x0e.paas.LogEntryR\aentries\"j\n" + + "\bLogEntry\x12\x1c\n" + + "\ttimestamp\x18\x01 \x01(\tR\ttimestamp\x12\x12\n" + + "\x04line\x18\x02 \x01(\tR\x04line\x12\x14\n" + + "\x05level\x18\x03 \x01(\tR\x05level\x12\x16\n" + + "\x06stream\x18\x04 \x01(\tR\x06stream\"\x88\x01\n" + "\x13ScaleServiceRequest\x12\x18\n" + "\aproject\x18\x01 \x01(\tR\aproject\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12!\n" + @@ -2393,7 +2547,7 @@ func file_proto_paas_proto_rawDescGZIP() []byte { return file_proto_paas_proto_rawDescData } -var file_proto_paas_proto_msgTypes = make([]protoimpl.MessageInfo, 40) +var file_proto_paas_proto_msgTypes = make([]protoimpl.MessageInfo, 41) var file_proto_paas_proto_goTypes = []any{ (*DeployContainerRequest)(nil), // 0: paas.DeployContainerRequest (*DeployContainerResponse)(nil), // 1: paas.DeployContainerResponse @@ -2425,64 +2579,66 @@ var file_proto_paas_proto_goTypes = []any{ (*StartServiceResponse)(nil), // 27: paas.StartServiceResponse (*GetLogsRequest)(nil), // 28: paas.GetLogsRequest (*GetLogsResponse)(nil), // 29: paas.GetLogsResponse - (*ScaleServiceRequest)(nil), // 30: paas.ScaleServiceRequest - (*ScaleServiceResponse)(nil), // 31: paas.ScaleServiceResponse - (*ResourceLimits)(nil), // 32: paas.ResourceLimits - (*UpdateResourcesRequest)(nil), // 33: paas.UpdateResourcesRequest - (*UpdateResourcesResponse)(nil), // 34: paas.UpdateResourcesResponse - (*UpdateDeployRequest)(nil), // 35: paas.UpdateDeployRequest - (*UpdateDeployResponse)(nil), // 36: paas.UpdateDeployResponse - nil, // 37: paas.DeployContainerRequest.EnvEntry - nil, // 38: paas.DeployComposeRequest.EnvEntry - nil, // 39: paas.UpdateEnvRequest.EnvEntry + (*LogEntry)(nil), // 30: paas.LogEntry + (*ScaleServiceRequest)(nil), // 31: paas.ScaleServiceRequest + (*ScaleServiceResponse)(nil), // 32: paas.ScaleServiceResponse + (*ResourceLimits)(nil), // 33: paas.ResourceLimits + (*UpdateResourcesRequest)(nil), // 34: paas.UpdateResourcesRequest + (*UpdateResourcesResponse)(nil), // 35: paas.UpdateResourcesResponse + (*UpdateDeployRequest)(nil), // 36: paas.UpdateDeployRequest + (*UpdateDeployResponse)(nil), // 37: paas.UpdateDeployResponse + nil, // 38: paas.DeployContainerRequest.EnvEntry + nil, // 39: paas.DeployComposeRequest.EnvEntry + nil, // 40: paas.UpdateEnvRequest.EnvEntry } var file_proto_paas_proto_depIdxs = []int32{ - 37, // 0: paas.DeployContainerRequest.env:type_name -> paas.DeployContainerRequest.EnvEntry - 32, // 1: paas.DeployContainerRequest.resources:type_name -> paas.ResourceLimits - 38, // 2: paas.DeployComposeRequest.env:type_name -> paas.DeployComposeRequest.EnvEntry + 38, // 0: paas.DeployContainerRequest.env:type_name -> paas.DeployContainerRequest.EnvEntry + 33, // 1: paas.DeployContainerRequest.resources:type_name -> paas.ResourceLimits + 39, // 2: paas.DeployComposeRequest.env:type_name -> paas.DeployComposeRequest.EnvEntry 3, // 3: paas.DeployComposeRequest.domains:type_name -> paas.ComposeDomain 5, // 4: paas.DeployComposeResponse.domains:type_name -> paas.DomainMapping 12, // 5: paas.ListServicesResponse.services:type_name -> paas.ServiceInfo 19, // 6: paas.ListDomainsResponse.domains:type_name -> paas.DomainInfo - 39, // 7: paas.UpdateEnvRequest.env:type_name -> paas.UpdateEnvRequest.EnvEntry - 32, // 8: paas.UpdateResourcesRequest.resources:type_name -> paas.ResourceLimits - 0, // 9: paas.PaaS.DeployContainer:input_type -> paas.DeployContainerRequest - 2, // 10: paas.PaaS.DeployCompose:input_type -> paas.DeployComposeRequest - 6, // 11: paas.PaaS.DestroyService:input_type -> paas.DestroyServiceRequest - 8, // 12: paas.PaaS.GetServiceStatus:input_type -> paas.GetServiceStatusRequest - 10, // 13: paas.PaaS.ListServices:input_type -> paas.ListServicesRequest - 13, // 14: paas.PaaS.AddDomain:input_type -> paas.AddDomainRequest - 15, // 15: paas.PaaS.RemoveDomain:input_type -> paas.RemoveDomainRequest - 17, // 16: paas.PaaS.ListDomains:input_type -> paas.ListDomainsRequest - 20, // 17: paas.PaaS.UpdateEnv:input_type -> paas.UpdateEnvRequest - 22, // 18: paas.PaaS.RestartService:input_type -> paas.RestartServiceRequest - 24, // 19: paas.PaaS.StopService:input_type -> paas.StopServiceRequest - 26, // 20: paas.PaaS.StartService:input_type -> paas.StartServiceRequest - 28, // 21: paas.PaaS.GetLogs:input_type -> paas.GetLogsRequest - 30, // 22: paas.PaaS.ScaleService:input_type -> paas.ScaleServiceRequest - 33, // 23: paas.PaaS.UpdateResources:input_type -> paas.UpdateResourcesRequest - 35, // 24: paas.PaaS.UpdateDeploy:input_type -> paas.UpdateDeployRequest - 1, // 25: paas.PaaS.DeployContainer:output_type -> paas.DeployContainerResponse - 4, // 26: paas.PaaS.DeployCompose:output_type -> paas.DeployComposeResponse - 7, // 27: paas.PaaS.DestroyService:output_type -> paas.DestroyServiceResponse - 9, // 28: paas.PaaS.GetServiceStatus:output_type -> paas.GetServiceStatusResponse - 11, // 29: paas.PaaS.ListServices:output_type -> paas.ListServicesResponse - 14, // 30: paas.PaaS.AddDomain:output_type -> paas.AddDomainResponse - 16, // 31: paas.PaaS.RemoveDomain:output_type -> paas.RemoveDomainResponse - 18, // 32: paas.PaaS.ListDomains:output_type -> paas.ListDomainsResponse - 21, // 33: paas.PaaS.UpdateEnv:output_type -> paas.UpdateEnvResponse - 23, // 34: paas.PaaS.RestartService:output_type -> paas.RestartServiceResponse - 25, // 35: paas.PaaS.StopService:output_type -> paas.StopServiceResponse - 27, // 36: paas.PaaS.StartService:output_type -> paas.StartServiceResponse - 29, // 37: paas.PaaS.GetLogs:output_type -> paas.GetLogsResponse - 31, // 38: paas.PaaS.ScaleService:output_type -> paas.ScaleServiceResponse - 34, // 39: paas.PaaS.UpdateResources:output_type -> paas.UpdateResourcesResponse - 36, // 40: paas.PaaS.UpdateDeploy:output_type -> paas.UpdateDeployResponse - 25, // [25:41] is the sub-list for method output_type - 9, // [9:25] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 40, // 7: paas.UpdateEnvRequest.env:type_name -> paas.UpdateEnvRequest.EnvEntry + 30, // 8: paas.GetLogsResponse.entries:type_name -> paas.LogEntry + 33, // 9: paas.UpdateResourcesRequest.resources:type_name -> paas.ResourceLimits + 0, // 10: paas.PaaS.DeployContainer:input_type -> paas.DeployContainerRequest + 2, // 11: paas.PaaS.DeployCompose:input_type -> paas.DeployComposeRequest + 6, // 12: paas.PaaS.DestroyService:input_type -> paas.DestroyServiceRequest + 8, // 13: paas.PaaS.GetServiceStatus:input_type -> paas.GetServiceStatusRequest + 10, // 14: paas.PaaS.ListServices:input_type -> paas.ListServicesRequest + 13, // 15: paas.PaaS.AddDomain:input_type -> paas.AddDomainRequest + 15, // 16: paas.PaaS.RemoveDomain:input_type -> paas.RemoveDomainRequest + 17, // 17: paas.PaaS.ListDomains:input_type -> paas.ListDomainsRequest + 20, // 18: paas.PaaS.UpdateEnv:input_type -> paas.UpdateEnvRequest + 22, // 19: paas.PaaS.RestartService:input_type -> paas.RestartServiceRequest + 24, // 20: paas.PaaS.StopService:input_type -> paas.StopServiceRequest + 26, // 21: paas.PaaS.StartService:input_type -> paas.StartServiceRequest + 28, // 22: paas.PaaS.GetLogs:input_type -> paas.GetLogsRequest + 31, // 23: paas.PaaS.ScaleService:input_type -> paas.ScaleServiceRequest + 34, // 24: paas.PaaS.UpdateResources:input_type -> paas.UpdateResourcesRequest + 36, // 25: paas.PaaS.UpdateDeploy:input_type -> paas.UpdateDeployRequest + 1, // 26: paas.PaaS.DeployContainer:output_type -> paas.DeployContainerResponse + 4, // 27: paas.PaaS.DeployCompose:output_type -> paas.DeployComposeResponse + 7, // 28: paas.PaaS.DestroyService:output_type -> paas.DestroyServiceResponse + 9, // 29: paas.PaaS.GetServiceStatus:output_type -> paas.GetServiceStatusResponse + 11, // 30: paas.PaaS.ListServices:output_type -> paas.ListServicesResponse + 14, // 31: paas.PaaS.AddDomain:output_type -> paas.AddDomainResponse + 16, // 32: paas.PaaS.RemoveDomain:output_type -> paas.RemoveDomainResponse + 18, // 33: paas.PaaS.ListDomains:output_type -> paas.ListDomainsResponse + 21, // 34: paas.PaaS.UpdateEnv:output_type -> paas.UpdateEnvResponse + 23, // 35: paas.PaaS.RestartService:output_type -> paas.RestartServiceResponse + 25, // 36: paas.PaaS.StopService:output_type -> paas.StopServiceResponse + 27, // 37: paas.PaaS.StartService:output_type -> paas.StartServiceResponse + 29, // 38: paas.PaaS.GetLogs:output_type -> paas.GetLogsResponse + 32, // 39: paas.PaaS.ScaleService:output_type -> paas.ScaleServiceResponse + 35, // 40: paas.PaaS.UpdateResources:output_type -> paas.UpdateResourcesResponse + 37, // 41: paas.PaaS.UpdateDeploy:output_type -> paas.UpdateDeployResponse + 26, // [26:42] is the sub-list for method output_type + 10, // [10:26] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_proto_paas_proto_init() } @@ -2496,7 +2652,7 @@ func file_proto_paas_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_paas_proto_rawDesc), len(file_proto_paas_proto_rawDesc)), NumEnums: 0, - NumMessages: 40, + NumMessages: 41, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/paas.proto b/proto/paas.proto index 08c207e..6bc7c13 100644 --- a/proto/paas.proto +++ b/proto/paas.proto @@ -159,6 +159,8 @@ message AddDomainRequest { int32 port = 4; // target port (default 80) string compose_service = 5; // for compose services bool https = 6; + // app (default) or compose. Domain config is applied by redeploying this type. + string service_type = 7; } message AddDomainResponse { @@ -168,6 +170,10 @@ message AddDomainResponse { message RemoveDomainRequest { string domain_id = 1; + // Required for automatic redeploy after compose/app domain removal. + string project = 2; + string service = 3; + string service_type = 4; } message RemoveDomainResponse { @@ -242,10 +248,28 @@ message StartServiceResponse { message GetLogsRequest { string project = 1; string service = 2; + // app (default) or compose. Compose services are served by a different + // panel route. + string service_type = 3; + // Optional: restrict compose logs to one internal compose service. + string compose_service = 4; + // Optional: max log lines to return. Defaults to 200. + int32 limit = 5; } message GetLogsResponse { string logs = 1; + repeated LogEntry entries = 2; +} + +message LogEntry { + // Nanosecond unix timestamp as reported by the panel log store. + string timestamp = 1; + string line = 2; + // Detected level (info, error, ...) when the panel provides one. + string level = 3; + // stdout or stderr when the panel provides one. + string stream = 4; } // --- Scale ---