Add input service state export and import functionality. Add /status endpoint. Add persistent state (write state to a file).
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
@@ -18,10 +19,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
repoDir = "cloud-provider-ip-addresses"
|
repoDir = "cloud-provider-ip-addresses"
|
||||||
port = 8080
|
port = 8080
|
||||||
|
stateDir = "progress_state"
|
||||||
|
saveInterval = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GeneratorState represents the serializable state of a generator
|
||||||
|
type GeneratorState struct {
|
||||||
|
CurrentFile int `json:"current_file"`
|
||||||
|
CurrentCIDRs []string `json:"current_cidrs"`
|
||||||
|
ActiveGenStates []HostGenState `json:"active_gen_states"`
|
||||||
|
CIDRFiles []string `json:"cidr_files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostGenState struct {
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
|
Current string `json:"current"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
// IPGenerator generates IPs from CIDR ranges lazily
|
// IPGenerator generates IPs from CIDR ranges lazily
|
||||||
type IPGenerator struct {
|
type IPGenerator struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -31,9 +48,12 @@ type IPGenerator struct {
|
|||||||
activeGens []*hostGenerator
|
activeGens []*hostGenerator
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
totalCIDRsCount int
|
totalCIDRsCount int
|
||||||
|
consumer string
|
||||||
|
dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type hostGenerator struct {
|
type hostGenerator struct {
|
||||||
|
cidr string
|
||||||
network *net.IPNet
|
network *net.IPNet
|
||||||
current net.IP
|
current net.IP
|
||||||
done bool
|
done bool
|
||||||
@@ -58,6 +78,7 @@ func newHostGenerator(cidr string) (*hostGenerator, error) {
|
|||||||
ones, bits := network.Mask.Size()
|
ones, bits := network.Mask.Size()
|
||||||
|
|
||||||
hg := &hostGenerator{
|
hg := &hostGenerator{
|
||||||
|
cidr: cidr,
|
||||||
network: network,
|
network: network,
|
||||||
current: make(net.IP, len(network.IP)),
|
current: make(net.IP, len(network.IP)),
|
||||||
}
|
}
|
||||||
@@ -131,11 +152,27 @@ func (hg *hostGenerator) next() (string, bool) {
|
|||||||
return ip, true
|
return ip, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPGenerator() (*IPGenerator, error) {
|
func (hg *hostGenerator) getState() HostGenState {
|
||||||
|
return HostGenState{
|
||||||
|
CIDR: hg.cidr,
|
||||||
|
Current: hg.current.String(),
|
||||||
|
Done: hg.done,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIPGenerator(consumer string) (*IPGenerator, error) {
|
||||||
gen := &IPGenerator{
|
gen := &IPGenerator{
|
||||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
|
consumer: consumer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to load existing state
|
||||||
|
if err := gen.loadState(); err == nil {
|
||||||
|
log.Printf("📂 Loaded saved state for consumer: %s", consumer)
|
||||||
|
return gen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No saved state, initialize fresh
|
||||||
// Find all IP files
|
// Find all IP files
|
||||||
err := filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,6 +197,7 @@ func newIPGenerator() (*IPGenerator, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("🆕 New generator for %s: %d IP files, %d CIDRs", consumer, len(gen.cidrFiles), gen.totalCIDRsCount)
|
||||||
log.Printf("📁 Found %d IP files", len(gen.cidrFiles))
|
log.Printf("📁 Found %d IP files", len(gen.cidrFiles))
|
||||||
log.Printf("📊 Total CIDRs discovered: %d", gen.totalCIDRsCount)
|
log.Printf("📊 Total CIDRs discovered: %d", gen.totalCIDRsCount)
|
||||||
|
|
||||||
@@ -230,6 +268,7 @@ func (g *IPGenerator) loadNextFile() error {
|
|||||||
g.activeGens = append(g.activeGens, gen)
|
g.activeGens = append(g.activeGens, gen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.dirty = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,23 +295,179 @@ func (g *IPGenerator) Next() (string, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
// This generator is exhausted, remove it
|
// This generator is exhausted, remove it
|
||||||
g.activeGens = append(g.activeGens[:idx], g.activeGens[idx+1:]...)
|
g.activeGens = append(g.activeGens[:idx], g.activeGens[idx+1:]...)
|
||||||
|
g.dirty = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.dirty = true
|
||||||
return ip, nil
|
return ip, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *IPGenerator) getState() GeneratorState {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
activeStates := make([]HostGenState, len(g.activeGens))
|
||||||
|
for i, gen := range g.activeGens {
|
||||||
|
activeStates[i] = gen.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeneratorState{
|
||||||
|
CurrentFile: g.currentFile,
|
||||||
|
CurrentCIDRs: g.currentCIDRs,
|
||||||
|
ActiveGenStates: activeStates,
|
||||||
|
CIDRFiles: g.cidrFiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *IPGenerator) saveState() error {
|
||||||
|
if !g.dirty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := g.getState()
|
||||||
|
|
||||||
|
// Ensure state directory exists
|
||||||
|
if err := os.MkdirAll(stateDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create state directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use consumer as filename (sanitize for filesystem)
|
||||||
|
filename := strings.ReplaceAll(g.consumer, ":", "_")
|
||||||
|
filename = strings.ReplaceAll(filename, "/", "_")
|
||||||
|
filepath := filepath.Join(stateDir, filename+".json")
|
||||||
|
|
||||||
|
// Write to temp file first, then rename for atomic write
|
||||||
|
tempPath := filepath + ".tmp"
|
||||||
|
file, err := os.Create(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(state); err != nil {
|
||||||
|
file.Close()
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to encode state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to close temp state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tempPath, filepath); err != nil {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to rename state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *IPGenerator) loadState() error {
|
||||||
|
// Use consumer as filename (sanitize for filesystem)
|
||||||
|
filename := strings.ReplaceAll(g.consumer, ":", "_")
|
||||||
|
filename = strings.ReplaceAll(filename, "/", "_")
|
||||||
|
filepath := filepath.Join(stateDir, filename+".json")
|
||||||
|
|
||||||
|
file, err := os.Open(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var state GeneratorState
|
||||||
|
if err := json.NewDecoder(file).Decode(&state); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore state
|
||||||
|
g.cidrFiles = state.CIDRFiles
|
||||||
|
g.currentFile = state.CurrentFile
|
||||||
|
g.currentCIDRs = state.CurrentCIDRs
|
||||||
|
g.totalCIDRsCount = len(state.CurrentCIDRs)
|
||||||
|
|
||||||
|
// Rebuild active generators from state
|
||||||
|
g.activeGens = make([]*hostGenerator, 0, len(state.ActiveGenStates))
|
||||||
|
for _, genState := range state.ActiveGenStates {
|
||||||
|
gen, err := newHostGenerator(genState.CIDR)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore current IP position
|
||||||
|
gen.current = net.ParseIP(genState.Current)
|
||||||
|
if gen.current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gen.done = genState.Done
|
||||||
|
|
||||||
|
g.activeGens = append(g.activeGens, gen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Server holds per-consumer generators
|
// Server holds per-consumer generators
|
||||||
type Server struct {
|
type Server struct {
|
||||||
generators map[string]*IPGenerator
|
generators map[string]*IPGenerator
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
stopSaver chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer() *Server {
|
func newServer() *Server {
|
||||||
return &Server{
|
s := &Server{
|
||||||
generators: make(map[string]*IPGenerator),
|
generators: make(map[string]*IPGenerator),
|
||||||
|
stopSaver: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.startPeriodicSaver()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startPeriodicSaver() {
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
ticker := time.NewTicker(saveInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.saveAllStates()
|
||||||
|
case <-s.stopSaver:
|
||||||
|
// Final save before shutdown
|
||||||
|
s.saveAllStates()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) saveAllStates() {
|
||||||
|
s.mu.RLock()
|
||||||
|
generators := make([]*IPGenerator, 0, len(s.generators))
|
||||||
|
for _, gen := range s.generators {
|
||||||
|
generators = append(generators, gen)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, gen := range generators {
|
||||||
|
if err := gen.saveState(); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to save state for %s: %v", gen.consumer, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) shutdown() {
|
||||||
|
close(s.stopSaver)
|
||||||
|
s.wg.Wait()
|
||||||
|
log.Println("💾 All states saved")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getGenerator(consumer string) (*IPGenerator, error) {
|
func (s *Server) getGenerator(consumer string) (*IPGenerator, error) {
|
||||||
@@ -293,7 +488,7 @@ func (s *Server) getGenerator(consumer string) (*IPGenerator, error) {
|
|||||||
return gen, nil
|
return gen, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
newGen, err := newIPGenerator()
|
newGen, err := newIPGenerator(consumer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -335,6 +530,160 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprintf(w, "%s\n", ip)
|
fmt.Fprintf(w, "%s\n", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConsumerStatus struct {
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
CurrentFile int `json:"current_file"`
|
||||||
|
TotalFiles int `json:"total_files"`
|
||||||
|
ActiveCIDRs int `json:"active_cidrs"`
|
||||||
|
TotalCIDRs int `json:"total_cidrs_discovered"`
|
||||||
|
CurrentFilePath string `json:"current_file_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusResponse struct {
|
||||||
|
TotalConsumers int `json:"total_consumers"`
|
||||||
|
Consumers []ConsumerStatus `json:"consumers"`
|
||||||
|
StateDirectory string `json:"state_directory"`
|
||||||
|
SaveInterval string `json:"save_interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
response := StatusResponse{
|
||||||
|
TotalConsumers: len(s.generators),
|
||||||
|
Consumers: make([]ConsumerStatus, 0, len(s.generators)),
|
||||||
|
StateDirectory: stateDir,
|
||||||
|
SaveInterval: saveInterval.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for consumer, gen := range s.generators {
|
||||||
|
gen.mu.Lock()
|
||||||
|
status := ConsumerStatus{
|
||||||
|
Consumer: consumer,
|
||||||
|
CurrentFile: gen.currentFile,
|
||||||
|
TotalFiles: len(gen.cidrFiles),
|
||||||
|
ActiveCIDRs: len(gen.activeGens),
|
||||||
|
TotalCIDRs: gen.totalCIDRsCount,
|
||||||
|
}
|
||||||
|
if gen.currentFile > 0 && gen.currentFile <= len(gen.cidrFiles) {
|
||||||
|
status.CurrentFilePath = gen.cidrFiles[gen.currentFile-1]
|
||||||
|
}
|
||||||
|
gen.mu.Unlock()
|
||||||
|
|
||||||
|
response.Consumers = append(response.Consumers, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(response); err != nil {
|
||||||
|
log.Printf("❌ Failed to encode status response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportResponse struct {
|
||||||
|
ExportedAt time.Time `json:"exported_at"`
|
||||||
|
States map[string]GeneratorState `json:"states"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force save all current states first
|
||||||
|
s.saveAllStates()
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
response := ExportResponse{
|
||||||
|
ExportedAt: time.Now(),
|
||||||
|
States: make(map[string]GeneratorState),
|
||||||
|
}
|
||||||
|
|
||||||
|
for consumer, gen := range s.generators {
|
||||||
|
response.States[consumer] = gen.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=state-export-%s.json",
|
||||||
|
time.Now().Format("2006-01-02-150405")))
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(response); err != nil {
|
||||||
|
log.Printf("❌ Failed to encode export response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var exportData ExportResponse
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&exportData); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to decode import data: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure state directory exists
|
||||||
|
if err := os.MkdirAll(stateDir, 0755); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create state directory: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
failed := 0
|
||||||
|
|
||||||
|
for consumer, state := range exportData.States {
|
||||||
|
// Sanitize consumer name for filename
|
||||||
|
filename := strings.ReplaceAll(consumer, ":", "_")
|
||||||
|
filename = strings.ReplaceAll(filename, "/", "_")
|
||||||
|
filepath := filepath.Join(stateDir, filename+".json")
|
||||||
|
|
||||||
|
file, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Failed to create state file for %s: %v", consumer, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(state); err != nil {
|
||||||
|
file.Close()
|
||||||
|
log.Printf("⚠️ Failed to encode state for %s: %v", consumer, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Close()
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"imported": imported,
|
||||||
|
"failed": failed,
|
||||||
|
"total": len(exportData.States),
|
||||||
|
"message": fmt.Sprintf("Successfully imported %d consumer states", imported),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
|
||||||
|
log.Printf("📥 Imported %d consumer states (%d failed)", imported, failed)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Check if repo directory exists
|
// Check if repo directory exists
|
||||||
if _, err := os.Stat(repoDir); os.IsNotExist(err) {
|
if _, err := os.Stat(repoDir); os.IsNotExist(err) {
|
||||||
@@ -345,6 +694,9 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", server.handleRequest)
|
mux.HandleFunc("/", server.handleRequest)
|
||||||
|
mux.HandleFunc("/status", server.handleStatus)
|
||||||
|
mux.HandleFunc("/export", server.handleExport)
|
||||||
|
mux.HandleFunc("/import", server.handleImport)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", port),
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
@@ -365,6 +717,9 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Stop periodic saver and save final state
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
if err := httpServer.Shutdown(ctx); err != nil {
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
log.Printf("❌ Error during shutdown: %v", err)
|
log.Printf("❌ Error during shutdown: %v", err)
|
||||||
}
|
}
|
||||||
@@ -373,6 +728,10 @@ func main() {
|
|||||||
log.Printf("🌐 HTTP Input Server running on http://localhost:%d", port)
|
log.Printf("🌐 HTTP Input Server running on http://localhost:%d", port)
|
||||||
log.Printf(" Serving individual IPv4 host addresses lazily")
|
log.Printf(" Serving individual IPv4 host addresses lazily")
|
||||||
log.Printf(" In highly mixed random order per consumer")
|
log.Printf(" In highly mixed random order per consumer")
|
||||||
|
log.Printf(" 💾 Progress saved every %v to '%s' directory", saveInterval, stateDir)
|
||||||
|
log.Printf(" 📊 Status endpoint: http://localhost:%d/status", port)
|
||||||
|
log.Printf(" 📤 Export endpoint: http://localhost:%d/export", port)
|
||||||
|
log.Printf(" 📥 Import endpoint: http://localhost:%d/import (POST)", port)
|
||||||
log.Printf(" Press Ctrl+C to stop")
|
log.Printf(" Press Ctrl+C to stop")
|
||||||
|
|
||||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
@@ -380,4 +739,4 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Println("✅ Server stopped cleanly")
|
log.Println("✅ Server stopped cleanly")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user