Skip to content
Merged

Dev #24

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/Containerfile.api
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ RUN go mod download
COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="-s -w -X github.com/narvanalabs/control-plane/internal/api.Version=${VERSION}" \
-o /api ./cmd/api

# Runtime
Expand Down
2 changes: 2 additions & 0 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ services:
ports:
- "${API_PORT:-8080}:8080"
- "${GRPC_PORT:-9090}:9090"
volumes:
- /var/lib/narvana:/var/lib/narvana # Shared volume for update flags
environment:
DATABASE_URL: postgres://narvana:${POSTGRES_PASSWORD}@postgres:5432/narvana?sslmode=disable
JWT_SECRET: ${JWT_SECRET}
Expand Down
25 changes: 25 additions & 0 deletions deploy/narvana-updater.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[Unit]
Description=Narvana Update Watcher
Documentation=https://github.com/narvanalabs/control-plane
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/narvana
ExecStart=/opt/narvana/update-watcher.sh
Restart=always
RestartSec=10

# Environment
Environment=UPDATE_FLAG_FILE=/var/lib/narvana/.update-requested
Environment=COMPOSE_FILE=/opt/narvana/compose.yaml
Environment=ENV_FILE=/opt/narvana/.env

# Security
NoNewPrivileges=false
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (

require (
filippo.io/hpke v0.4.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE=
github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
Expand Down
12 changes: 12 additions & 0 deletions internal/api/handlers/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,18 @@ func (h *GitHubHandler) AppInstall(w http.ResponseWriter, r *http.Request) {
return
}

// Ensure this is an App configuration (not OAuth)
if config.ConfigType != "app" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "Not a GitHub App configuration"})
return
}

// Check if slug is available
if config.Slug == nil || *config.Slug == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "GitHub App slug not available"})
return
}

installURL := fmt.Sprintf("https://github.com/settings/apps/%s/installations/new", *config.Slug)
WriteJSON(w, http.StatusOK, map[string]string{"url": installURL})
}
Expand Down
51 changes: 47 additions & 4 deletions internal/api/handlers/server_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@ import (
)

type ServerStatsHandler struct {
logger *slog.Logger
logger *slog.Logger
version string
}

func NewServerStatsHandler(logger *slog.Logger) *ServerStatsHandler {
return &ServerStatsHandler{logger: logger}
func NewServerStatsHandler(logger *slog.Logger, version string) *ServerStatsHandler {
return &ServerStatsHandler{
logger: logger,
version: version,
}
}

type SystemStats struct {
Resources *models.NodeResources `json:"resources"`
Uptime float64 `json:"uptime"`
OS string `json:"os"`
Hostname string `json:"hostname"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
}

Expand Down Expand Up @@ -91,16 +96,54 @@ func (h *ServerStatsHandler) collectStats() SystemStats {
}

hostname, _ := os.Hostname()
osName := detectOS()

return SystemStats{
Resources: resources,
Uptime: uptime,
OS: "Linux",
OS: osName,
Hostname: hostname,
Version: h.version,
Timestamp: time.Now().Unix(),
}
}

// detectOS reads /etc/os-release to get the actual OS name and version
func detectOS() string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return "Linux" // Fallback
}

lines := strings.Split(string(data), "\n")
prettyName := ""
name := ""
version := ""

for _, line := range lines {
if strings.HasPrefix(line, "PRETTY_NAME=") {
prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`)
} else if strings.HasPrefix(line, "NAME=") {
name = strings.Trim(strings.TrimPrefix(line, "NAME="), `"`)
} else if strings.HasPrefix(line, "VERSION=") {
version = strings.Trim(strings.TrimPrefix(line, "VERSION="), `"`)
}
}

// Prefer PRETTY_NAME (e.g. "Ubuntu 24.04 LTS")
if prettyName != "" {
return prettyName
}
// Fallback to NAME + VERSION
if name != "" {
if version != "" {
return name + " " + version
}
return name
}
return "Linux"
}

type memInfo struct {
Total int64
Available int64
Expand Down
80 changes: 80 additions & 0 deletions internal/api/handlers/updates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Package handlers provides HTTP handlers for the update system.
package handlers

import (
"encoding/json"
"log/slog"
"net/http"

"github.com/narvanalabs/control-plane/internal/updater"
)

// UpdatesHandler handles update-related requests.
type UpdatesHandler struct {
updater *updater.Service
logger *slog.Logger
}

// NewUpdatesHandler creates a new updates handler.
func NewUpdatesHandler(updaterService *updater.Service, logger *slog.Logger) *UpdatesHandler {
return &UpdatesHandler{
updater: updaterService,
logger: logger,
}
}

// CheckForUpdates handles GET /v1/updates/check
func (h *UpdatesHandler) CheckForUpdates(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

info, err := h.updater.CheckForUpdates(ctx)
if err != nil {
h.logger.Error("failed to check for updates", "error", err)
WriteJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Failed to check for updates",
})
return
}

WriteJSON(w, http.StatusOK, info)
}

// TriggerUpdate handles POST /v1/updates/apply
func (h *UpdatesHandler) TriggerUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

var req struct {
Version string `json:"version"`
}

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
return
}

if req.Version == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "Version is required",
})
return
}

// Start the update process
if err := h.updater.ApplyUpdate(ctx, req.Version); err != nil {
h.logger.Error("failed to apply update", "error", err, "version", req.Version)
WriteJSON(w, http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
return
}

h.logger.Info("update initiated successfully", "version", req.Version)
WriteJSON(w, http.StatusOK, map[string]interface{}{
"status": "success",
"message": "Update initiated. Services will restart shortly.",
"version": req.Version,
})
}

9 changes: 8 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/narvanalabs/control-plane/internal/queue"
"github.com/narvanalabs/control-plane/internal/secrets"
"github.com/narvanalabs/control-plane/internal/store"
"github.com/narvanalabs/control-plane/internal/updater"
"github.com/narvanalabs/control-plane/pkg/config"
)

Expand Down Expand Up @@ -336,10 +337,16 @@ func (s *Server) setupRouter() {
r.Post("/server/restart", serverLogsHandler.Restart)
r.Get("/server/console/ws", serverLogsHandler.TerminalWS)

serverStatsHandler := handlers.NewServerStatsHandler(s.logger)
serverStatsHandler := handlers.NewServerStatsHandler(s.logger, Version)
r.Get("/server/stats", serverStatsHandler.Get)
r.Get("/server/stats/stream", serverStatsHandler.Stream)

// Update routes
updaterService := updater.NewService(Version, "narvanalabs/control-plane", s.logger)
updatesHandler := handlers.NewUpdatesHandler(updaterService, s.logger)
r.Get("/updates/check", updatesHandler.CheckForUpdates)
r.Post("/updates/apply", updatesHandler.TriggerUpdate)

// Admin cleanup routes
// Requirements: 19.1, 19.2, 19.3, 19.4, 25.4, 26.4
podmanClientForCleanup := podman.NewClient(s.config.Worker.PodmanSocket, s.logger)
Expand Down
Loading