diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e088a53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,69 @@ +DUAL LICENSE + +This software is available under two licenses: + +================================================================================ +NON-PROFIT & EDUCATIONAL LICENSE (MIT-BASED) +================================================================================ + +For non-profit organizations, educational institutions, and personal use: + +Copyright (c) 2025 LibOps, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This license applies ONLY to: +✅ 501(c)(3) and other registered non-profit organizations +✅ Educational institutions (schools, universities, libraries) +✅ Personal/hobby use (non-commercial) +✅ Open-source projects (also licensed under compatible terms) + +================================================================================ +COMMERCIAL LICENSE REQUIRED +================================================================================ + +For-profit companies, businesses, and commercial use require a separate +commercial license. + +❌ This includes: + +- For-profit corporations and LLCs +- Consulting companies and contractors +- SaaS providers and cloud services +- Any commercial or revenue-generating use + +💰 Commercial License Benefits: + +- Right to use without restrictions +- Legal warranties and indemnification +- Technical support and updates +- Custom features and professional services + +📞 Contact for Commercial License: +Email: [info@libops.io] +Website: [www.libops.io] + +================================================================================ +COPYRIGHT NOTICE +================================================================================ + +Copyright (C) 2025 LibOps, LLC. +All rights reserved. + +Unauthorized use by for-profit entities is prohibited without a commercial license. diff --git a/README.md b/README.md index b92da2c..245436a 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,23 @@ This service is designed to run on **Google Cloud Run as an ingress layer** in f ## Architecture ``` -Internet → Cloud Run (PPB) → Google Compute Engine (Full App Stack) +Internet → Cloud Run (PPB) → Google Compute Engine (Full App Stack + lightsout) ``` - **Cloud Run**: Runs PPB as serverless ingress, scales to zero when no traffic - **GCE VM**: Runs your complete application (web server, database, etc.), can power off when idle - **PPB**: Powers on the VM when requests arrive, proxies traffic through with IP authorization +- **lightsout**: Monitors activity and automatically shuts down VMs during idle periods (optional companion service) + +### Complete On-Demand Infrastructure + +PPB works seamlessly with [lightsout](https://github.com/libops/lightsout) to create a complete on-demand infrastructure solution: + +- **PPB handles startup**: Automatically powers on GCE instances when traffic arrives +- **lightsout handles shutdown**: Monitors activity and automatically suspends instances during idle periods +- **Cost optimization**: Only pay for compute resources when actively serving traffic + +Deploy lightsout alongside your application on the GCE instance to complete the automation cycle. ## Cloud Run Behavior diff --git a/main.go b/main.go index 2c882e8..9de3b01 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,11 @@ import ( "log/slog" "net/http" "os" + "os/signal" "strings" + "sync" + "syscall" + "time" "github.com/libops/ppb/pkg/config" "github.com/libops/ppb/pkg/proxy" @@ -31,6 +35,45 @@ func init() { slog.SetDefault(handler) } +func startPingRoutine(ctx context.Context, wg *sync.WaitGroup, c *config.Config, interval time.Duration) { + defer wg.Done() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + slog.Info("Starting ping routine to GCE instance", "interval", interval) + + for { + select { + case <-ctx.Done(): + slog.Info("Ping routine shutting down") + return + case <-ticker.C: + host := c.Machine.Host() + if host == "" { + slog.Debug("No GCE host IP available for ping") + continue + } + + pingURL := fmt.Sprintf("http://%s:8808/ping", host) + slog.Debug("Pinging GCE instance", "url", pingURL) + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + resp, err := client.Get(pingURL) + if err != nil { + slog.Debug("Ping failed", "url", pingURL, "error", err) + continue + } + resp.Body.Close() + + slog.Debug("Ping successful", "url", pingURL, "status", resp.StatusCode) + } + } +} + func main() { c, err := config.LoadConfig() if err != nil { @@ -45,6 +88,14 @@ func main() { c.PowerOnCooldown = 30 } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) + var wg sync.WaitGroup + wg.Add(1) + go startPingRoutine(ctx, &wg, c, 30*time.Second) + http.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintln(w, "OK") @@ -58,8 +109,8 @@ func main() { } // Attempt to power on machine with cooldown protection - ctx := context.Background() - err := c.Machine.PowerOnWithCooldown(ctx, c.PowerOnCooldown) + reqCtx := context.Background() + err := c.Machine.PowerOnWithCooldown(reqCtx, c.PowerOnCooldown) if err != nil { slog.Error("Power-on attempt failed", "err", err) http.Error(w, "Backend not available", http.StatusServiceUnavailable) @@ -71,8 +122,24 @@ func main() { p.ServeHTTP(w, r) }) - slog.Info("Server listening on :8080") - if err := http.ListenAndServe(":8080", nil); err != nil { - panic(err) + server := &http.Server{Addr: ":8080"} + go func() { + slog.Info("Server listening on :8080") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("Server error", "err", err) + } + }() + + <-sigChan + slog.Info("Received shutdown signal, gracefully shutting down...") + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error("Server shutdown error", "err", err) } + + wg.Wait() + slog.Info("Shutdown complete") } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..d9c9f69 --- /dev/null +++ b/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/libops/ppb/pkg/config" + "github.com/libops/ppb/pkg/machine" +) + +func TestStartPingRoutine_Integration(t *testing.T) { + // Track ping requests + var pingCount int + var pingURLs []string + var mu sync.Mutex + + mux := http.NewServeMux() + mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + pingCount++ + pingURLs = append(pingURLs, r.URL.Path) + + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("pong")) + if err != nil { + slog.Error("Unable to write ping response", "err", err) + os.Exit(1) + } + }) + server := &http.Server{ + Addr: ":8808", + Handler: mux, + } + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // Ignore "address already in use" errors during testing + if !strings.Contains(err.Error(), "address already in use") { + t.Errorf("Mock server error: %v", err) + } + } + }() + + time.Sleep(1 * time.Second) + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := server.Shutdown(ctx) + if err != nil { + slog.Error("Server shutdown failed", "err", err) + os.Exit(1) + } + }() + + mockMachine := machine.NewGceMachine() + mockMachine.SetHostForTesting("127.0.0.1") + + config := &config.Config{ + Machine: mockMachine, + } + + ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond) + defer cancel() + + var wg sync.WaitGroup + wg.Add(1) + + go startPingRoutine(ctx, &wg, config, 100*time.Millisecond) + wg.Wait() + + mu.Lock() + defer mu.Unlock() + + if pingCount == 0 { + t.Error("Expected at least one ping request, got none") + } + + // Verify all requests were to /ping endpoint + for _, url := range pingURLs { + if url != "/ping" { + t.Errorf("Expected ping to /ping endpoint, got %s", url) + } + } + + // Should have made multiple pings (at least 2-3 in 350ms with 100ms interval) + if pingCount < 2 { + t.Errorf("Expected at least 2 ping requests, got %d", pingCount) + } +} + +func TestStartPingRoutine_ContextCancellation(t *testing.T) { + mockMachine := machine.NewGceMachine() + mockMachine.SetHostForTesting("127.0.0.1") + + config := &config.Config{ + Machine: mockMachine, + } + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Add(1) + + routineFinished := make(chan bool, 1) + + go func() { + startPingRoutine(ctx, &wg, config, 50*time.Millisecond) + routineFinished <- true + }() + + time.Sleep(100 * time.Millisecond) + cancel() + + wg.Wait() + + // Verify the routine actually finished + select { + case <-routineFinished: + case <-time.After(1 * time.Second): + t.Error("Ping routine did not finish within expected time after context cancellation") + } +}