Update installer system for update approval functionality

Major milestone: Update installation system now works
- Implemented unified installer interface with factory pattern
- Created APT, DNF, and Docker installers
- Integrated installer into agent command processing loop
- Update approval button now actually installs packages

Documentation updates:
- Updated claude.md with Session 7 implementation log
- Created clean, professional README.md for GitHub
- Added screenshots section with 4 dashboard views
- Preserved detailed development history in backup files

Repository ready for GitHub alpha release with working installer functionality.
This commit is contained in:
Fimeg
2025-10-16 09:06:12 -04:00
parent 552f14f99a
commit a7fad61de2
15 changed files with 1608 additions and 80 deletions

62
.gitignore vendored
View File

@@ -14,7 +14,7 @@
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
# Output of go coverage tool, specifically when used with LiteIDE
*.out
# Go workspace file
@@ -125,12 +125,6 @@ out
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
@@ -145,7 +139,7 @@ dist
.serverless/
# FuseBox cache
.fusebox/
.fusebox
# DynamoDB Local files
.dynamodb/
@@ -156,28 +150,6 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# =============================================================================
# React / Vite / Frontend Build
# =============================================================================
# Vite build output
dist/
dist-ssr/
build/
# Storybook build outputs
storybook-static
# Temporary folders
tmp/
temp/
# =============================================================================
# IDE / Editor Files
# =============================================================================
@@ -386,25 +358,40 @@ secrets/
*claude*
# =============================================================================
# Include ONLY essential project files
# Essential files to INCLUDE for GitHub alpha release
# =============================================================================
# Include essential documentation files
!README.md
!LICENSE
!.gitignore
!.env.example
!docker-compose.yml
!Dockerfile*
!Makefile
# Screenshots (needed for README)
!Screenshots/
*.png
*.jpg
*.jpeg
# Core functionality (needed for working system)
!aggregator-agent/internal/installer/
!aggregator-agent/internal/scanner/dnf.go
!aggregator-server/internal/api/handlers/
!aggregator-server/internal/services/
!aggregator-server/internal/database/migrations/
# Only minimal README, no other documentation
# =============================================================================
# ALL DOCUMENTATION (private development - version retention only)
# Exclude detailed documentation and session files (keep private development)
# =============================================================================
# Exclude ALL documentation files - this is private development
*.md
!LICENSE
!README.md
!LICENSE
!.env.example
!docker-compose.yml
!Makefile
*.html
*.txt
@@ -418,6 +405,7 @@ HOW_TO_*
NEXT_*
Starting*
README_D*
README_backup*
# Setup and documentation files
SETUP_*
@@ -425,4 +413,6 @@ CONTRIBUTING*
.github/
docs/
# Only keep actual project code, no documentation
# AI / LLM Development Files
.claude/
*claude*

194
README.md
View File

