Skip to content

Commit 82c2662

Browse files
committed
feat(updater): Implement update system with API endpoints and service integration
- Added an updater service to check for and apply updates, integrating with GitHub releases. - Introduced new API endpoints for checking available updates and triggering updates. - Created a systemd service for monitoring update requests and managing container restarts. - Enhanced the installation script to set up the update watcher service. - Updated the web interface to display update availability and trigger updates from the UI.
1 parent a8747d2 commit 82c2662

11 files changed

Lines changed: 530 additions & 0 deletions

File tree

deploy/compose.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ services:
2828
ports:
2929
- "${API_PORT:-8080}:8080"
3030
- "${GRPC_PORT:-9090}:9090"
31+
volumes:
32+
- /var/lib/narvana:/var/lib/narvana # Shared volume for update flags
3133
environment:
3234
DATABASE_URL: postgres://narvana:${POSTGRES_PASSWORD}@postgres:5432/narvana?sslmode=disable
3335
JWT_SECRET: ${JWT_SECRET}

deploy/narvana-updater.service

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[Unit]
2+
Description=Narvana Update Watcher
3+
Documentation=https://github.com/narvanalabs/control-plane
4+
After=network.target
5+
6+
[Service]
7+
Type=simple
8+
User=root
9+
WorkingDirectory=/opt/narvana
10+
ExecStart=/opt/narvana/update-watcher.sh
11+
Restart=always
12+
RestartSec=10
13+
14+
# Environment
15+
Environment=UPDATE_FLAG_FILE=/var/lib/narvana/.update-requested
16+
Environment=COMPOSE_FILE=/opt/narvana/compose.yaml
17+
Environment=ENV_FILE=/opt/narvana/.env
18+
19+
# Security
20+
NoNewPrivileges=false
21+
PrivateTmp=yes
22+
23+
[Install]
24+
WantedBy=multi-user.target
25+

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222

2323
require (
2424
filippo.io/hpke v0.4.0 // indirect
25+
github.com/Masterminds/semver/v3 v3.4.0 // indirect
2526
github.com/golang/protobuf v1.5.4 // indirect
2627
github.com/jackc/pgpassfile v1.0.0 // indirect
2728
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
4545
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
4646
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
4747
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
48+
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
49+
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
4850
github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE=
4951
github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
5052
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=

internal/api/handlers/updates.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Package handlers provides HTTP handlers for the update system.
2+
package handlers
3+
4+
import (
5+
"encoding/json"
6+
"log/slog"
7+
"net/http"
8+
9+
"github.com/narvanalabs/control-plane/internal/updater"
10+
)
11+
12+
// UpdatesHandler handles update-related requests.
13+
type UpdatesHandler struct {
14+
updater *updater.Service
15+
logger *slog.Logger
16+
}
17+
18+
// NewUpdatesHandler creates a new updates handler.
19+
func NewUpdatesHandler(updaterService *updater.Service, logger *slog.Logger) *UpdatesHandler {
20+
return &UpdatesHandler{
21+
updater: updaterService,
22+
logger: logger,
23+
}
24+
}
25+
26+
// CheckForUpdates handles GET /v1/updates/check
27+
func (h *UpdatesHandler) CheckForUpdates(w http.ResponseWriter, r *http.Request) {
28+
ctx := r.Context()
29+
30+
info, err := h.updater.CheckForUpdates(ctx)
31+
if err != nil {
32+
h.logger.Error("failed to check for updates", "error", err)
33+
WriteJSON(w, http.StatusInternalServerError, map[string]string{
34+
"error": "Failed to check for updates",
35+
})
36+
return
37+
}
38+
39+
WriteJSON(w, http.StatusOK, info)
40+
}
41+
42+
// TriggerUpdate handles POST /v1/updates/apply
43+
func (h *UpdatesHandler) TriggerUpdate(w http.ResponseWriter, r *http.Request) {
44+
ctx := r.Context()
45+
46+
var req struct {
47+
Version string `json:"version"`
48+
}
49+
50+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
51+
WriteJSON(w, http.StatusBadRequest, map[string]string{
52+
"error": "Invalid request body",
53+
})
54+
return
55+
}
56+
57+
if req.Version == "" {
58+
WriteJSON(w, http.StatusBadRequest, map[string]string{
59+
"error": "Version is required",
60+
})
61+
return
62+
}
63+
64+
// Start the update process
65+
if err := h.updater.ApplyUpdate(ctx, req.Version); err != nil {
66+
h.logger.Error("failed to apply update", "error", err, "version", req.Version)
67+
WriteJSON(w, http.StatusInternalServerError, map[string]string{
68+
"error": err.Error(),
69+
})
70+
return
71+
}
72+
73+
h.logger.Info("update initiated successfully", "version", req.Version)
74+
WriteJSON(w, http.StatusOK, map[string]interface{}{
75+
"status": "success",
76+
"message": "Update initiated. Services will restart shortly.",
77+
"version": req.Version,
78+
})
79+
}
80+

