Skip to content

Commit 36b4142

Browse files
author
mirkobrombin
committed
feat: add PythonPlugin with Flask integration and logging support
1 parent 5adc2ff commit 36b4142

File tree

5 files changed

+393
-1
lines changed

5 files changed

+393
-1
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ private.key
22
certificate.crt
33
test.sh
44
public/node_test/app/node_modules
5-
public/node_test/app/pnpm-lock.yaml
5+
public/node_test/app/pnpm-lock.yaml
6+
public/**/*.pyc
7+
public/**/*.venv

cmd/goup/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ func main() {
1515
pluginManager.Register(&plugins.PHPPlugin{})
1616
pluginManager.Register(&plugins.AuthPlugin{})
1717
pluginManager.Register(&plugins.NodeJSPlugin{})
18+
pluginManager.Register(&plugins.PythonPlugin{})
1819

1920
cli.Execute()
2021
}

plugins/python.go

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
package plugins
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"sync"
13+
14+
"github.com/mirkobrombin/goup/internal/config"
15+
"github.com/mirkobrombin/goup/internal/plugin"
16+
log "github.com/sirupsen/logrus"
17+
)
18+
19+
type PythonPluginConfig struct {
20+
Enable bool `json:"enable"`
21+
Port string `json:"port"`
22+
RootDir string `json:"root_dir"`
23+
AppType string `json:"app_type"`
24+
Command string `json:"command"`
25+
PackageManager string `json:"package_manager"`
26+
InstallDeps bool `json:"install_deps"`
27+
EnvVars map[string]string `json:"env_vars"`
28+
ProxyPaths []string `json:"proxy_paths"`
29+
UseVenv bool `json:"use_venv"`
30+
}
31+
32+
type pythonProcessState struct {
33+
process *os.Process
34+
config PythonPluginConfig
35+
}
36+
37+
type PythonPlugin struct {
38+
plugin.BasePlugin
39+
40+
mu sync.Mutex
41+
processes map[string]*pythonProcessState
42+
}
43+
44+
func (p *PythonPlugin) Name() string {
45+
return "PythonPlugin"
46+
}
47+
48+
func (p *PythonPlugin) OnInit() error {
49+
p.processes = make(map[string]*pythonProcessState)
50+
return nil
51+
}
52+
53+
func (p *PythonPlugin) OnInitForSite(conf config.SiteConfig, baseLogger *log.Logger) error {
54+
if err := p.SetupLoggers(conf, p.Name(), baseLogger); err != nil {
55+
return err
56+
}
57+
58+
raw, ok := conf.PluginConfigs[p.Name()]
59+
if !ok {
60+
p.processes[conf.Domain] = &pythonProcessState{config: PythonPluginConfig{}}
61+
return nil
62+
}
63+
64+
cfg := PythonPluginConfig{}
65+
if rawMap, ok := raw.(map[string]interface{}); ok {
66+
if v, ok := rawMap["enable"].(bool); ok {
67+
cfg.Enable = v
68+
}
69+
if v, ok := rawMap["port"].(string); ok {
70+
cfg.Port = v
71+
}
72+
if v, ok := rawMap["root_dir"].(string); ok {
73+
cfg.RootDir = v
74+
}
75+
if v, ok := rawMap["app_type"].(string); ok {
76+
cfg.AppType = v
77+
}
78+
if v, ok := rawMap["command"].(string); ok {
79+
cfg.Command = v
80+
}
81+
if v, ok := rawMap["package_manager"].(string); ok {
82+
cfg.PackageManager = v
83+
}
84+
if v, ok := rawMap["install_deps"].(bool); ok {
85+
cfg.InstallDeps = v
86+
}
87+
if envVars, ok := rawMap["env_vars"].(map[string]interface{}); ok {
88+
tmp := make(map[string]string)
89+
for k, val := range envVars {
90+
if s, ok := val.(string); ok {
91+
tmp[k] = s
92+
}
93+
}
94+
cfg.EnvVars = tmp
95+
}
96+
if proxyPaths, ok := rawMap["proxy_paths"].([]interface{}); ok {
97+
for _, pathVal := range proxyPaths {
98+
if pathStr, ok := pathVal.(string); ok {
99+
cfg.ProxyPaths = append(cfg.ProxyPaths, pathStr)
100+
}
101+
}
102+
}
103+
if uv, ok := rawMap["use_venv"].(bool); ok {
104+
cfg.UseVenv = uv
105+
}
106+
}
107+
p.processes[conf.Domain] = &pythonProcessState{config: cfg}
108+
return nil
109+
}
110+
111+
func (p *PythonPlugin) BeforeRequest(r *http.Request) {}
112+
113+
func (p *PythonPlugin) HandleRequest(w http.ResponseWriter, r *http.Request) bool {
114+
host := r.Host
115+
if idx := strings.Index(host, ":"); idx != -1 {
116+
host = host[:idx]
117+
}
118+
119+
st, ok := p.processes[host]
120+
if !ok || !st.config.Enable {
121+
return false
122+
}
123+
124+
p.ensurePythonProcess(host)
125+
126+
if len(st.config.ProxyPaths) == 1 && st.config.ProxyPaths[0] == "/" {
127+
p.proxyToPython(host, w, r)
128+
return true
129+
}
130+
for _, prefix := range st.config.ProxyPaths {
131+
if strings.HasPrefix(r.URL.Path, prefix) {
132+
p.DomainLogger.Infof("[PythonPlugin] Delegating path=%s to python, domain=%s", r.URL.Path, host)
133+
p.proxyToPython(host, w, r)
134+
return true
135+
}
136+
}
137+
return false
138+
}
139+
140+
func (p *PythonPlugin) AfterRequest(w http.ResponseWriter, r *http.Request) {}
141+
142+
func (p *PythonPlugin) OnExit() error {
143+
p.mu.Lock()
144+
defer p.mu.Unlock()
145+
for domain, st := range p.processes {
146+
if st.process != nil {
147+
p.PluginLogger.Infof("Terminating Python process for domain '%s' (PID=%d)", domain, st.process.Pid)
148+
_ = st.process.Kill()
149+
st.process = nil
150+
}
151+
}
152+
return nil
153+
}
154+
155+
func (p *PythonPlugin) ensurePythonProcess(domain string) {
156+
p.mu.Lock()
157+
defer p.mu.Unlock()
158+
159+
st := p.processes[domain]
160+
if st == nil || st.config.Port == "" || st.process != nil {
161+
return
162+
}
163+
164+
p.DomainLogger.Infof("[PythonPlugin] Starting python server for domain=%s ...", domain)
165+
pythonCmd := st.config.Command
166+
if pythonCmd == "" {
167+
pythonCmd = "python"
168+
if _, err := exec.LookPath("python3"); err == nil {
169+
pythonCmd = "python3"
170+
}
171+
}
172+
173+
var venvPy string
174+
if st.config.UseVenv {
175+
venvPy = p.setupVenv(st.config, pythonCmd)
176+
if venvPy != "" {
177+
pythonCmd = venvPy
178+
} else {
179+
p.PluginLogger.Warnf("Failed to setup venv, fallback to system python: %s", pythonCmd)
180+
}
181+
}
182+
183+
if st.config.InstallDeps {
184+
p.installDeps(st.config, pythonCmd)
185+
}
186+
187+
var args []string
188+
switch strings.ToLower(st.config.AppType) {
189+
case "flask":
190+
args = []string{"-m", "flask", "run", "--host=0.0.0.0", "--port=" + st.config.Port}
191+
case "django":
192+
args = []string{"manage.py", "runserver", "0.0.0.0:" + st.config.Port}
193+
default:
194+
entryFile := filepath.Join(st.config.RootDir, "app.py")
195+
args = []string{entryFile}
196+
}
197+
198+
cmd := exec.Command(pythonCmd, args...)
199+
cmd.Dir = st.config.RootDir
200+
201+
envList := os.Environ()
202+
for k, v := range st.config.EnvVars {
203+
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
204+
}
205+
cmd.Env = envList
206+
207+
// Write python stdout to the plugin logger
208+
cmd.Stdout = p.PluginLogger.Writer()
209+
cmd.Stderr = p.PluginLogger.Writer()
210+
211+
if err := cmd.Start(); err != nil {
212+
p.PluginLogger.Errorf("Failed to start Python process for '%s': %v", domain, err)
213+
return
214+
}
215+
216+
st.process = cmd.Process
217+
p.PluginLogger.Infof("Started Python server for domain '%s' (PID=%d) on port %s",
218+
domain, st.process.Pid, st.config.Port)
219+
220+
go func(dom string, c *exec.Cmd) {
221+
err := c.Wait()
222+
p.PluginLogger.Infof("Python server exited for domain '%s' (PID=%d), err=%v", dom, c.Process.Pid, err)
223+
p.PluginLogger.Writer().Close()
224+
p.mu.Lock()
225+
st.process = nil
226+
p.mu.Unlock()
227+
}(domain, cmd)
228+
}
229+
230+
func (p *PythonPlugin) proxyToPython(domain string, w http.ResponseWriter, r *http.Request) {
231+
p.mu.Lock()
232+
st := p.processes[domain]
233+
p.mu.Unlock()
234+
235+
if st == nil {
236+
http.Error(w, "Python not configured for this domain", http.StatusBadGateway)
237+
return
238+
}
239+
240+
targetURL := fmt.Sprintf("http://localhost:%s%s", st.config.Port, r.URL.Path)
241+
if r.URL.RawQuery != "" {
242+
targetURL += "?" + r.URL.RawQuery
243+
}
244+
245+
p.DomainLogger.Infof("[PythonPlugin] Delegating path=%s to Python", targetURL)
246+
247+
bodyData, err := io.ReadAll(r.Body)
248+
if err != nil {
249+
p.PluginLogger.Errorf("Failed to read request body: %v", err)
250+
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
251+
return
252+
}
253+
defer r.Body.Close()
254+
255+
req, err := http.NewRequest(r.Method, targetURL, strings.NewReader(string(bodyData)))
256+
if err != nil {
257+
p.PluginLogger.Errorf("Failed to create request for Python app: %v", err)
258+
http.Error(w, "Failed to create request", http.StatusInternalServerError)
259+
return
260+
}
261+
262+
for k, vals := range r.Header {
263+
for _, val := range vals {
264+
req.Header.Add(k, val)
265+
}
266+
}
267+
268+
client := &http.Client{}
269+
resp, err := client.Do(req)
270+
if err != nil {
271+
p.PluginLogger.Errorf("Failed to connect to Python backend [%s]: %v", domain, err)
272+
http.Error(w, "Python backend unavailable", http.StatusBadGateway)
273+
return
274+
}
275+
defer resp.Body.Close()
276+
277+
for k, vals := range resp.Header {
278+
for _, val := range vals {
279+
w.Header().Add(k, val)
280+
}
281+
}
282+
w.WriteHeader(resp.StatusCode)
283+
284+
respBody, err := io.ReadAll(resp.Body)
285+
if err != nil {
286+
p.PluginLogger.Errorf("Failed to read response from Python app [%s]: %v", domain, err)
287+
http.Error(w, "Failed to read response from Python app", http.StatusInternalServerError)
288+
return
289+
}
290+
w.Write(respBody)
291+
}
292+
293+
func (p *PythonPlugin) setupVenv(cfg PythonPluginConfig, systemPython string) string {
294+
venvDir := filepath.Join(cfg.RootDir, ".venv")
295+
296+
if _, err := os.Stat(venvDir); os.IsNotExist(err) {
297+
p.PluginLogger.Infof("Creating venv in %s", venvDir)
298+
cmd := exec.Command(systemPython, "-m", "venv", ".venv")
299+
cmd.Dir = cfg.RootDir
300+
cmd.Stdout = p.PluginLogger.Writer()
301+
cmd.Stderr = p.PluginLogger.Writer()
302+
if err := cmd.Run(); err != nil {
303+
p.PluginLogger.Errorf("Failed to create venv: %v", err)
304+
return ""
305+
}
306+
}
307+
308+
if runtime.GOOS == "windows" {
309+
return filepath.Join(venvDir, "Scripts", "python.exe")
310+
}
311+
return filepath.Join(venvDir, "bin", "python")
312+
}
313+
314+
func (p *PythonPlugin) installDeps(cfg PythonPluginConfig, pythonCmd string) {
315+
p.PluginLogger.Infof("Looking for dependencies in %s", cfg.RootDir)
316+
317+
reqTxt := filepath.Join(cfg.RootDir, "requirements.txt")
318+
pyProj := filepath.Join(cfg.RootDir, "pyproject.toml")
319+
320+
manager := cfg.PackageManager
321+
if manager == "" {
322+
manager = "pip"
323+
}
324+
325+
plugin.ShowProgressBar("Installing Python dependencies...")
326+
defer plugin.HideProgressBar()
327+
328+
switch manager {
329+
case "pip", "pip3":
330+
if _, err := os.Stat(reqTxt); err == nil {
331+
cmd := exec.Command(pythonCmd, "-m", "pip", "install", "-r", reqTxt)
332+
cmd.Dir = cfg.RootDir
333+
cmd.Stdout = p.PluginLogger.Writer()
334+
cmd.Stderr = p.PluginLogger.Writer()
335+
if err := cmd.Run(); err != nil {
336+
p.PluginLogger.Errorf("Failed to install deps with pip: %v", err)
337+
} else {
338+
fmt.Println("Python dependencies installed successfully (pip).")
339+
}
340+
} else {
341+
cmd := exec.Command(pythonCmd, "-m", "pip", "install")
342+
cmd.Dir = cfg.RootDir
343+
cmd.Stdout = p.PluginLogger.Writer()
344+
cmd.Stderr = p.PluginLogger.Writer()
345+
if err := cmd.Run(); err != nil {
346+
p.PluginLogger.Errorf("Failed to run pip install: %v", err)
347+
} else {
348+
fmt.Println("Python dependencies installed successfully (pip).")
349+
}
350+
}
351+
case "poetry":
352+
if _, err := os.Stat(pyProj); err == nil {
353+
cmd := exec.Command("poetry", "install")
354+
cmd.Dir = cfg.RootDir
355+
cmd.Stdout = p.PluginLogger.Writer()
356+
cmd.Stderr = p.PluginLogger.Writer()
357+
if err := cmd.Run(); err != nil {
358+
p.PluginLogger.Errorf("Failed to install deps with poetry: %v", err)
359+
} else {
360+
fmt.Println("Python dependencies installed successfully (poetry).")
361+
}
362+
}
363+
case "pipenv":
364+
cmd := exec.Command("pipenv", "install")
365+
cmd.Dir = cfg.RootDir
366+
cmd.Stdout = p.PluginLogger.Writer()
367+
cmd.Stderr = p.PluginLogger.Writer()
368+
if err := cmd.Run(); err != nil {
369+
p.PluginLogger.Errorf("Failed to install deps with pipenv: %v", err)
370+
} else {
371+
fmt.Println("Python dependencies installed successfully (pipenv).")
372+
}
373+
default:
374+
fmt.Printf("Package manager '%s' is not directly supported.\n", manager)
375+
}
376+
}

0 commit comments

Comments
 (0)