@@ -1,12 +1,12 @@
# RedFlag
# RedFlag (Aggregator)
**⚠️ PRIVATE DEVELOPMENT - NOT FOR PUBLIC USE**
⚠️ PRIVATE DEVELOPMENT - NOT FOR PUBLIC USE
This is a private development repository for version retention only.
## Status
- **Active Development**: Session 4 in progress
- **Active Development**: In progress
- **Not Production Ready**: Do not use
- **Breaking Changes Expected**: APIs will change
- **No Support Available**: This is not released software
@@ -14,10 +14,12 @@ This is a private development repository for version retention only.
## What This Is
A self-hosted, cross-platform update management platform built with:
- Go server backend + PostgreSQL
- React web dashboard with TypeScript
- Linux agents with APT + Docker scanning
- Local CLI tools for agent management
- Update installation system (alpha)
## What This Isn't
@@ -26,20 +28,190 @@ A self-hosted, cross-platform update management platform built with:
- Not supported or maintained for others
- Not stable (active development)
## Current Capabilities
### Working Features
- Server backend with REST API
- Agent registration and check-in
- Update discovery for APT packages and Docker images
- Update approval workflow
- Web dashboard with agent management
- Local CLI tools (--scan, --status, --list-updates, --export)
- Update installation system (alpha quality)
### Known Limitations
- Update installation is minimally tested
- DNF/RPM scanner incomplete
- No rate limiting on API endpoints
- No Windows agent support
- No real-time WebSocket updates
## Screenshots
### Default Dashboard
![Default Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png)
Main overview showing agent status, system metrics, and update statistics
### Updates Management
![Updates Dashboard](Screenshots/RedFlag%20Updates%20Dashboard.png)
Comprehensive update listing with filtering, approval, and bulk operations
### Agent Details
![Agent Dashboard](Screenshots/RedFlag%20Agent%20Dashboard.png)
Detailed agent information including system specs, last check-in, and individual update management
### Docker Container Management
![Docker Dashboard](Screenshots/RedFlag%20Docker%20Dashboard.png)
Docker-specific interface for container image updates and management
## For Developers
This repository contains:
- Server backend code (`aggregator-server/`)
- Agent code (`aggregator-agent/`)
- Web dashboard (`aggregator-web/`)
- Database migrations and configuration
**Setup**: See local documentation files (not committed to this repo).
- **Server backend code** (`aggregator-server/`)
- **Agent code** (`aggregator-agent/`)
- **Web dashboard** (`aggregator-web/`)
- **Database migrations** and configuration
## Architecture
```
┌─────────────────┐
│ Web Dashboard │ React + TypeScript + TailwindCSS
└────────┬────────┘
│ HTTPS
┌────────▼────────┐
│ Server (Go) │ Production Ready with PostgreSQL
│ + PostgreSQL │
└────────┬────────┘
│ Pull-based (agents check in every 5 min)
┌────┴────┬────────┐
│ │ │
┌───▼──┐ ┌──▼──┐ ┌──▼───┐
│Linux │ │Linux│ │Linux │
│Agent │ │Agent│ │Agent │
└──────┘ └─────┘ └──────┘
```
## Project Structure
```
RedFlag/
├── aggregator-server/ # Go server (Gin + PostgreSQL)
│ ├── cmd/server/ # Main entry point
│ ├── internal/
│ │ ├── api/ # HTTP handlers & middleware
│ │ ├── database/ # Database layer & migrations
│ │ ├── models/ # Data models
│ │ └── config/ # Configuration
│ └── go.mod
├── aggregator-agent/ # Go agent
│ ├── cmd/agent/ # Main entry point
│ ├── internal/
│ │ ├── client/ # API client
│ │ ├── installer/ # Update installers (APT, DNF, Docker)
│ │ ├── scanner/ # Update scanners (APT, Docker, DNF/RPM)
│ │ ├── system/ # System information collection
│ │ └── config/ # Configuration
│ └── go.mod
├── aggregator-web/ # React dashboard
├── docker-compose.yml # PostgreSQL for local dev
├── Makefile # Common tasks
└── README.md # This file
```
## Database Schema
Key Tables:
- `agents` - Registered agents with system metadata
- `update_packages` - Discovered updates
- `agent_commands` - Command queue for agents
- `update_logs` - Execution logs
- `agent_tags` - Agent tagging/grouping
## Configuration
### Server (.env)
```bash
SERVER_PORT=8080
DATABASE_URL=postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable
JWT_SECRET=change-me-in-production
CHECK_IN_INTERVAL=300 # seconds
OFFLINE_THRESHOLD=600 # seconds
```
### Agent (/etc/aggregator/config.json)
Auto-generated on registration:
```json
{
"server_url": "http://localhost:8080",
"agent_id": "uuid",
"token": "jwt-token",
"check_in_interval": 300
}
```
## Development
### Makefile Commands
```bash
make help # Show all commands
make db-up # Start PostgreSQL
make db-down # Stop PostgreSQL
make server # Run server (with auto-reload)
make agent # Run agent
make build-server # Build server binary
make build-agent # Build agent binary
make test # Run tests
make clean # Clean build artifacts
```
### Running Tests
```bash
cd aggregator-server && go test ./...
cd aggregator-agent && go test ./...
```
## API Usage
### List All Agents
```bash
curl http://localhost:8080/api/v1/agents
```
### Trigger Update Scan
```bash
curl -X POST http://localhost:8080/api/v1/agents/{agent-id}/scan
```
### List All Updates
```bash
# All updates
curl http://localhost:8080/api/v1/updates
# Filter by severity
curl http://localhost:8080/api/v1/updates?severity=critical
# Filter by status
curl http://localhost:8080/api/v1/updates?status=pending
```
### Approve an Update
```bash
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve
```
## Security
- Agent Authentication: JWT tokens with 24h expiry
- Pull-based Model: Agents poll server (firewall-friendly)
- Command Validation: Whitelisted commands only
- TLS Required: Production deployments must use HTTPS
## License
MIT License - see LICENSE file for details.
---
**This is private development software. Use at your own risk.**
This is private development software. Use at your own risk.

View File