internal/api/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/narvanalabs/control-plane/internal/queue"
2020
"github.com/narvanalabs/control-plane/internal/secrets"
2121
"github.com/narvanalabs/control-plane/internal/store"
22+
"github.com/narvanalabs/control-plane/internal/updater"
2223
"github.com/narvanalabs/control-plane/pkg/config"
2324
)
2425

@@ -340,6 +341,12 @@ func (s *Server) setupRouter() {
340341
r.Get("/server/stats", serverStatsHandler.Get)
341342
r.Get("/server/stats/stream", serverStatsHandler.Stream)
342343

344+
// Update routes
345+
updaterService := updater.NewService(Version, "narvanalabs/control-plane", s.logger)
346+
updatesHandler := handlers.NewUpdatesHandler(updaterService, s.logger)
347+
r.Get("/updates/check", updatesHandler.CheckForUpdates)
348+
r.Post("/updates/apply", updatesHandler.TriggerUpdate)
349+
343350
// Admin cleanup routes
344351
// Requirements: 19.1, 19.2, 19.3, 19.4, 25.4, 26.4
345352
podmanClientForCleanup := podman.NewClient(s.config.Worker.PodmanSocket, s.logger)

internal/updater/updater.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Package updater provides functionality to check for and apply updates.
2+
package updater
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"log/slog"
10+
"net/http"
11+
"os"
12+
"os/exec"
13+
"strings"
14+
"time"
15+
16+
"github.com/Masterminds/semver/v3"
17+
)
18+
19+
// Service handles version checking and updates.
20+
type Service struct {
21+
currentVersion string
22+
githubRepo string
23+
logger *slog.Logger
24+
httpClient *http.Client
25+
}
26+
27+
// NewService creates a new updater service.
28+
func NewService(currentVersion, githubRepo string, logger *slog.Logger) *Service {
29+
return &Service{
30+
currentVersion: currentVersion,
31+
githubRepo: githubRepo,
32+
logger: logger,
33+
httpClient: &http.Client{
34+
Timeout: 10 * time.Second,
35+
},
36+
}
37+
}
38+
39+
// UpdateInfo contains information about available updates.
40+
type UpdateInfo struct {
41+
CurrentVersion string `json:"current_version"`
42+
LatestVersion string `json:"latest_version"`
43+
UpdateAvailable bool `json:"update_available"`
44+
ReleaseURL string `json:"release_url,omitempty"`
45+
ReleaseNotes string `json:"release_notes,omitempty"`
46+
PublishedAt string `json:"published_at,omitempty"`
47+
}
48+
49+
// GitHubRelease represents a GitHub release from the API.
50+
type GitHubRelease struct {
51+
TagName string `json:"tag_name"`
52+
Name string `json:"name"`
53+
Body string `json:"body"`
54+
HTMLURL string `json:"html_url"`
55+
PublishedAt time.Time `json:"published_at"`
56+
Prerelease bool `json:"prerelease"`
57+
Draft bool `json:"draft"`
58+
}
59+
60+
// CheckForUpdates queries GitHub for the latest release and compares with current version.
61+
func (s *Service) CheckForUpdates(ctx context.Context) (*UpdateInfo, error) {
62+
info := &UpdateInfo{
63+
CurrentVersion: s.currentVersion,
64+
}
65+
66+
// Skip check if version is "dev" or empty
67+
if s.currentVersion == "" || s.currentVersion == "dev" {
68+
s.logger.Debug("skipping update check for dev version")
69+
return info, nil
70+
}
71+
72+
// Fetch latest release from GitHub
73+
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", s.githubRepo)
74+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
75+
if err != nil {
76+
return info, fmt.Errorf("failed to create request: %w", err)
77+
}
78+
79+
req.Header.Set("Accept", "application/vnd.github.v3+json")
80+
req.Header.Set("User-Agent", "Narvana-Control-Plane")
81+
82+
resp, err := s.httpClient.Do(req)
83+
if err != nil {
84+
return info, fmt.Errorf("failed to fetch releases: %w", err)
85+
}
86+
defer resp.Body.Close()
87+
88+
if resp.StatusCode != http.StatusOK {
89+
body, _ := io.ReadAll(resp.Body)
90+
return info, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
91+
}
92+
93+
var release GitHubRelease
94+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
95+
return info, fmt.Errorf("failed to decode release: %w", err)
96+
}
97+
98+
// Skip drafts and prereleases for stable version checks
99+
if release.Draft || release.Prerelease {
100+
s.logger.Debug("latest release is draft or prerelease, skipping", "tag", release.TagName)
101+
return info, nil
102+
}
103+
104+
info.LatestVersion = release.TagName
105+
info.ReleaseURL = release.HTMLURL
106+
info.ReleaseNotes = release.Body
107+
info.PublishedAt = release.PublishedAt.Format(time.RFC3339)
108+
109+
// Compare versions
110+
updateAvailable, err := s.isNewerVersion(release.TagName)
111+
if err != nil {
112+
s.logger.Warn("failed to compare versions", "error", err)
113+
return info, nil // Don't fail, just return what we have
114+
}
115+
116+
info.UpdateAvailable = updateAvailable
117+
return info, nil
118+
}
119+
120+
// isNewerVersion compares the latest version with current version.
121+
func (s *Service) isNewerVersion(latestVersion string) (bool, error) {
122+
// Normalize version strings (remove 'v' prefix if present)
123+
current := strings.TrimPrefix(s.currentVersion, "v")
124+
latest := strings.TrimPrefix(latestVersion, "v")
125+
126+
currentVer, err := semver.NewVersion(current)
127+
if err != nil {
128+
return false, fmt.Errorf("invalid current version %q: %w", current, err)
129+
}
130+
131+
latestVer, err := semver.NewVersion(latest)
132+
if err != nil {
133+
return false, fmt.Errorf("invalid latest version %q: %w", latest, err)
134+
}
135+
136+
return latestVer.GreaterThan(currentVer), nil
137+
}
138+
139+
// ApplyUpdate performs the update by pulling new container images and restarting services.
140+
// This is designed to work in containerized deployments using Podman/Docker Compose.
141+
func (s *Service) ApplyUpdate(ctx context.Context, version string) error {
142+
s.logger.Info("applying update", "version", version)
143+
144+
// Determine deployment method
145+
deployMethod := s.detectDeploymentMethod()
146+
s.logger.Info("detected deployment method", "method", deployMethod)
147+
148+
switch deployMethod {
149+
case "compose":
150+
return s.updateCompose(ctx, version)
151+
case "systemd":
152+
return s.updateSystemd(ctx, version)
153+
default:
154+
return fmt.Errorf("unsupported deployment method: %s", deployMethod)
155+
}
156+
}
157+
158+
// detectDeploymentMethod checks how Narvana is deployed.
159+
func (s *Service) detectDeploymentMethod() string {
160+
// Check if running in a container (presence of /.dockerenv or /run/.containerenv)
161+
if _, err := os.Stat("/.dockerenv"); err == nil {
162+
return "compose"
163+
}
164+
if _, err := os.Stat("/run/.containerenv"); err == nil {
165+
return "compose"
166+
}
167+
168+
// Check for systemd service
169+
if _, err := exec.LookPath("systemctl"); err == nil {
170+
cmd := exec.Command("systemctl", "is-active", "narvana-api")
171+
if err := cmd.Run(); err == nil {
172+
return "systemd"
173+
}
174+
}
175+
176+
return "unknown"
177+
}
178+
179+
// updateCompose updates a Docker/Podman Compose deployment.
180+
func (s *Service) updateCompose(ctx context.Context, version string) error {
181+
// In a containerized environment, we need to trigger an external update script
182+
// or communicate with the host system to restart the compose stack.
183+
184+
// Set the desired version in the environment
185+
composeFile := os.Getenv("COMPOSE_FILE")
186+
if composeFile == "" {
187+
composeFile = "/app/compose.yaml" // Default location in container
188+
}
189+
190+
// Write a flag file that the host can monitor to trigger updates
191+
updateFlagFile := "/var/lib/narvana/.update-requested"
192+
content := fmt.Sprintf("version=%s\ntimestamp=%s\n", version, time.Now().Format(time.RFC3339))
193+
194+
if err := os.WriteFile(updateFlagFile, []byte(content), 0644); err != nil {
195+
return fmt.Errorf("failed to write update flag: %w", err)
196+
}
197+
198+
s.logger.Info("update flag written, waiting for external updater", "file", updateFlagFile, "version", version)
199+
200+
// Note: The actual container restart must be done externally (by systemd, cron, or a watcher)
201+
// because the API can't restart its own container from inside.
202+
return nil
203+
}
204+
205+
// updateSystemd updates a systemd-based deployment.
206+
func (s *Service) updateSystemd(ctx context.Context, version string) error {
207+
// For systemd deployments, we would:
208+
// 1. Download new binaries
209+
// 2. Restart services
210+
// This is more complex and depends on the installation method
211+
212+
return fmt.Errorf("systemd-based updates not yet implemented")
213+
}
214+

scripts/install.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,19 @@ main() {
279279
run_migrations
280280
wait_for_healthy
281281

282+
# Install update watcher (optional, requires systemd)
283+
if command -v systemctl &>/dev/null; then
284+
info "Installing update watcher service..."
285+
curl -fsSL "$GITHUB_RAW/scripts/update-watcher.sh" -o update-watcher.sh
286+
chmod +x update-watcher.sh
287+
curl -fsSL "$GITHUB_RAW/deploy/narvana-updater.service" -o /etc/systemd/system/narvana-updater.service
288+
sed -i "s|/opt/narvana|$INSTALL_DIR|g" /etc/systemd/system/narvana-updater.service
289+
systemctl daemon-reload
290+
systemctl enable narvana-updater
291+
systemctl start narvana-updater
292+
success "Update watcher service installed"
293+
fi
294+
282295
print_success
283296
}
284297

0 commit comments

Comments
 (0)