Skip to content

Commit 980ee0b

Browse files
authored
[minor] ping lightsout (#3)
* ping lightsout * Add ping test
1 parent f1b72f2 commit 980ee0b

4 files changed

Lines changed: 281 additions & 6 deletions

File tree

LICENSE

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
DUAL LICENSE
2+
3+
This software is available under two licenses:
4+
5+
================================================================================
6+
NON-PROFIT & EDUCATIONAL LICENSE (MIT-BASED)
7+
================================================================================
8+
9+
For non-profit organizations, educational institutions, and personal use:
10+
11+
Copyright (c) 2025 LibOps, LLC.
12+
13+
Permission is hereby granted, free of charge, to any person obtaining a copy
14+
of this software and associated documentation files (the "Software"), to deal
15+
in the Software without restriction, including without limitation the rights
16+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17+
copies of the Software, and to permit persons to whom the Software is
18+
furnished to do so, subject to the following conditions:
19+
20+
The above copyright notice and this permission notice shall be included in all
21+
copies or substantial portions of the Software.
22+
23+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29+
SOFTWARE.
30+
31+
This license applies ONLY to:
32+
✅ 501(c)(3) and other registered non-profit organizations
33+
✅ Educational institutions (schools, universities, libraries)
34+
✅ Personal/hobby use (non-commercial)
35+
✅ Open-source projects (also licensed under compatible terms)
36+
37+
================================================================================
38+
COMMERCIAL LICENSE REQUIRED
39+
================================================================================
40+
41+
For-profit companies, businesses, and commercial use require a separate
42+
commercial license.
43+
44+
❌ This includes:
45+
46+
- For-profit corporations and LLCs
47+
- Consulting companies and contractors
48+
- SaaS providers and cloud services
49+
- Any commercial or revenue-generating use
50+
51+
💰 Commercial License Benefits:
52+
53+
- Right to use without restrictions
54+
- Legal warranties and indemnification
55+
- Technical support and updates
56+
- Custom features and professional services
57+
58+
📞 Contact for Commercial License:
59+
Email: [info@libops.io]
60+
Website: [www.libops.io]
61+
62+
================================================================================
63+
COPYRIGHT NOTICE
64+
================================================================================
65+
66+
Copyright (C) 2025 LibOps, LLC.
67+
All rights reserved.
68+
69+
Unauthorized use by for-profit entities is prohibited without a commercial license.

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,23 @@ This service is designed to run on **Google Cloud Run as an ingress layer** in f
77
## Architecture
88

99
```
10-
Internet → Cloud Run (PPB) → Google Compute Engine (Full App Stack)
10+
Internet → Cloud Run (PPB) → Google Compute Engine (Full App Stack + lightsout)
1111
```
1212

1313
- **Cloud Run**: Runs PPB as serverless ingress, scales to zero when no traffic
1414
- **GCE VM**: Runs your complete application (web server, database, etc.), can power off when idle
1515
- **PPB**: Powers on the VM when requests arrive, proxies traffic through with IP authorization
16+
- **lightsout**: Monitors activity and automatically shuts down VMs during idle periods (optional companion service)
17+
18+
### Complete On-Demand Infrastructure
19+
20+
PPB works seamlessly with [lightsout](https://github.com/libops/lightsout) to create a complete on-demand infrastructure solution:
21+
22+
- **PPB handles startup**: Automatically powers on GCE instances when traffic arrives
23+
- **lightsout handles shutdown**: Monitors activity and automatically suspends instances during idle periods
24+
- **Cost optimization**: Only pay for compute resources when actively serving traffic
25+
26+
Deploy lightsout alongside your application on the GCE instance to complete the automation cycle.
1627

1728
## Cloud Run Behavior
1829

main.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import (
66
"log/slog"
77
"net/http"
88
"os"
9+
"os/signal"
910
"strings"
11+
"sync"
12+
"syscall"
13+
"time"
1014

1115
"github.com/libops/ppb/pkg/config"
1216
"github.com/libops/ppb/pkg/proxy"
@@ -31,6 +35,45 @@ func init() {
3135
slog.SetDefault(handler)
3236
}
3337

38+
func startPingRoutine(ctx context.Context, wg *sync.WaitGroup, c *config.Config, interval time.Duration) {
39+
defer wg.Done()
40+
41+
ticker := time.NewTicker(interval)
42+
defer ticker.Stop()
43+
44+
slog.Info("Starting ping routine to GCE instance", "interval", interval)
45+
46+
for {
47+
select {
48+
case <-ctx.Done():
49+
slog.Info("Ping routine shutting down")
50+
return
51+
case <-ticker.C:
52+
host := c.Machine.Host()
53+
if host == "" {
54+
slog.Debug("No GCE host IP available for ping")
55+
continue
56+
}
57+
58+
pingURL := fmt.Sprintf("http://%s:8808/ping", host)
59+
slog.Debug("Pinging GCE instance", "url", pingURL)
60+
61+
client := &http.Client{
62+
Timeout: 5 * time.Second,
63+
}
64+
65+
resp, err := client.Get(pingURL)
66+
if err != nil {
67+
slog.Debug("Ping failed", "url", pingURL, "error", err)
68+
continue
69+
}
70+
resp.Body.Close()
71+
72+
slog.Debug("Ping successful", "url", pingURL, "status", resp.StatusCode)
73+
}
74+
}
75+
}
76+
3477
func main() {
3578
c, err := config.LoadConfig()
3679
if err != nil {
@@ -45,6 +88,14 @@ func main() {
4588
c.PowerOnCooldown = 30
4689
}
4790

91+
ctx, cancel := context.WithCancel(context.Background())
92+
defer cancel()
93+
sigChan := make(chan os.Signal, 1)
94+
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
95+
var wg sync.WaitGroup
96+
wg.Add(1)
97+
go startPingRoutine(ctx, &wg, c, 30*time.Second)
98+
4899
http.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
49100
w.WriteHeader(http.StatusOK)
50101
_, _ = fmt.Fprintln(w, "OK")
@@ -58,8 +109,8 @@ func main() {
58109
}
59110

60111
// Attempt to power on machine with cooldown protection
61-
ctx := context.Background()
62-
err := c.Machine.PowerOnWithCooldown(ctx, c.PowerOnCooldown)
112+
reqCtx := context.Background()
113+
err := c.Machine.PowerOnWithCooldown(reqCtx, c.PowerOnCooldown)
63114
if err != nil {
64115
slog.Error("Power-on attempt failed", "err", err)
65116
http.Error(w, "Backend not available", http.StatusServiceUnavailable)
@@ -71,8 +122,24 @@ func main() {
71122
p.ServeHTTP(w, r)
72123
})
73124

74-
slog.Info("Server listening on :8080")
75-
if err := http.ListenAndServe(":8080", nil); err != nil {
76-
panic(err)
125+
server := &http.Server{Addr: ":8080"}
126+
go func() {
127+
slog.Info("Server listening on :8080")
128+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
129+
slog.Error("Server error", "err", err)
130+
}
131+
}()
132+
133+
<-sigChan
134+
slog.Info("Received shutdown signal, gracefully shutting down...")
135+
cancel()
136+
137+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
138+
defer shutdownCancel()
139+
if err := server.Shutdown(shutdownCtx); err != nil {
140+
slog.Error("Server shutdown error", "err", err)
77141
}
142+
143+
wg.Wait()
144+
slog.Info("Shutdown complete")
78145
}

main_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"net/http"
7+
"os"
8+
"strings"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/libops/ppb/pkg/config"
14+
"github.com/libops/ppb/pkg/machine"
15+
)
16+
17+
func TestStartPingRoutine_Integration(t *testing.T) {
18+
// Track ping requests
19+
var pingCount int
20+
var pingURLs []string
21+
var mu sync.Mutex
22+
23+
mux := http.NewServeMux()
24+
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
25+
mu.Lock()
26+
defer mu.Unlock()
27+
pingCount++
28+
pingURLs = append(pingURLs, r.URL.Path)
29+
30+
w.WriteHeader(http.StatusOK)
31+
_, err := w.Write([]byte("pong"))
32+
if err != nil {
33+
slog.Error("Unable to write ping response", "err", err)
34+
os.Exit(1)
35+
}
36+
})
37+
server := &http.Server{
38+
Addr: ":8808",
39+
Handler: mux,
40+
}
41+
go func() {
42+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
43+
// Ignore "address already in use" errors during testing
44+
if !strings.Contains(err.Error(), "address already in use") {
45+
t.Errorf("Mock server error: %v", err)
46+
}
47+
}
48+
}()
49+
50+
time.Sleep(1 * time.Second)
51+
52+
defer func() {
53+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
54+
defer cancel()
55+
err := server.Shutdown(ctx)
56+
if err != nil {
57+
slog.Error("Server shutdown failed", "err", err)
58+
os.Exit(1)
59+
}
60+
}()
61+
62+
mockMachine := machine.NewGceMachine()
63+
mockMachine.SetHostForTesting("127.0.0.1")
64+
65+
config := &config.Config{
66+
Machine: mockMachine,
67+
}
68+
69+
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
70+
defer cancel()
71+
72+
var wg sync.WaitGroup
73+
wg.Add(1)
74+
75+
go startPingRoutine(ctx, &wg, config, 100*time.Millisecond)
76+
wg.Wait()
77+
78+
mu.Lock()
79+
defer mu.Unlock()
80+
81+
if pingCount == 0 {
82+
t.Error("Expected at least one ping request, got none")
83+
}
84+
85+
// Verify all requests were to /ping endpoint
86+
for _, url := range pingURLs {
87+
if url != "/ping" {
88+
t.Errorf("Expected ping to /ping endpoint, got %s", url)
89+
}
90+
}
91+
92+
// Should have made multiple pings (at least 2-3 in 350ms with 100ms interval)
93+
if pingCount < 2 {
94+
t.Errorf("Expected at least 2 ping requests, got %d", pingCount)
95+
}
96+
}
97+
98+
func TestStartPingRoutine_ContextCancellation(t *testing.T) {
99+
mockMachine := machine.NewGceMachine()
100+
mockMachine.SetHostForTesting("127.0.0.1")
101+
102+
config := &config.Config{
103+
Machine: mockMachine,
104+
}
105+
106+
ctx, cancel := context.WithCancel(context.Background())
107+
var wg sync.WaitGroup
108+
wg.Add(1)
109+
110+
routineFinished := make(chan bool, 1)
111+
112+
go func() {
113+
startPingRoutine(ctx, &wg, config, 50*time.Millisecond)
114+
routineFinished <- true
115+
}()
116+
117+
time.Sleep(100 * time.Millisecond)
118+
cancel()
119+
120+
wg.Wait()
121+
122+
// Verify the routine actually finished
123+
select {
124+
case <-routineFinished:
125+
case <-time.After(1 * time.Second):
126+
t.Error("Ping routine did not finish within expected time after context cancellation")
127+
}
128+
}

0 commit comments

Comments
 (0)