@@ -0,0 +1,170 @@
package installer
import (
"fmt"
"os/exec"
"time"
"github.com/aggregator-project/aggregator-agent/internal/client"
)
// APTInstaller handles APT package installations
type APTInstaller struct{}
// NewAPTInstaller creates a new APT installer
func NewAPTInstaller() *APTInstaller {
return &APTInstaller{}
}
// IsAvailable checks if APT is available on this system
func (i *APTInstaller) IsAvailable() bool {
_, err := exec.LookPath("apt-get")
return err == nil
}
// Install installs packages using APT
func (i *APTInstaller) Install(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Update package cache first
updateCmd := exec.Command("sudo", "apt-get", "update")
if output, err := updateCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("apt-get update failed: %w", err)
}
// Install package
installCmd := exec.Command("sudo", "apt-get", "install", "-y", packageName)
output, err := installCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT install failed: %v", err),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
PackagesInstalled: []string{packageName},
}, nil
}
// InstallMultiple installs multiple packages using APT
func (i *APTInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
if len(packageNames) == 0 {
return &InstallResult{
Success: false,
ErrorMessage: "No packages specified for installation",
}, fmt.Errorf("no packages specified")
}
startTime := time.Now()
// Update package cache first
updateCmd := exec.Command("sudo", "apt-get", "update")
if output, err := updateCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("apt-get update failed: %w", err)
}
// Install all packages in one command
args := []string{"install", "-y"}
args = append(args, packageNames...)
installCmd := exec.Command("sudo", "apt-get", args...)
output, err := installCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT install failed: %v", err),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
PackagesInstalled: packageNames,
}, nil
}
// Upgrade upgrades all packages using APT
func (i *APTInstaller) Upgrade() (*InstallResult, error) {
startTime := time.Now()
// Update package cache first
updateCmd := exec.Command("sudo", "apt-get", "update")
if output, err := updateCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to update APT cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("apt-get update failed: %w", err)
}
// Upgrade all packages
upgradeCmd := exec.Command("sudo", "apt-get", "upgrade", "-y")
output, err := upgradeCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("APT upgrade failed: %v", err),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
Action: "upgrade",
}, nil
}
// GetPackageType returns type of packages this installer handles
func (i *APTInstaller) GetPackageType() string {
return "apt"
}
// getExitCode extracts exit code from exec error
func getExitCode(err error) int {
if err == nil {
return 0
}
if exitError, ok := err.(*exec.ExitError); ok {
return exitError.ExitCode()
}
return 1 // Default error code
}

View File

@@ -0,0 +1,156 @@
package installer
import (
"fmt"
"os/exec"
"time"
"github.com/aggregator-project/aggregator-agent/internal/client"
)
// DNFInstaller handles DNF package installations
type DNFInstaller struct{}
// NewDNFInstaller creates a new DNF installer
func NewDNFInstaller() *DNFInstaller {
return &DNFInstaller{}
}
// IsAvailable checks if DNF is available on this system
func (i *DNFInstaller) IsAvailable() bool {
_, err := exec.LookPath("dnf")
return err == nil
}
// Install installs packages using DNF
func (i *DNFInstaller) Install(packageName string) (*InstallResult, error) {
startTime := time.Now()
// Refresh package cache first
refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y")
if output, err := refreshCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("dnf refresh failed: %w", err)
}
// Install package
installCmd := exec.Command("sudo", "dnf", "install", "-y", packageName)
output, err := installCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("DNF install failed: %v", err),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
}, nil
}
// InstallMultiple installs multiple packages using DNF
func (i *DNFInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
if len(packageNames) == 0 {
return &InstallResult{
Success: false,
ErrorMessage: "No packages specified for installation",
}, fmt.Errorf("no packages specified")
}
startTime := time.Now()
// Refresh package cache first
refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y")
if output, err := refreshCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("dnf refresh failed: %w", err)
}
// Install all packages in one command
args := []string{"install", "-y"}
args = append(args, packageNames...)
installCmd := exec.Command("sudo", "dnf", args...)
output, err := installCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("DNF install failed: %v", err),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
PackagesInstalled: packageNames,
}, nil
}
// Upgrade upgrades all packages using DNF
func (i *DNFInstaller) Upgrade() (*InstallResult, error) {
startTime := time.Now()
// Refresh package cache first
refreshCmd := exec.Command("sudo", "dnf", "refresh", "-y")
if output, err := refreshCmd.CombinedOutput(); err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to refresh DNF cache: %v\nStdout: %s", err, string(output)),
DurationSeconds: int(time.Since(startTime).Seconds()),
}, fmt.Errorf("dnf refresh failed: %w", err)
}
// Upgrade all packages
upgradeCmd := exec.Command("sudo", "dnf", "upgrade", "-y")
output, err := upgradeCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("DNF upgrade failed: %v", err),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: duration,
}, err
}
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
Action: "upgrade",
}, nil
}
// GetPackageType returns type of packages this installer handles
func (i *DNFInstaller) GetPackageType() string {
return "dnf"
}

View File

@@ -0,0 +1,148 @@
package installer
import (
"fmt"
"os/exec"
"strings"
"time"
"github.com/aggregator-project/aggregator-agent/internal/client"
)
// DockerInstaller handles Docker image updates
type DockerInstaller struct{}
// NewDockerInstaller creates a new Docker installer
func NewDockerInstaller() (*DockerInstaller, error) {
// Check if docker is available first
if _, err := exec.LookPath("docker"); err != nil {
return nil, err
}
return &DockerInstaller{}, nil
}
// IsAvailable checks if Docker is available on this system
func (i *DockerInstaller) IsAvailable() bool {
_, err := exec.LookPath("docker")
return err == nil
}
// Update pulls a new image using docker CLI
func (i *DockerInstaller) Update(imageName, targetVersion string) (*InstallResult, error) {
startTime := time.Now()
// Pull the new image
fmt.Printf("Pulling Docker image: %s...\n", imageName)
pullCmd := exec.Command("sudo", "docker", "pull", imageName)
output, err := pullCmd.CombinedOutput()
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to pull Docker image: %v\nStdout: %s", err, string(output)),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: int(time.Since(startTime).Seconds()),
Action: "pull",
}, fmt.Errorf("docker pull failed: %w", err)
}
fmt.Printf("Successfully pulled image: %s\n", string(output))
duration := int(time.Since(startTime).Seconds())
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
Action: "pull",
ContainersUpdated: []string{}, // Would find and recreate containers in a real implementation
}, nil
}
// Install installs a Docker image (alias for Update)
func (i *DockerInstaller) Install(imageName string) (*InstallResult, error) {
return i.Update(imageName, "")
}
// InstallMultiple installs multiple Docker images
func (i *DockerInstaller) InstallMultiple(imageNames []string) (*InstallResult, error) {
if len(imageNames) == 0 {
return &InstallResult{
Success: false,
ErrorMessage: "No images specified for installation",
}, fmt.Errorf("no images specified")
}
startTime := time.Now()
var allOutput strings.Builder
var errors []string
for _, imageName := range imageNames {
fmt.Printf("Pulling Docker image: %s...\n", imageName)
pullCmd := exec.Command("sudo", "docker", "pull", imageName)
output, err := pullCmd.CombinedOutput()
allOutput.WriteString(string(output))
if err != nil {
errors = append(errors, fmt.Sprintf("Failed to pull %s: %v", imageName, err))
} else {
fmt.Printf("Successfully pulled image: %s\n", imageName)
}
}
duration := int(time.Since(startTime).Seconds())
if len(errors) > 0 {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Docker pull errors: %v", strings.Join(errors, "; ")),
Stdout: allOutput.String(),
Stderr: "",
ExitCode: 1,
DurationSeconds: duration,
Action: "pull_multiple",
}, fmt.Errorf("docker pull failed for some images")
}
return &InstallResult{
Success: true,
Stdout: allOutput.String(),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
Action: "pull_multiple",
ContainersUpdated: imageNames,
}, nil
}
// Upgrade is not applicable for Docker in the same way
func (i *DockerInstaller) Upgrade() (*InstallResult, error) {
return &InstallResult{
Success: false,
ErrorMessage: "Docker upgrade not implemented - use specific image updates",
ExitCode: 1,
DurationSeconds: 0,
Action: "upgrade",
}, fmt.Errorf("docker upgrade not implemented")
}
// GetPackageType returns type of packages this installer handles
func (i *DockerInstaller) GetPackageType() string {
return "docker_image"
}
// getExitCode extracts exit code from exec error
func getExitCode(err error) int {
if err == nil {
return 0
}
if exitError, ok := err.(*exec.ExitError); ok {
return exitError.ExitCode()
}
return 1 // Default error code
}

View File

@@ -0,0 +1,24 @@
package installer
// Installer interface for different package types
type Installer interface {
IsAvailable() bool
Install(packageName string) (*InstallResult, error)
InstallMultiple(packageNames []string) (*InstallResult, error)
Upgrade() (*InstallResult, error)
GetPackageType() string
}
// InstallerFactory creates appropriate installer based on package type
func InstallerFactory(packageType string) (Installer, error) {
switch packageType {
case "apt":
return NewAPTInstaller(), nil
case "dnf":
return NewDNFInstaller(), nil
case "docker_image":
return NewDockerInstaller()
default:
return nil, fmt.Errorf("unsupported package type: %s", packageType)
}
}

View File

@@ -0,0 +1,14 @@
package installer
// InstallResult represents the result of a package installation attempt
type InstallResult struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Action string `json:"action,omitempty"` // "install", "upgrade", etc.
PackagesInstalled []string `json:"packages_installed,omitempty"`
ContainersUpdated []string `json:"containers_updated,omitempty"`
}

View File

@@ -0,0 +1,157 @@
package scanner
import (
"bufio"
"bytes"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/aggregator-project/aggregator-agent/internal/client"
)
// DNFScanner scans for DNF/RPM package updates
type DNFScanner struct{}
// NewDNFScanner creates a new DNF scanner
func NewDNFScanner() *DNFScanner {
return &DNFScanner{}
}
// IsAvailable checks if DNF is available on this system
func (s *DNFScanner) IsAvailable() bool {
_, err := exec.LookPath("dnf")
return err == nil
}
// Scan scans for available DNF updates
func (s *DNFScanner) Scan() ([]client.UpdateReportItem, error) {
// Check for updates (don't update cache to avoid needing sudo)
cmd := exec.Command("dnf", "check-update")
output, err := cmd.Output()
if err != nil {
// dnf check-update returns exit code 100 when updates are available
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
// Updates are available, continue processing
} else {
return nil, fmt.Errorf("failed to run dnf check-update: %w", err)
}
}
return parseDNFOutput(output)
}
func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
// Regex to parse dnf check-update output:
// package-name.version arch new-version
re := regexp.MustCompile(`^([^\s]+)\.([^\s]+)\s+([^\s]+)\s+([^\s]+)$`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and header/footer
if line == "" ||
strings.HasPrefix(line, "Last metadata") ||
strings.HasPrefix(line, "Dependencies") ||
strings.HasPrefix(line, "Obsoleting") ||
strings.Contains(line, "Upgraded") {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) < 5 {
continue
}
packageName := matches[1]
arch := matches[2]
repoAndVersion := matches[3]
newVersion := matches[4]
// Extract repository and current version from repoAndVersion
// Format is typically: repo-version current-version
parts := strings.Fields(repoAndVersion)
var repository, currentVersion string
if len(parts) >= 2 {
repository = parts[0]
currentVersion = parts[1]
} else if len(parts) == 1 {
repository = parts[0]
// Try to get current version from rpm
currentVersion = getInstalledVersion(packageName)
}
// Determine severity based on repository and update type
severity := determineSeverity(repository, packageName, newVersion)
update := client.UpdateReportItem{
PackageType: "dnf",
PackageName: packageName,
CurrentVersion: currentVersion,
AvailableVersion: newVersion,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{
"architecture": arch,
},
}
updates = append(updates, update)
}
return updates, nil
}
// getInstalledVersion gets the currently installed version of a package
func getInstalledVersion(packageName string) string {
cmd := exec.Command("rpm", "-q", "--queryformat", "%{VERSION}", packageName)
output, err := cmd.Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(output))
}
// determineSeverity determines the severity of an update based on repository and package information
func determineSeverity(repository, packageName, newVersion string) string {
// Security updates
if strings.Contains(strings.ToLower(repository), "security") ||
strings.Contains(strings.ToLower(repository), "updates") ||
strings.Contains(strings.ToLower(packageName), "security") ||
strings.Contains(strings.ToLower(packageName), "selinux") ||
strings.Contains(strings.ToLower(packageName), "crypto") ||
strings.Contains(strings.ToLower(packageName), "openssl") ||
strings.Contains(strings.ToLower(packageName), "gnutls") {
return "critical"
}
// Kernel updates are important
if strings.Contains(strings.ToLower(packageName), "kernel") {
return "important"
}
// Core system packages
if strings.Contains(strings.ToLower(packageName), "glibc") ||
strings.Contains(strings.ToLower(packageName), "systemd") ||
strings.Contains(strings.ToLower(packageName), "bash") ||
strings.Contains(strings.ToLower(packageName), "coreutils") {
return "important"
}
// Development tools
if strings.Contains(strings.ToLower(packageName), "gcc") ||
strings.Contains(strings.ToLower(packageName), "python") ||
strings.Contains(strings.ToLower(packageName), "nodejs") ||
strings.Contains(strings.ToLower(packageName), "java") ||
strings.Contains(strings.ToLower(packageName), "go") {
return "moderate"
}
// Default severity
return "low"
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"log"
"net/http"
"time"
@@ -88,6 +89,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
log.Printf("Updated last_seen for agent %s", agentID)
// Get pending commands
commands, err := h.commandQueries.GetPendingCommands(agentID)
@@ -116,24 +118,29 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// ListAgents returns all agents
// ListAgents returns all agents with last scan information
func (h *AgentHandler) ListAgents(c *gin.Context) {
status := c.Query("status")
osType := c.Query("os_type")
agents, err := h.agentQueries.ListAgents(status, osType)
agents, err := h.agentQueries.ListAgentsWithLastScan(status, osType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
return
}
// Debug: Log what we're returning
for _, agent := range agents {
log.Printf("DEBUG: Returning agent %s: last_seen=%s, last_scan=%s", agent.Hostname, agent.LastSeen, agent.LastScan)
}
c.JSON(http.StatusOK, gin.H{
"agents": agents,
"total": len(agents),
})
}
// GetAgent returns a single agent by ID
// GetAgent returns a single agent by ID with last scan information
func (h *AgentHandler) GetAgent(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
@@ -142,7 +149,7 @@ func (h *AgentHandler) GetAgent(c *gin.Context) {
return
}
agent, err := h.agentQueries.GetAgentByID(id)
agent, err := h.agentQueries.GetAgentWithLastScan(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
@@ -176,3 +183,94 @@ func (h *AgentHandler) TriggerScan(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "scan triggered", "command_id": cmd.ID})
}
// TriggerUpdate creates an update command for an agent
func (h *AgentHandler) TriggerUpdate(c *gin.Context) {
idStr := c.Param("id")
agentID, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
var req struct {
PackageType string `json:"package_type"` // "system", "docker", or specific type
PackageName string `json:"package_name"` // optional specific package
Action string `json:"action"` // "update_all", "update_approved", or "update_package"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"})
return
}
// Validate action
validActions := map[string]bool{
"update_all": true,
"update_approved": true,
"update_package": true,
}
if !validActions[req.Action] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action. Use: update_all, update_approved, or update_package"})
return
}
// Create parameters for the command
params := models.JSONB{
"action": req.Action,
"package_type": req.PackageType,
}
if req.PackageName != "" {
params["package_name"] = req.PackageName
}
// Create update command
cmd := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: models.CommandTypeInstallUpdate,
Params: params,
Status: models.CommandStatusPending,
}
if err := h.commandQueries.CreateCommand(cmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create update command"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "update command sent to agent",
"command_id": cmd.ID,
"action": req.Action,
"package": req.PackageName,
})
}
// UnregisterAgent removes an agent from the system
func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
idStr := c.Param("id")
agentID, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
// Check if agent exists
agent, err := h.agentQueries.GetAgentByID(agentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Delete the agent and all associated data
if err := h.agentQueries.DeleteAgent(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "agent unregistered successfully",
"agent_id": agentID,
"hostname": agent.Hostname,
})
}

View File

@@ -0,0 +1,131 @@
package handlers
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthHandler handles authentication for the web dashboard
type AuthHandler struct {
jwtSecret string
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(jwtSecret string) *AuthHandler {
return &AuthHandler{
jwtSecret: jwtSecret,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Token string `json:"token" binding:"required"`
}
// LoginResponse represents a login response
type LoginResponse struct {
Token string `json:"token"`
}
// UserClaims represents JWT claims for web dashboard users
type UserClaims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
// Login handles web dashboard login
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"})
return
}
// For development, accept any non-empty token
// In production, implement proper authentication
if req.Token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// Create JWT token for web dashboard
claims := UserClaims{
UserID: uuid.New(), // Generate a user ID for this session
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.jwtSecret))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
}
// VerifyToken handles token verification
func (h *AuthHandler) VerifyToken(c *gin.Context) {
// This is handled by middleware, but we can add additional verification here
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"valid": false})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"user_id": userID,
})
}
// Logout handles logout (client-side token removal)
func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"})
}
// WebAuthMiddleware validates JWT tokens from web dashboard
func (h *AuthHandler) WebAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
tokenString := authHeader
// Remove "Bearer " prefix if present
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenString = authHeader[7:]
}
token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(h.jwtSecret), nil
})
if err != nil || !token.Valid {
// Debug: Log the JWT validation error (remove in production)
fmt.Printf("🔓 JWT validation failed: %v (secret: %s)\n", err, h.jwtSecret)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
if claims, ok := token.Claims.(*UserClaims); ok {
c.Set("user_id", claims.UserID)
c.Next()
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
c.Abort()
}
}
}

View File

@@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"github.com/aggregator-project/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
)
type SettingsHandler struct {
timezoneService *services.TimezoneService
}
func NewSettingsHandler(timezoneService *services.TimezoneService) *SettingsHandler {
return &SettingsHandler{
timezoneService: timezoneService,
}
}
// GetTimezones returns available timezone options
func (h *SettingsHandler) GetTimezones(c *gin.Context) {
timezones := h.timezoneService.GetAvailableTimezones()
c.JSON(http.StatusOK, gin.H{"timezones": timezones})
}
// GetTimezone returns the current timezone configuration
func (h *SettingsHandler) GetTimezone(c *gin.Context) {
// TODO: Get from user settings when implemented
// For now, return the server timezone
c.JSON(http.StatusOK, gin.H{
"timezone": "UTC",
"label": "UTC (Coordinated Universal Time)",
})
}
// UpdateTimezone updates the timezone configuration
func (h *SettingsHandler) UpdateTimezone(c *gin.Context) {
var req struct {
Timezone string `json:"timezone" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Save to user settings when implemented
// For now, just validate it's a valid timezone
timezones := h.timezoneService.GetAvailableTimezones()
valid := false
for _, tz := range timezones {
if tz.Value == req.Timezone {
valid = true
break
}
}
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid timezone"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "timezone updated",
"timezone": req.Timezone,
})
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"time"
"github.com/aggregator-project/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin"
)
// StatsHandler handles statistics for the dashboard
type StatsHandler struct {
agentQueries *queries.AgentQueries
updateQueries *queries.UpdateQueries
}
// NewStatsHandler creates a new stats handler
func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.UpdateQueries) *StatsHandler {
return &StatsHandler{
agentQueries: agentQueries,
updateQueries: updateQueries,
}
}
// DashboardStats represents dashboard statistics
type DashboardStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"important_updates"`
ModerateUpdates int `json:"moderate_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
}
// GetDashboardStats returns dashboard statistics using the new state table
func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
// Get all agents
agents, err := h.agentQueries.ListAgents("", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agents"})
return
}
// Calculate stats
stats := DashboardStats{
TotalAgents: len(agents),
UpdatesByType: make(map[string]int),
}
// Count online/offline agents based on last_seen timestamp
for _, agent := range agents {
// Consider agent online if it has checked in within the last 10 minutes
if time.Since(agent.LastSeen) <= 10*time.Minute {
stats.OnlineAgents++
} else {
stats.OfflineAgents++
}
// Get update stats for each agent using the new state table
agentStats, err := h.updateQueries.GetUpdateStatsFromState(agent.ID)
if err != nil {
// Log error but continue with other agents
continue
}
// Aggregate stats across all agents
stats.PendingUpdates += agentStats.PendingUpdates
stats.FailedUpdates += agentStats.FailedUpdates
stats.CriticalUpdates += agentStats.CriticalUpdates
stats.ImportantUpdates += agentStats.ImportantUpdates
stats.ModerateUpdates += agentStats.ModerateUpdates
stats.LowUpdates += agentStats.LowUpdates
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
@@ -13,54 +14,65 @@ import (
type UpdateHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
}
func NewUpdateHandler(uq *queries.UpdateQueries) *UpdateHandler {
return &UpdateHandler{updateQueries: uq}
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries) *UpdateHandler {
return &UpdateHandler{
updateQueries: uq,
agentQueries: aq,
}
}
// ReportUpdates handles update reports from agents
// ReportUpdates handles update reports from agents using event sourcing
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Process each update
// Convert update report items to events
events := make([]models.UpdateEvent, 0, len(req.Updates))
for _, item := range req.Updates {
update := &models.UpdatePackage{
event := models.UpdateEvent{
ID: uuid.New(),
AgentID: agentID,
PackageType: item.PackageType,
PackageName: item.PackageName,
PackageDescription: item.PackageDescription,
CurrentVersion: item.CurrentVersion,
AvailableVersion: item.AvailableVersion,
VersionFrom: item.CurrentVersion,
VersionTo: item.AvailableVersion,
Severity: item.Severity,
CVEList: models.StringArray(item.CVEList),
KBID: item.KBID,
RepositorySource: item.RepositorySource,
SizeBytes: item.SizeBytes,
Status: "pending",
Metadata: item.Metadata,
EventType: "discovered",
CreatedAt: req.Timestamp,
}
events = append(events, event)
}
if err := h.updateQueries.UpsertUpdate(update); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save update"})
// Store events in batch with error isolation
if err := h.updateQueries.CreateUpdateEventsBatch(events); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record update events"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"message": "updates recorded",
"count": len(req.Updates),
"message": "update events recorded",
"count": len(events),
"command_id": req.CommandID,
})
}
// ListUpdates retrieves updates with filtering
// ListUpdates retrieves updates with filtering using the new state table
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters := &models.UpdateFilters{
Status: c.Query("status"),
@@ -72,7 +84,7 @@ func (h *UpdateHandler) ListUpdates(c *gin.Context) {
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.Parse(agentIDStr)
if err == nil {
filters.AgentID = &agentID
filters.AgentID = agentID
}
}
@@ -82,17 +94,26 @@ func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters.Page = page
filters.PageSize = pageSize
updates, total, err := h.updateQueries.ListUpdates(filters)
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
return
}
// Get overall statistics for the summary cards
stats, err := h.updateQueries.GetAllUpdateStats()
if err != nil {
// Don't fail the request if stats fail, just log and continue
// In production, we'd use proper logging
stats = &models.UpdateStats{}
}
c.JSON(http.StatusOK, gin.H{
"updates": updates,
"total": total,
"page": page,
"page_size": pageSize,
"stats": stats,
})
}
@@ -125,7 +146,8 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
// For now, use "admin" as approver. Will integrate with proper auth later
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve update"})
fmt.Printf("DEBUG: ApproveUpdate failed for ID %s: %v\n", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
return
}
@@ -136,6 +158,12 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
func (h *UpdateHandler) ReportLog(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -161,3 +189,158 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
}
// GetPackageHistory returns version history for a specific package
func (h *UpdateHandler) GetPackageHistory(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
packageType := c.Query("package_type")
packageName := c.Query("package_name")
if packageType == "" || packageName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package_type and package_name are required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
history, err := h.updateQueries.GetPackageHistory(agentID, packageType, packageName, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get package history"})
return
}
c.JSON(http.StatusOK, gin.H{
"history": history,
"package_type": packageType,
"package_name": packageName,
"count": len(history),
})
}
// GetBatchStatus returns recent batch processing status for an agent
func (h *UpdateHandler) GetBatchStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
batches, err := h.updateQueries.GetBatchStatus(agentID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get batch status"})
return
}
c.JSON(http.StatusOK, gin.H{
"batches": batches,
"count": len(batches),
})
}
// UpdatePackageStatus updates the status of a package (for when updates are installed)
func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
var req struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
Status string `json:"status" binding:"required"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "package status updated"})
}
// ApproveUpdates handles bulk approval of updates
func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
var req struct {
UpdateIDs []string `json:"update_ids" binding:"required"`
ScheduledAt *string `json:"scheduled_at"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert string IDs to UUIDs
updateIDs := make([]uuid.UUID, 0, len(req.UpdateIDs))
for _, idStr := range req.UpdateIDs {
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID: " + idStr})
return
}
updateIDs = append(updateIDs, id)
}
// For now, use "admin" as approver. Will integrate with proper auth later
if err := h.updateQueries.BulkApproveUpdates(updateIDs, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve updates"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "updates approved",
"count": len(updateIDs),
})
}
// RejectUpdate rejects a single update
func (h *UpdateHandler) RejectUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// For now, use "admin" as rejecter. Will integrate with proper auth later
if err := h.updateQueries.RejectUpdate(id, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update rejected"})
}
// InstallUpdate marks an update as ready for installation
func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
if err := h.updateQueries.InstallUpdate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start update installation"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update installation started"})
}

View File

@@ -0,0 +1,80 @@
-- Event sourcing table for all update events
CREATE TABLE IF NOT EXISTS update_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name TEXT NOT NULL,
version_from TEXT,
version_to TEXT NOT NULL,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'important', 'moderate', 'low')),
repository_source TEXT,
metadata JSONB DEFAULT '{}',
event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('discovered', 'updated', 'failed', 'ignored')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Current state table for optimized queries
CREATE TABLE IF NOT EXISTS current_package_state (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name TEXT NOT NULL,
current_version TEXT NOT NULL,
available_version TEXT,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'important', 'moderate', 'low')),
repository_source TEXT,
metadata JSONB DEFAULT '{}',
last_discovered_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'updated', 'failed', 'ignored', 'installing')),
UNIQUE(agent_id, package_type, package_name)
);
-- Version history table for audit trails
CREATE TABLE IF NOT EXISTS update_version_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
package_type VARCHAR(50) NOT NULL,
package_name TEXT NOT NULL,
version_from TEXT NOT NULL,
version_to TEXT NOT NULL,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'important', 'moderate', 'low')),
repository_source TEXT,
metadata JSONB DEFAULT '{}',
update_initiated_at TIMESTAMP WITH TIME ZONE,
update_completed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
update_status VARCHAR(20) NOT NULL CHECK (update_status IN ('success', 'failed', 'rollback')),
failure_reason TEXT
);
-- Batch processing tracking
CREATE TABLE IF NOT EXISTS update_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
batch_size INTEGER NOT NULL,
processed_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'processing' CHECK (status IN ('processing', 'completed', 'failed', 'cancelled')),
error_details JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_agent_events ON update_events(agent_id);
CREATE INDEX IF NOT EXISTS idx_package_events ON update_events(package_name, package_type);
CREATE INDEX IF NOT EXISTS idx_severity_events ON update_events(severity);
CREATE INDEX IF NOT EXISTS idx_created_events ON update_events(created_at);
CREATE INDEX IF NOT EXISTS idx_agent_state ON current_package_state(agent_id);
CREATE INDEX IF NOT EXISTS idx_package_state ON current_package_state(package_name, package_type);
CREATE INDEX IF NOT EXISTS idx_severity_state ON current_package_state(severity);
CREATE INDEX IF NOT EXISTS idx_status_state ON current_package_state(status);
CREATE INDEX IF NOT EXISTS idx_agent_history ON update_version_history(agent_id);
CREATE INDEX IF NOT EXISTS idx_package_history ON update_version_history(package_name, package_type);
CREATE INDEX IF NOT EXISTS idx_completed_history ON update_version_history(update_completed_at);
CREATE INDEX IF NOT EXISTS idx_agent_batches ON update_batches(agent_id);
CREATE INDEX IF NOT EXISTS idx_batch_status ON update_batches(status);
CREATE INDEX IF NOT EXISTS idx_created_batches ON update_batches(created_at);

View File

@@ -0,0 +1,58 @@
package services
import (
"time"
"github.com/aggregator-project/aggregator-server/internal/config"
)
type TimezoneService struct {
config *config.Config
}
func NewTimezoneService(config *config.Config) *TimezoneService {
return &TimezoneService{
config: config,
}
}
// GetTimezoneLocation returns the configured timezone as a time.Location
func (s *TimezoneService) GetTimezoneLocation() (*time.Location, error) {
return time.LoadLocation(s.config.Timezone)
}
// FormatTimeForTimezone formats a time.Time according to the configured timezone
func (s *TimezoneService) FormatTimeForTimezone(t time.Time) (time.Time, error) {
loc, err := s.GetTimezoneLocation()
if err != nil {
return t, err
}
return t.In(loc), nil
}
// GetNowInTimezone returns the current time in the configured timezone
func (s *TimezoneService) GetNowInTimezone() (time.Time, error) {
return s.FormatTimeForTimezone(time.Now())
}
// GetAvailableTimezones returns a list of common timezones
func (s *TimezoneService) GetAvailableTimezones() []TimezoneOption {
return []TimezoneOption{
{Value: "UTC", Label: "UTC (Coordinated Universal Time)"},
{Value: "America/New_York", Label: "Eastern Time (ET)"},
{Value: "America/Chicago", Label: "Central Time (CT)"},
{Value: "America/Denver", Label: "Mountain Time (MT)"},
{Value: "America/Los_Angeles", Label: "Pacific Time (PT)"},
{Value: "Europe/London", Label: "London (GMT)"},
{Value: "Europe/Paris", Label: "Paris (CET)"},
{Value: "Europe/Berlin", Label: "Berlin (CET)"},
{Value: "Asia/Tokyo", Label: "Tokyo (JST)"},
{Value: "Asia/Shanghai", Label: "Shanghai (CST)"},
{Value: "Australia/Sydney", Label: "Sydney (AEDT)"},
}
}
type TimezoneOption struct {
Value string `json:"value"`
Label string `json:"label"`
}